From 1f35cf05b5bcf8e2baba217b4732c890aa5217db Mon Sep 17 00:00:00 2001 From: Joloxx9 Date: Wed, 30 Sep 2026 02:02:56 +0100 Subject: [PATCH 01/87] feat(playback): add independent subtitle text opacity and a gray color option (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(playback): add independent subtitle text opacity and a gray color option White subtitle text against a dark background is uncomfortably bright on an OLED or HDR display. The subtitle appearance settings had no way to dim the text itself: backgroundOpacity only affects the box behind it, and the font color palette had no gray option. Adds a textOpacity field (1-100, default 100) alongside the existing backgroundOpacity, applied to the text color independently of the background, plus a gray swatch in the font color palette, on both phone and TV. Both opacity controls moved from a slider to a typed percent field (phone) or a D-pad picker (TV, matching the existing paradigm) after a slider proved too imprecise to hit low values. Also re-vendors the settings contract at manifest revision 13 and fixes a found bug where the percent field committed on every keystroke instead of on blur, which could leave the displayed value out of sync with what was saved. Co-Authored-By: Claude Sonnet 5 * fix(playback): keep TV opacity pickers in sync with off-cadence values The TV background/text opacity pickers only listed their fixed step values (0/25/50/75/100 or every 5), so a value set via the phone's free-typed percent field could have no matching option. Opening the picker then selected the first step, and pressing Select silently overwrote the real value. Fixed with a shared helper that unions the step list with the current value, matching the existing delayPicker pattern. Also commits a pending opacity edit when the subtitle sheet is dismissed while the field is still focused, since that tears the composable down without firing onFocusChanged(false). Found by CodeRabbit and Kody review on PR #413. Co-Authored-By: Claude Sonnet 5 * fix(playback): apply subtitle-appearance edits as a transform, not a snapshot Every control in the phone subtitle-style sheet built its update as appearance.copy(field = value) against a composable-captured appearance snapshot, then wrote the whole object back. Two edits that both go through that snapshot in the same composition pass — most reachably, the two opacity fields committing independently as the sheet is dismissed, since the sheet can tear down both PercentInputRows before either write round-trips back into the read appearance would race: whichever write lands first can be clobbered by the second, which was still building its new value from the appearance the sheet had before the first write applied. SubtitleStyleSheet's onUpdate now takes a transform, not a precomputed value, and PlayerViewModel/SettingsViewModel apply it against the freshest value read from the store immediately before writing — the same pattern TvSettingsViewModel.editAppearance already used for the identical reason. Found by Kody review on PR #413, after the previous commit's fix for lost edits on sheet dismissal made this reachable. Co-Authored-By: Claude Sonnet 5 * fix(playback): make subtitle-appearance updates atomic at the store Reading subtitleAppearanceFlow.first() and then writing the transformed result back separately narrowed the race the previous commit targeted, but didn't close it: two concurrent callers (e.g. both opacity fields committing as the sheet is dismissed, each in its own launched coroutine) could still both read the same value before either write landed, and the second write would discard the first edit. Adds PlayerSettingsStore.updateSubtitleAppearance(transform), which reads the current appearance from DataStore's own preferences snapshot inside the same edit {} transaction that writes the result. DataStore serializes edit calls against each other, so this is an actual fix rather than a smaller window: no other write can land between the read and the write. The interface keeps a default (read-then-write) implementation for test fakes that only need to capture a value, not real atomicity. PlayerViewModel, SettingsViewModel, and TvSettingsViewModel.editAppearance (which had the identical read-then-write pattern, predating this feature) all move to the atomic method. Found by Kody (critical) and CodeRabbit (major) review on PR #413, after the previous commit's snapshot-based fix made the race reachable but not closed. Co-Authored-By: Claude Sonnet 5 * chore(settings): re-vendor settings contract at manifest revision 14 The server moved subtitle textOpacity to revision 14 because revision 13 went to the advisory_age overlay id. Re-vendor manifest.json and conformance.json from the server and regenerate SettingKeys. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(playback): gate subtitle text opacity on settings revision 14 The server schema for playback.subtitle_appearance only accepts textOpacity from manifest revision 14 and rejects unknown properties. A server below 14 answers a write that carries it with a contract error, the flusher drops the op, and the next refresh reverts every subtitle edit in that write. The flusher now decides at send time: it strips textOpacity for a server known to be below 14, sends it whole from 14, and holds the write (kept queued and retried) while the revision is unknown, so a value queued before the probe answers neither fails on an old server nor resets the stored value on a new one. The revision comes from a shared SettingsContractRevision cache that refreshFromServer re-reads before it pushes. The phone style sheet, TV HUD, and TV settings hide the Text Opacity control when the server is known to be below 14. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(playback): enqueue subtitle appearance writes in commit order updateSubtitleAppearance applied its transform inside the DataStore edit but enqueued the result after the edit returned. The flusher keeps the last value enqueued per key, so two concurrent edits that committed A then B could enqueue B then A, and the server kept the older composite. The enqueue now runs inside the transaction, in updateSubtitleAppearance, flushProjectedSubtitleAppearance, and the override-enable path, so the queued value is always the one the latest commit wrote. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(playback): close the keyboard when a subtitle percentage is done Pressing Done in the Text Opacity or Background Opacity field committed the value but left the field focused and the keyboard open over the sheet. Done now commits, clears focus, and hides the keyboard. Commit on blur stays, and the row skips the repeat commit that clearing focus triggers. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): drop a revision probe answered by a different server The capabilities request goes to whichever server is active when it is sent. A server switch during the fetch cached the new server's revision under the old URL, so the flusher could strip textOpacity for a server that stores it, or send it to one that rejects it. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): start text opacity pickers at a readable value 1% reads as no subtitles. The HUD now offers 25-100% and Settings 5-100%, matching the tvOS app; a stored value between steps is still offered. Co-Authored-By: Claude Opus 5.5 (1M context) * chore(settings): point the vendored contract at its merged server commit Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Kamil Gielas Co-authored-by: Claude Sonnet 5 Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- .../silo/common/di/PlayerInfraModule.kt | 14 ++ .../silo/common/player/SubtitleManager.kt | 3 +- .../settings/AndroidPlayerSettingsStore.kt | 57 +++++- .../settings/DeviceCaptioningAppearance.kt | 2 + .../common/settings/PlayerSettingsStore.kt | 26 +++ .../common/settings/ServerSettingsFlusher.kt | 32 +++- .../settings/SettingsContractRevision.kt | 75 ++++++++ .../player/SubtitleManagerAppearanceTest.kt | 11 ++ .../AndroidPlayerSettingsStoreTest.kt | 132 ++++++++++++++ .../settings/ServerSettingsFlusherTest.kt | 90 ++++++++++ .../settings/SettingsContractRevisionTest.kt | 54 ++++++ .../ui/screens/player/PlayerOverlay.kt | 3 +- .../ui/screens/player/PlayerViewModel.kt | 15 +- .../ui/screens/player/SubtitleStyleSheet.kt | 170 ++++++++++++++---- .../ui/screens/settings/SettingsScreen.kt | 3 +- .../ui/screens/settings/SettingsViewModel.kt | 22 ++- .../silo/tv/ui/screens/player/TvPlayerHud.kt | 42 ++++- .../tv/ui/screens/player/TvPlayerScreen.kt | 2 + .../tv/ui/screens/player/TvPlayerViewModel.kt | 3 + .../player/TvSubtitleAppearanceOptions.kt | 30 ++++ .../ui/screens/settings/TvSettingsScreen.kt | 39 +++- .../screens/settings/TvSettingsViewModel.kt | 22 ++- .../model/settings/PlaybackSettingsKeys.kt | 2 + .../silo/model/settings/SettingKeys.kt | 21 ++- .../silo/model/settings/SubtitleAppearance.kt | 32 +++- .../settings/SubtitleAppearanceProjection.kt | 5 + .../SubtitleAppearanceProjectionTest.kt | 26 +++ .../model/settings/SubtitleAppearanceTest.kt | 29 +++ .../commonTest/resources/settings/v1/SOURCE | 8 +- .../resources/settings/v1/conformance.json | 65 ++++++- .../resources/settings/v1/manifest.json | 96 +++++++++- 31 files changed, 1045 insertions(+), 86 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsContractRevision.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/SettingsContractRevisionTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt index 3d46fad4a8..19aa761636 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt @@ -21,12 +21,14 @@ import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.common.settings.ServerDrivenConfigRefresher import org.siloserver.silo.common.settings.SeekIntervalStore +import org.siloserver.silo.common.settings.SettingsContractRevision import org.siloserver.silo.common.settings.ServerSettingsFlusher import org.siloserver.silo.domain.player.IntroAutoSkipController import org.siloserver.silo.domain.settings.SeekIntervalController import org.siloserver.silo.network.DeviceMetadataProvider import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.api.SettingsApi import org.siloserver.silo.repository.LibraryPlaybackPrefsRepository import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.SettingsRepository @@ -89,6 +91,16 @@ val playerInfraModule = module { } } + // The connected server's settings manifest revision. One instance, so the + // flusher's send-time gates and the settings UI agree on what it supports. + single { + val settingsApi = get() + SettingsContractRevision( + fetchCapabilities = { settingsApi.getContractCapabilities() }, + getServerUrl = { get().getServerUrl() }, + ) + } + // Long-lived application-scope flusher: debounced server writes survive // ViewModel teardown. Uses Dispatchers.IO since flushOne does network work. single { @@ -100,6 +112,7 @@ val playerInfraModule = module { // against is still the one requests would reach. getServerUrl = { get().getServerUrl() }, getAuthScope = { get().snapshotCurrentScope() }, + contractRevision = get(), ) } @@ -132,6 +145,7 @@ val playerInfraModule = module { // server so settings scope stays in lockstep with what the // server records as `device_id` for each override. getDeviceId = { get().current()?.id }, + contractRevision = get(), ) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt index 1f26a2abc3..2329951af7 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt @@ -328,7 +328,8 @@ class SubtitleManager( } private fun buildCaptionStyle(appearance: SubtitleAppearance): CaptionStyleCompat { - val foreground = parseHexColor(appearance.fontColor) + val foregroundAlpha = appearance.textOpacity.coerceIn(1, 100) * 255 / 100 + val foreground = parseHexColor(appearance.fontColor, foregroundAlpha) val backgroundAlpha = if (appearance.backgroundStyle == SubtitleBackgroundStylePreset.Box) { (appearance.backgroundOpacity.coerceIn(0, 100) * 255 / 100) } else { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt index fdec03774f..88d36d630c 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt @@ -51,6 +51,8 @@ class AndroidPlayerSettingsStore( private val getDeviceId: suspend () -> String? = { null }, private val serverChangeSignal: Flow = flowOf(Unit), private val getAuthScope: suspend () -> org.siloserver.silo.network.AuthScopeSnapshot? = { null }, + /** Null in tests that never reach a server; text opacity then counts as supported. */ + private val contractRevision: SettingsContractRevision? = null, private val dataStoreFactory: (profileId: String) -> DataStore = { profileId -> PreferenceDataStoreFactory.create( produceFile = { context.preferencesDataStoreFile(fileNameFor(profileId)) }, @@ -400,6 +402,17 @@ class AndroidPlayerSettingsStore( if (matchDevice) deviceCaptioningAppearance(context, appearance) else appearance } + override val subtitleTextOpacitySupportedFlow: Flow = + if (contractRevision == null) { + flowOf(true) + } else { + combine(currentScopeFlow, contractRevision.known) { scope, known -> + val revision = known?.takeIf { scope != null && it.serverUrl == scope.serverUrl } + ?.manifestRevision + revision == null || SubtitleAppearance.supportsTextOpacity(revision) + }.distinctUntilChanged() + } + override suspend fun setSubtitleMatchesDevice(enabled: Boolean) = writeBoolLocal(PlaybackSettingsKeys.SubtitleMatchesDevice, enabled) @@ -532,10 +545,27 @@ class AndroidPlayerSettingsStore( writeString(PlaybackSettingsKeys.OrientationMode, value) override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { - val sanitized = value.sanitized() - val json = sanitized.toJsonString() + updateSubtitleAppearance { value } + } + + // Reads the current appearance from `prefs` inside the same `edit` + // transaction that writes the transformed result. DataStore serializes + // `edit` calls against each other (each transform lambda runs to + // completion holding the store's internal lock before the next one + // starts), so this is the actual fix for two callers racing on a + // separately-read "current" value — not just a smaller window. + // + // The enqueue happens inside the transaction for the same reason: the + // flusher keeps the last value enqueued per key, so enqueueing after + // `edit` returns would let two concurrent edits enqueue in the opposite + // order to the one they committed in, and the server would keep the + // older composite. + override suspend fun updateSubtitleAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { withScope { scope, store -> store.edit { prefs -> + val current = prefs.projectedAppearance(scope) + val sanitized = transform(current).sanitized() + val json = sanitized.toJsonString() 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 @@ -545,8 +575,8 @@ class AndroidPlayerSettingsStore( // Setting an explicit appearance implicitly enables the // device override (matches iOS `setSubtitleAppearance`). prefs[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } } @@ -561,15 +591,16 @@ class AndroidPlayerSettingsStore( */ 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 + // Projected and enqueued inside the transaction, like + // [updateSubtitleAppearance], so a concurrent edit cannot commit + // between the read and the write or enqueue out of commit order. store.edit { prefs -> + val json = prefs.projectedAppearance(scope).toJsonString() + if (prefs.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") == json) return@edit prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } } @@ -594,6 +625,13 @@ class AndroidPlayerSettingsStore( // restarting the session in place reverted the toggle every time. // Draining first makes the pull observe the write. Offline, both // fail and the local value stands. + // + // Re-read the server's settings revision first, so the push below + // gates revision-dependent fields on a current answer and a server + // upgraded while the app ran is noticed. + contractRevision?.let { revision -> + getServerUrl()?.takeIf { it.isNotBlank() }?.let { url -> runCatching { revision.refresh(url) } } + } runCatching { serverSettingsFlusher.flushNow() } withScope { scope, store -> // Batched canonical resolution: one request answers every @@ -638,8 +676,9 @@ class AndroidPlayerSettingsStore( // otherwise the fields left by whatever resolved while the // override was off win right back over it. writeGranularAppearance(it, scope, sanitized) + // In the transaction, so it enqueues in commit order. + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) serverSettingsFlusher.flushNow() } else { store.edit { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/DeviceCaptioningAppearance.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/DeviceCaptioningAppearance.kt index b2367ce88c..85c944982c 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/DeviceCaptioningAppearance.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/DeviceCaptioningAppearance.kt @@ -23,6 +23,7 @@ fun deviceCaptioningAppearance(context: Context, base: SubtitleAppearance): Subt val edgeType = style.takeIf { it.hasEdgeType() }?.edgeType val edgeColor = style.takeIf { it.hasEdgeColor() }?.edgeColor + val foregroundAlpha = fontColor?.let { AndroidColor.alpha(it) } val backgroundAlpha = backgroundColor?.let { AndroidColor.alpha(it) } val backgroundStyle = when { backgroundAlpha == null -> SubtitleBackgroundStylePreset.Box @@ -34,6 +35,7 @@ fun deviceCaptioningAppearance(context: Context, base: SubtitleAppearance): Subt fontSize = fontSizePresetFor(manager.fontScale), fontFamily = base.fontFamily, fontColor = fontColor?.let(::rgbHex) ?: base.fontColor, + textOpacity = foregroundAlpha?.let { (it * 100 / 255).coerceIn(1, 100) } ?: base.textOpacity, backgroundColor = backgroundColor?.let(::rgbHex) ?: base.backgroundColor, backgroundStyle = backgroundStyle, backgroundOpacity = backgroundAlpha?.let { (it * 100) / 255 } ?: base.backgroundOpacity, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt index e86cf7016a..779bbdaf6a 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt @@ -129,6 +129,15 @@ interface PlayerSettingsStore { /** [subtitleAppearanceFlow] with the match-device override applied. */ val effectiveSubtitleAppearanceFlow: Flow + /** + * False only when the active server is known to run a settings manifest + * older than [SubtitleAppearance.TEXT_OPACITY_MIN_MANIFEST_REVISION], so it + * would discard [SubtitleAppearance.textOpacity]. An unknown revision + * counts as supported: the flusher holds the value until it is known. + */ + val subtitleTextOpacitySupportedFlow: Flow + get() = flowOf(true) + // Setters suspend fun setIntroSkipMode(value: IntroSkipMode) @@ -179,6 +188,23 @@ interface PlayerSettingsStore { suspend fun setSubtitleAppearance(value: SubtitleAppearance) + /** + * Applies [transform] atomically against the current stored appearance, + * read inside the same DataStore transaction that writes the result. + * Unlike a caller reading [subtitleAppearanceFlow] and then calling + * [setSubtitleAppearance] separately, no write from another caller can + * land in the gap between the read and the write — two edits committing + * around the same time (e.g. two fields as a sheet dismisses) each see + * the other's result instead of racing on a shared pre-transaction read. + * + * The default falls back to the non-atomic read-then-write for fakes + * that only need to capture the resulting value; [AndroidPlayerSettingsStore] + * overrides this with the real atomic transaction. + */ + suspend fun updateSubtitleAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { + setSubtitleAppearance(transform(subtitleAppearanceFlow.first())) + } + /** * Project the granular, client-local `subtitle.*` fields into the * composite `playback.subtitle_appearance` and enqueue it. diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusher.kt index 8f62f4ad95..a53f6c4010 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusher.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusher.kt @@ -4,6 +4,7 @@ import org.siloserver.silo.common.diagnostics.SiloLog import org.siloserver.silo.model.diagnostics.DiagnosticsLogCategory import org.siloserver.silo.model.settings.SettingKeys import org.siloserver.silo.model.settings.SettingScopeIdentity +import org.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.SettingsApi @@ -95,6 +96,13 @@ class DefaultServerSettingsFlusher( */ private val getServerUrl: suspend () -> String? = { null }, private val getAuthScope: (suspend () -> AuthScopeSnapshot?)? = null, + /** + * The connected server's settings manifest revision, for writes whose + * accepted shape depends on it. Share the app's instance so the settings + * UI and the flusher agree on what the server supports. + */ + private val contractRevision: SettingsContractRevision = + SettingsContractRevision({ settingsApi.getContractCapabilities() }, getServerUrl), ) : ServerSettingsFlusher { private val lock = Any() @@ -275,11 +283,13 @@ class DefaultServerSettingsFlusher( SiloLog.w(CATEGORY, TAG, "dropping $key: ${op.value} does not encode as the contract type") return false } + val wire = gateForServerRevision(key, encoded, op.serverUrl) + ?: return failed("put", key, "settings manifest revision unknown", retry = true) return when ( val result = settingsApi.putValue( key = key, scope = SettingScopeIdentity.profileDevice(), - value = encoded, + value = wire, profileId = profileId, authority = op.authority, ) @@ -294,6 +304,26 @@ class DefaultServerSettingsFlusher( } } + /** + * [encoded] in the shape the server at [serverUrl] accepts, or null when + * that depends on a manifest revision not known yet. + * + * Decided here, at send time, rather than at enqueue: a value queued + * before the revision was known has to reach a revision-14 server whole, + * because a PUT replaces the stored object and a stripped one would reset + * the textOpacity stored there. While the revision is unknown the write is + * held — kept queued and retried like a transient failure — because both + * alternatives lose data: sent whole, a server below 14 rejects it as a + * contract error, the op is dropped and the next refresh reverts every + * field in it; sent stripped, it can reset a revision-14 server's value. + */ + private suspend fun gateForServerRevision(key: String, encoded: JsonElement, serverUrl: String): JsonElement? { + if (key != SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE) return encoded + if (encoded !is JsonObject || SubtitleAppearance.TEXT_OPACITY_FIELD !in encoded) return encoded + val revision = contractRevision.revisionFor(serverUrl) ?: return null + return SubtitleAppearance.wireObjectForRevision(encoded, revision) + } + private suspend fun flushDelete(profileId: String, key: String, op: PendingOp.Delete): Boolean { return when ( val result = settingsApi.deleteValue( diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsContractRevision.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsContractRevision.kt new file mode 100644 index 0000000000..ca2f2818ff --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsContractRevision.kt @@ -0,0 +1,75 @@ +package org.siloserver.silo.common.settings + +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.network.ApiResult +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * The settings manifest revision of the connected server, read from the + * contract capabilities endpoint and cached per server URL. + * + * The settings flusher asks for it before sending a write whose shape depends + * on the revision, and the settings UI hides controls the server would + * discard. Both share one instance so they agree on what the server supports. + */ +class SettingsContractRevision( + private val fetchCapabilities: suspend () -> ApiResult, + /** The server requests currently address; null when it cannot be read. */ + private val getServerUrl: suspend () -> String? = { null }, +) { + data class Known(val serverUrl: String, val manifestRevision: Int) + + private val mutex = Mutex() + private val _known = MutableStateFlow(null) + + /** The last revision a probe answered, with the server that answered it. */ + val known: StateFlow = _known.asStateFlow() + + /** + * The cached revision for [serverUrl], probing when none is cached. + * Null means unknown: the probe failed, or [serverUrl] is not the server + * requests would reach, so any answer would describe a different server. + */ + suspend fun revisionFor(serverUrl: String): Int? { + cachedFor(serverUrl)?.let { return it } + return mutex.withLock { + cachedFor(serverUrl) ?: probe(serverUrl) + } + } + + /** + * Re-read the revision for [serverUrl] even when one is cached, so a + * server upgraded while the app runs is noticed. A failed probe keeps the + * cached value. + */ + suspend fun refresh(serverUrl: String): Int? = mutex.withLock { + probe(serverUrl) ?: cachedFor(serverUrl) + } + + private fun cachedFor(serverUrl: String): Int? = + _known.value?.takeIf { it.serverUrl == serverUrl }?.manifestRevision + + private suspend fun probe(serverUrl: String): Int? { + if (!isActive(serverUrl)) return null + val revision = when (val result = fetchCapabilities()) { + is ApiResult.Success -> result.data.manifestRevision + // No capabilities route: the server predates every revision gate. + is ApiResult.Error -> if (result.code == 404) 0 else null + is ApiResult.NetworkError -> null + } ?: return null + // The request goes to whichever server is active when it is sent, so a + // switch during the fetch means the answer describes another server. + if (!isActive(serverUrl)) return null + _known.value = Known(serverUrl, revision) + return revision + } + + private suspend fun isActive(serverUrl: String): Boolean { + val active = runCatching { getServerUrl() }.getOrNull() + return active == null || active == serverUrl + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerAppearanceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerAppearanceTest.kt index 57bb8acd1e..b0c64ba037 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerAppearanceTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerAppearanceTest.kt @@ -38,6 +38,17 @@ class SubtitleManagerAppearanceTest { assertEquals(0xFF000000.toInt(), style.edgeColor) } + @Test + fun textOpacityScalesOnlyTheForegroundAlpha() { + val half = captionStyleFor(SubtitleAppearance.DEFAULT.copy(fontColor = "#ff0000", textOpacity = 50)) + // 50% of 255 truncates to 0x7F; the RGB and the edge color are untouched. + assertEquals(0x7FFF0000, half.foregroundColor) + assertEquals(0xFF000000.toInt(), half.edgeColor) + + val faintest = captionStyleFor(SubtitleAppearance.DEFAULT.copy(textOpacity = 1)) + assertEquals(0x02FFFFFF, faintest.foregroundColor) + } + @Test fun bottomSubtitlesUseTheReferenceSafeMargin() { val method = SubtitleManager::class.java.getDeclaredMethod( diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt index 255e8e9721..89079ad8a6 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt @@ -24,6 +24,7 @@ import io.ktor.client.HttpClient import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json @@ -358,6 +359,111 @@ class AndroidPlayerSettingsStoreTest { assertEquals("#ff0000", read.fontColor) } + @Test + fun `updateSubtitleAppearance applies the transform against the current value`() = runTest { + val store = newStore() + store.setSubtitleAppearance(SubtitleAppearance.DEFAULT.copy(fontSize = SubtitleFontSizePreset.XLarge)) + store.updateSubtitleAppearance { it.copy(textOpacity = 42) } + val read = store.subtitleAppearanceFlow.first() + // The field the transform didn't touch survives... + assertEquals(SubtitleFontSizePreset.XLarge, read.fontSize) + // ...and the one it did is applied. + assertEquals(42, read.textOpacity) + } + + @Test + fun `sequential updateSubtitleAppearance calls do not clobber each other`() = runTest { + val store = newStore() + // Each call reads the current stored value inside its own write + // transaction rather than a value read before either call started, + // so a per-field edit from one call survives the next. + store.updateSubtitleAppearance { it.copy(textOpacity = 55) } + store.updateSubtitleAppearance { it.copy(backgroundOpacity = 33) } + val read = store.subtitleAppearanceFlow.first() + assertEquals(55, read.textOpacity) + assertEquals(33, read.backgroundOpacity) + } + + @Test + fun `concurrent appearance edits enqueue in the order they commit`() = runTest { + val gated = ReturnGatedDataStore( + PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_gated.preferences_pb") }), + ) + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), + legacyCache = fakeLegacyCache, + getActiveProfileId = { activeProfileId }, + getServerUrl = { serverUrl }, + serverSettingsFlusher = fakeFlusher, + getDeviceId = { null }, + dataStoreFactory = { gated }, + ) + // Runs the one-time migrations so the gate only sees the two edits. + store.setSubtitleAppearance(SubtitleAppearance.DEFAULT) + fakeFlusher.calls.clear() + gated.armed = true + + // A commits first but its edit returns only after B's has returned: + // exactly the interleaving where an enqueue made after `edit` returned + // would send B's composite and then overwrite it with A's older one. + val a = launch { store.updateSubtitleAppearance { it.copy(textOpacity = 40) } } + val b = launch { store.updateSubtitleAppearance { it.copy(backgroundOpacity = 30) } } + a.join() + b.join() + + val stored = store.subtitleAppearanceFlow.first() + assertEquals(40, stored.textOpacity) + assertEquals(30, stored.backgroundOpacity) + val lastEnqueued = fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.SubtitleAppearance } + assertEquals(stored, SubtitleAppearance.decode(lastEnqueued.value)) + } + + @Test + fun `text opacity is offered unless the active server is known to be below revision 14`() = runTest { + var active = serverUrl + var revision: ApiResult = + ApiResult.NetworkError(IllegalStateException("offline")) + val contractRevision = SettingsContractRevision({ revision }, { active }) + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), + legacyCache = fakeLegacyCache, + getActiveProfileId = { activeProfileId }, + getServerUrl = { active }, + serverSettingsFlusher = fakeFlusher, + settingsRepository = SettingsRepository(FakeSettingsApi()), + getDeviceId = { null }, + contractRevision = contractRevision, + dataStoreFactory = { id -> + PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_rev_$id.preferences_pb") }) + }, + ) + + // Unknown: offered, since the flusher holds the value until it is known. + store.refreshFromServer() + assertTrue(store.subtitleTextOpacitySupportedFlow.first()) + + // The refresh re-reads the revision before it pushes. + revision = capabilitiesAt(13) + store.refreshFromServer() + assertFalse(store.subtitleTextOpacitySupportedFlow.first()) + + // A revision known for another server says nothing about this one. + active = "https://other.example" + assertTrue(store.subtitleTextOpacitySupportedFlow.first()) + + active = serverUrl + revision = capabilitiesAt(14) + store.refreshFromServer() + assertTrue(store.subtitleTextOpacitySupportedFlow.first()) + } + + private fun capabilitiesAt(revision: Int): ApiResult = + ApiResult.Success( + org.siloserver.silo.model.settings.SettingsContractCapabilities( + apiVersion = 1, manifestRevision = revision, supportsBatchedEffective = true, + ), + ) + @Test fun `setPlaybackSpeed clamps out-of-range values`() = runTest { val store = newStore() @@ -917,6 +1023,32 @@ private class FakeServerSettingsFlusher : ServerSettingsFlusher { } } +/** + * Delegates to a real DataStore, but once [armed] holds the first edit's + * return until the second edit has returned. The commits themselves stay in + * submission order; only the callers resume in reverse. + */ +private class ReturnGatedDataStore( + private val delegate: DataStore, +) : DataStore { + @Volatile var armed = false + private val edits = java.util.concurrent.atomic.AtomicInteger() + private val secondReturned = kotlinx.coroutines.CompletableDeferred() + + override val data: kotlinx.coroutines.flow.Flow = delegate.data + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + if (!armed) return delegate.updateData(transform) + val index = edits.incrementAndGet() + val result = delegate.updateData(transform) + when (index) { + 1 -> secondReturned.await() + 2 -> secondReturned.complete(Unit) + } + return result + } +} + /** One canned entry: a value stored at [scope] (default: profile_device). */ private fun stored( key: String, diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusherTest.kt index c92641dec7..e1ecca1c99 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerSettingsFlusherTest.kt @@ -4,6 +4,7 @@ import org.siloserver.silo.model.settings.PlaybackSettingsKeys import org.siloserver.silo.model.settings.SettingKeys import org.siloserver.silo.model.settings.SettingScope import org.siloserver.silo.model.settings.SettingScopeIdentity +import org.siloserver.silo.model.settings.SettingsContractCapabilities import org.siloserver.silo.model.settings.StoredSettingValue import org.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.network.ApiResult @@ -19,6 +20,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue @@ -502,6 +504,76 @@ class ServerSettingsFlusherTest { assertEquals(0, api.calls.size, "non-remote keys would 404 as unknown_setting; drop locally") } + + // ---- Settings revision gate for subtitle text opacity ------------------- + + private val translucentText = SubtitleAppearance.DEFAULT.copy(textOpacity = 40).toJsonString() + + private fun sentAppearance(api: RecordingSettingsApi): JsonObject = + api.calls.last { it.key == objectKey }.value as JsonObject + + @Test + fun `subtitle appearance omits textOpacity for a server below revision 14`() = runTest { + val api = RecordingSettingsApi().apply { manifestRevision(13) } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + advanceUntilIdle() + + val sent = sentAppearance(api) + assertFalse(SubtitleAppearance.TEXT_OPACITY_FIELD in sent, "a revision 13 server rejects the whole object") + assertEquals(JsonPrimitive("#ffffff"), sent["fontColor"], "every other field still goes up") + } + + @Test + fun `subtitle appearance keeps textOpacity for a revision 14 server`() = runTest { + val api = RecordingSettingsApi().apply { manifestRevision(14) } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + advanceUntilIdle() + + assertEquals(JsonPrimitive(40), sentAppearance(api)[SubtitleAppearance.TEXT_OPACITY_FIELD]) + } + + @Test + fun `subtitle appearance is held while the revision is unknown, then sent whole`() = runTest { + val api = RecordingSettingsApi().apply { + capabilities = ApiResult.NetworkError(IllegalStateException("offline")) + } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + flusher.enqueue("p1", stringKey, "720p", serverUrl) + flusher.flushNow() + + // Neither stripped nor whole: stripped could reset a revision-14 + // server's stored value, whole would be refused by an older one. + assertTrue(api.calls.none { it.key == objectKey }) + assertEquals(setOf(objectKey), flusher.pendingKeys("p1"), "held, not dropped") + assertTrue(api.calls.any { it.key == stringKey }, "the hold is per key; other writes still land") + + // Queued before the revision was known; gated when it is sent. + api.manifestRevision(14) + flusher.flushNow() + + assertEquals(JsonPrimitive(40), sentAppearance(api)[SubtitleAppearance.TEXT_OPACITY_FIELD]) + assertTrue(flusher.pendingKeys("p1").isEmpty()) + } + + @Test + fun `the revision is probed once per server, not per write`() = runTest { + val api = RecordingSettingsApi().apply { manifestRevision(13) } + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", objectKey, translucentText, serverUrl) + flusher.flushNow() + flusher.enqueue("p1", objectKey, SubtitleAppearance.DEFAULT.copy(textOpacity = 60).toJsonString(), serverUrl) + flusher.flushNow() + + assertEquals(1, api.capabilityCalls) + assertEquals(2, api.calls.count { it.key == objectKey }) + } } /** @@ -522,6 +594,19 @@ private class RecordingSettingsApi : SettingsApi(org.siloserver.silo.network.api val calls = mutableListOf() + /** What the settings capabilities probe answers; the generated revision by default. */ + var capabilities: ApiResult = capabilitiesAt(SettingKeys.REVISION) + var capabilityCalls = 0 + + fun manifestRevision(revision: Int) { + capabilities = capabilitiesAt(revision) + } + + override suspend fun getContractCapabilities(): ApiResult { + capabilityCalls++ + return capabilities + } + /** * 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. @@ -534,6 +619,11 @@ private class RecordingSettingsApi : SettingsApi(org.siloserver.silo.network.api private var deleteFailuresRemaining = 0 private var deleteFailure: ApiResult? = null + private fun capabilitiesAt(revision: Int): ApiResult = + ApiResult.Success( + SettingsContractCapabilities(apiVersion = 1, manifestRevision = revision, supportsBatchedEffective = true), + ) + fun failNextPuts(count: Int, failure: ApiResult) { putFailuresRemaining = count putFailure = failure diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/SettingsContractRevisionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/SettingsContractRevisionTest.kt new file mode 100644 index 0000000000..9b7e8b454a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/SettingsContractRevisionTest.kt @@ -0,0 +1,54 @@ +package org.siloserver.silo.common.settings + +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.network.ApiResult +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SettingsContractRevisionTest { + + private fun capabilities(revision: Int) = + ApiResult.Success(SettingsContractCapabilities(apiVersion = 1, manifestRevision = revision)) + + @Test + fun cachesTheRevisionForTheServerThatAnswered() = runTest { + var probes = 0 + val revision = SettingsContractRevision( + fetchCapabilities = { probes++; capabilities(14) }, + getServerUrl = { "https://a.example" }, + ) + + assertEquals(14, revision.revisionFor("https://a.example")) + assertEquals(14, revision.revisionFor("https://a.example")) + assertEquals(1, probes) + } + + @Test + fun discardsAnAnswerWhenTheServerChangesDuringTheFetch() = runTest { + var active = "https://old.example" + val revision = SettingsContractRevision( + fetchCapabilities = { + // The user switched servers while this request was in flight, + // so it reached the new server. + active = "https://new.example" + capabilities(14) + }, + getServerUrl = { active }, + ) + + assertNull(revision.revisionFor("https://old.example")) + assertNull(revision.known.value) + } + + @Test + fun treatsAMissingCapabilitiesRouteAsAnOldServer() = runTest { + val revision = SettingsContractRevision( + fetchCapabilities = { ApiResult.Error(404, "not_found", "") }, + getServerUrl = { "https://a.example" }, + ) + + assertEquals(0, revision.revisionFor("https://a.example")) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index de8e974456..ae8dcd5e86 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -674,7 +674,8 @@ fun PlayerOverlay( SubtitleStyleSheet( isVisible = subtitleStyleVisible, appearance = viewModel.subtitleAppearance.collectAsState().value, - onUpdate = viewModel::onSetSubtitleAppearance, + showTextOpacity = viewModel.subtitleTextOpacitySupported.collectAsState().value, + onUpdate = viewModel::onEditSubtitleAppearance, onDismiss = { subtitleStyleVisible = false }, onBack = { subtitleStyleVisible = false diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index eff41a5307..8f6191ceb8 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -768,6 +768,9 @@ class PlayerViewModel( // (then the OS captioning style, tvOS parity). val subtitleAppearance: StateFlow = playerSettingsStore.effectiveSubtitleAppearanceFlow .stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleAppearance.DEFAULT) + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: StateFlow = playerSettingsStore.subtitleTextOpacitySupportedFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, true) /** * Per-device audio/subtitle delay in ms. Mirrors iOS phone's `audioSyncMs` / * `subtitleSyncMs` (`iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift:265-285`). @@ -4310,8 +4313,16 @@ class PlayerViewModel( viewModelScope.launch { playerSettingsStore.setDolbyVisionEnabled(value) } } - fun onSetSubtitleAppearance(value: SubtitleAppearance) { - viewModelScope.launch { playerSettingsStore.setSubtitleAppearance(value) } + /** + * Commits a subtitle-appearance change via a transform rather than a + * precomputed value (replaced the former `onSetSubtitleAppearance`). + * [PlayerSettingsStore.updateSubtitleAppearance] applies it atomically + * inside the store's own write transaction, so two edits committing + * around the same time (e.g. two opacity fields as the sheet is + * dismissed) can't race on a snapshot read before either writes. + */ + fun onEditSubtitleAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { + viewModelScope.launch { playerSettingsStore.updateSubtitleAppearance(transform) } } /** diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt index 7013ba27ef..53395265d8 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt @@ -19,23 +19,38 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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 +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.TextStyle 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.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -49,17 +64,25 @@ import org.siloserver.silo.model.settings.SubtitlePositionPreset * font size/family/color, background style/color/opacity, optional outline, * and on-screen position. * - * Each control writes back the full sanitized appearance via [onUpdate]; - * the consuming layer (`PlayerViewModel` / `PlayerSettingsStore`) is - * responsible for persistence and propagating to [org.siloserver.silo.common.player.SubtitleManager]. + * Each control writes back a transform via [onUpdate] rather than a + * precomputed value built from [appearance]: that parameter is a + * composable-captured snapshot that can go stale between when a control's + * closure is built and when it actually runs (e.g. two opacity fields + * committing independently as the sheet is dismissed), so the caller applies + * the transform against the freshest value it can read instead. The + * consuming layer (`PlayerViewModel` / `PlayerSettingsStore`) is responsible + * for persistence and propagating to [org.siloserver.silo.common.player.SubtitleManager]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SubtitleStyleSheet( isVisible: Boolean, appearance: SubtitleAppearance, - onUpdate: (SubtitleAppearance) -> Unit, + onUpdate: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, onDismiss: () -> Unit, + // False when the server is known to discard text opacity; the row is + // hidden rather than offering a value that will not be kept. + showTextOpacity: Boolean = true, // Gear-submenu back affordance: dismisses this sheet and reopens the // parent settings sheet (wired in PlayerOverlay). onBack: (() -> Unit)? = null, @@ -116,14 +139,14 @@ fun SubtitleStyleSheet( FontSizeRow( selected = appearance.fontSize, onSelect = { value -> - onUpdate(appearance.copy(fontSize = value).sanitized()) + onUpdate { it.copy(fontSize = value).sanitized() } }, ) FontFamilyRow( selected = appearance.fontFamily, onSelect = { value -> - onUpdate(appearance.copy(fontFamily = value).sanitized()) + onUpdate { it.copy(fontFamily = value).sanitized() } }, ) @@ -132,17 +155,28 @@ fun SubtitleStyleSheet( swatches = TEXT_COLOR_SWATCHES, selectedHex = appearance.fontColor, onSelect = { hex -> - onUpdate(appearance.copy(fontColor = hex).sanitized()) + onUpdate { it.copy(fontColor = hex).sanitized() } }, ) + if (showTextOpacity) { + PercentInputRow( + label = "Text Opacity", + value = appearance.textOpacity, + min = 1, + onChange = { value -> + onUpdate { it.copy(textOpacity = value).sanitized() } + }, + ) + } + // ---- Background section ----------------------------------------- SectionHeader("Background") BackgroundStyleRow( selected = appearance.backgroundStyle, onSelect = { value -> - onUpdate(appearance.copy(backgroundStyle = value).sanitized()) + onUpdate { it.copy(backgroundStyle = value).sanitized() } }, ) @@ -151,14 +185,16 @@ fun SubtitleStyleSheet( swatches = BACKGROUND_COLOR_SWATCHES, selectedHex = appearance.backgroundColor, onSelect = { hex -> - onUpdate(appearance.copy(backgroundColor = hex).sanitized()) + onUpdate { it.copy(backgroundColor = hex).sanitized() } }, ) - OpacityRow( - opacity = appearance.backgroundOpacity, + PercentInputRow( + label = "Background Opacity", + value = appearance.backgroundOpacity, + min = 0, onChange = { value -> - onUpdate(appearance.copy(backgroundOpacity = value).sanitized()) + onUpdate { it.copy(backgroundOpacity = value).sanitized() } }, ) @@ -170,7 +206,7 @@ fun SubtitleStyleSheet( subtitle = null, checked = appearance.textOutline, onCheckedChange = { value -> - onUpdate(appearance.copy(textOutline = value).sanitized()) + onUpdate { it.copy(textOutline = value).sanitized() } }, ) @@ -180,7 +216,7 @@ fun SubtitleStyleSheet( swatches = BACKGROUND_COLOR_SWATCHES, selectedHex = appearance.textOutlineColor, onSelect = { hex -> - onUpdate(appearance.copy(textOutlineColor = hex).sanitized()) + onUpdate { it.copy(textOutlineColor = hex).sanitized() } }, ) } @@ -191,7 +227,7 @@ fun SubtitleStyleSheet( PositionRow( selected = appearance.position, onSelect = { value -> - onUpdate(appearance.copy(position = value).sanitized()) + onUpdate { it.copy(position = value).sanitized() } }, ) @@ -422,34 +458,89 @@ private fun ColorSwatch( ) } +/** + * A typed percentage value (`min`-100), for controls where dragging a slider + * is more fiddly than just typing the number — opacity wants precision at the + * low end where a few percent is the difference between legible and not. + */ @Composable -private fun OpacityRow( - opacity: Int, +private fun PercentInputRow( + label: String, + value: Int, + min: Int, onChange: (Int) -> Unit, ) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Opacity ${opacity.coerceIn(0, 100)}%", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) + var text by remember(value) { mutableStateOf(value.toString()) } + // The last value handed to onChange that `value` has not caught up with + // yet. Done commits and then clears focus, which commits again on blur; + // without this the second commit would repeat the same write. + var sent by remember(value) { mutableStateOf(null) } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + // Committing on every keystroke fights the clamp: typing "0" below the + // floor calls onChange(min), which can equal the value already in effect, + // so `value` never changes and `remember(value)` never re-keys the draft + // back to a valid display. Committing once, on blur/Done, avoids that + // entirely and lets the field hold invalid intermediate text (including + // empty) while the user is still typing. + fun commit() { + val clamped = text.toIntOrNull()?.coerceIn(min, 100) + if (clamped != null && clamped != value && clamped != sent) { + sent = clamped + onChange(clamped) } - Slider( - value = opacity.coerceIn(0, 100).toFloat(), - onValueChange = { value -> onChange(value.toInt().coerceIn(0, 100)) }, - valueRange = 0f..100f, - steps = 0, - colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color(0xFF06B6D4), - inactiveTrackColor = Color.White.copy(alpha = 0.25f), + text = (clamped ?: value).toString() + } + + // Dismissing the sheet while the field is still focused (swipe-away, back + // press) tears down this composable without firing onFocusChanged(false), + // so a typed-but-uncommitted percentage would otherwise be silently lost. + // rememberUpdatedState keeps the lambda pointed at the latest commit + // closure across recompositions, so onDispose always commits current text. + val latestCommit = rememberUpdatedState(::commit) + DisposableEffect(Unit) { + onDispose { latestCommit.value() } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + color = Color.White, + fontSize = 16.sp, + modifier = Modifier.weight(1f), + ) + BasicTextField( + value = text, + onValueChange = { input -> text = input.filter { it.isDigit() }.take(3) }, + singleLine = true, + textStyle = TextStyle(color = Color.White, fontSize = 16.sp, textAlign = TextAlign.End), + cursorBrush = SolidColor(Color.White), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + commit() + focusManager.clearFocus() + keyboardController?.hide() + }, ), + modifier = Modifier + .width(44.dp) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.4f), + shape = RoundedCornerShape(6.dp), + ) + .padding(horizontal = 8.dp, vertical = 6.dp) + .onFocusChanged { focusState -> if (!focusState.isFocused) commit() }, ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = "%", color = Color.White, fontSize = 16.sp) } } @@ -501,6 +592,7 @@ private val TEXT_COLOR_SWATCHES = listOf( "#d946ef", // Magenta "#ef4444", // Red "#3b82f6", // Blue + "#9ca3af", // Gray "#000000", // Black ) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index 188f24b9c4..3cbf441eeb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -109,7 +109,8 @@ fun SettingsScreen( org.siloserver.silo.android.ui.screens.player.SubtitleStyleSheet( isVisible = subtitleStyleVisible, appearance = state.subtitleAppearance, - onUpdate = viewModel::setSubtitleAppearance, + showTextOpacity = state.subtitleTextOpacitySupported, + onUpdate = viewModel::editSubtitleAppearance, onDismiss = { subtitleStyleVisible = false }, ) val downloadsState by downloadsViewModel.uiState.collectAsState() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 57b1212e45..1b6eba7b40 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -84,6 +84,8 @@ data class SettingsUiState( val showAudiobooks: Boolean = false, val subtitleAppearance: org.siloserver.silo.model.settings.SubtitleAppearance = org.siloserver.silo.model.settings.SubtitleAppearance.DEFAULT, + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: Boolean = true, // Up Next card: auto-play the next episode at countdown expiry, and how // many seconds before the end to surface the card (0 = only at end). val autoPlayNext: Boolean = true, @@ -279,6 +281,9 @@ class SettingsViewModel( }.launchIn(viewModelScope) playerSettingsStore.subtitleAppearanceFlow.onEach { appearance -> _uiState.update { it.copy(subtitleAppearance = appearance) } + }.launchIn(viewModelScope) + playerSettingsStore.subtitleTextOpacitySupportedFlow.onEach { supported -> + _uiState.update { it.copy(subtitleTextOpacitySupported = supported) } }.launchIn(viewModelScope) } fun setDownloadsWifiOnly(value: Boolean) { @@ -520,12 +525,19 @@ class SettingsViewModel( viewModelScope.launch { playerSettingsStore.setShowAudiobooks(enabled) } } - fun setSubtitleAppearance(value: org.siloserver.silo.model.settings.SubtitleAppearance) { + /** + * Commits a subtitle-appearance change via a transform rather than a + * precomputed value (replaced the former `setSubtitleAppearance`). + * [PlayerSettingsStore.updateSubtitleAppearance][org.siloserver.silo.common.settings.PlayerSettingsStore.updateSubtitleAppearance] + * applies it atomically inside the store's own write transaction, so two + * edits committing around the same time (e.g. two opacity fields as the + * sheet is dismissed) can't race on a snapshot read before either writes. + */ + fun editSubtitleAppearance( + transform: (org.siloserver.silo.model.settings.SubtitleAppearance) -> org.siloserver.silo.model.settings.SubtitleAppearance, + ) { 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.updateSubtitleAppearance(transform) playerSettingsStore.flushProjectedSubtitleAppearance() } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt index 97c3849336..665de99c67 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt @@ -204,6 +204,8 @@ internal fun TvPlayerHud( onSubtitleDelayChanged: (Int) -> Unit, subtitleAppearance: SubtitleAppearance, onSubtitleAppearanceChanged: (SubtitleAppearance) -> Unit, + /** False when the server is known to discard subtitle text opacity. */ + subtitleTextOpacitySupported: Boolean = true, onSubtitlesPaneShown: () -> Unit, onSearchSubtitles: (() -> Unit)?, onTranslateWithAi: (() -> Unit)?, @@ -546,6 +548,7 @@ internal fun TvPlayerHud( onSubtitleDelayChanged = onSubtitleDelayChanged, appearance = subtitleAppearance, onAppearanceChanged = onSubtitleAppearanceChanged, + showTextOpacity = subtitleTextOpacitySupported, onPaneShown = onSubtitlesPaneShown, onSearchSubtitles = onSearchSubtitles, onTranslateWithAi = onTranslateWithAi, @@ -1482,6 +1485,7 @@ private fun HudSubtitlesPane( onSubtitleDelayChanged: (Int) -> Unit, appearance: SubtitleAppearance, onAppearanceChanged: (SubtitleAppearance) -> Unit, + showTextOpacity: Boolean, onPaneShown: () -> Unit, onSearchSubtitles: (() -> Unit)?, onTranslateWithAi: (() -> Unit)?, @@ -1627,6 +1631,32 @@ private fun HudSubtitlesPane( }, ) + if (showTextOpacity) { + HudFocusedSettingRow( + label = "Text Opacity", + value = "${appearance.textOpacity}%", + enabled = stylingEnabled, + rightFocusRequester = subtitleTextColorFocus, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Text Opacity", + options = TvSubtitleAppearanceOptions.percentOptions( + TEXT_OPACITY_STEPS, + appearance.textOpacity, + ).map { HudPickerOption(it.toString(), "$it%") }, + selectedId = appearance.textOpacity.toString(), + onSelect = { id -> + id.toIntOrNull()?.let { + onAppearanceChanged(appearance.copy(textOpacity = it)) + } + }, + ), + ) + }, + ) + } + HudFocusedSettingRow( label = "Background", value = BACKGROUND_STYLES.firstOrNull { it.first == appearance.backgroundStyle }?.second @@ -1658,7 +1688,10 @@ private fun HudSubtitlesPane( onPresentPicker( HudPickerPresentation( title = "Background Opacity", - options = OPACITY_STEPS.map { HudPickerOption(it.toString(), "$it%") }, + options = TvSubtitleAppearanceOptions.percentOptions( + OPACITY_STEPS, + appearance.backgroundOpacity, + ).map { HudPickerOption(it.toString(), "$it%") }, selectedId = appearance.backgroundOpacity.toString(), onSelect = { id -> id.toIntOrNull()?.let { @@ -1818,11 +1851,13 @@ private fun HudSubtitlePreview( val decoration = TvSubtitleAppearanceOptions.previewDecoration(safe) val fontSize = TvSubtitleAppearanceOptions.previewFontSizeSp(safe.fontSize).sp val fontFamily = TvSubtitleAppearanceOptions.previewFontFamily(safe.fontFamily) - val foreground = hexToColor(safe.fontColor) + val foreground = hexToColor(safe.fontColor).copy( + alpha = TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.textOpacity, floor = 1), + ) val outline = hexToColor(safe.textOutlineColor) val backgroundColor = hexToColor(safe.backgroundColor).copy( alpha = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) { - safe.backgroundOpacity.coerceIn(0, 100) / 100f + TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.backgroundOpacity, floor = 0) } else { 0f }, @@ -1997,6 +2032,7 @@ private val FONT_FAMILIES = TvSubtitleAppearanceOptions.FONT_FAMILIES private val BACKGROUND_STYLES = TvSubtitleAppearanceOptions.BACKGROUND_STYLES private val POSITIONS = TvSubtitleAppearanceOptions.POSITIONS private val OPACITY_STEPS = TvSubtitleAppearanceOptions.OPACITY_STEPS +private val TEXT_OPACITY_STEPS = TvSubtitleAppearanceOptions.TEXT_OPACITY_STEPS private val TEXT_COLOR_SWATCHES = TvSubtitleAppearanceOptions.TEXT_COLOR_SWATCHES private val BACKGROUND_COLOR_SWATCHES = TvSubtitleAppearanceOptions.BACKGROUND_COLOR_SWATCHES private val OUTLINE_COLOR_SWATCHES = TvSubtitleAppearanceOptions.OUTLINE_COLOR_SWATCHES diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index 80e08b69c4..9c6966039a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -334,6 +334,7 @@ fun TvPlayerScreen( val introSkipCountdownRun by viewModel.introSkipCountdownRun.collectAsState() val introSkipTimerRunning by viewModel.introSkipTimerRunning.collectAsState() val subtitleAppearance by viewModel.subtitleAppearance.collectAsState() + val subtitleTextOpacitySupported by viewModel.subtitleTextOpacitySupported.collectAsState() val playbackSpeed by viewModel.playbackSpeed.collectAsState() val sleepTimerState by viewModel.sleepTimerState.collectAsState() val introSkipMode by viewModel.introSkipMode.collectAsState() @@ -2304,6 +2305,7 @@ fun TvPlayerScreen( onSubtitleDelayChanged = viewModel::onSubtitleDelayChanged, subtitleAppearance = subtitleAppearance, onSubtitleAppearanceChanged = viewModel::onSetSubtitleAppearance, + subtitleTextOpacitySupported = subtitleTextOpacitySupported, onSubtitlesPaneShown = viewModel::onSubtitlesPaneShown, onSearchSubtitles = if (state.mediaFileId != null) { { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 51e02d7e8a..09baece9dc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -1403,6 +1403,9 @@ class TvPlayerViewModel( // (then the OS captioning style, tvOS parity). val subtitleAppearance: StateFlow = playerSettingsStore.effectiveSubtitleAppearanceFlow .stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleAppearance.DEFAULT) + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: StateFlow = playerSettingsStore.subtitleTextOpacitySupportedFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, true) /** * Per-profile audio delay in ms, ±500 clamp. Sourced from * [PlayerSettingsStore.audioSyncMsFlow]; mirrored into the active diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt index 2e5ddb3f75..3979e89195 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleAppearanceOptions.kt @@ -48,10 +48,31 @@ object TvSubtitleAppearanceOptions { /** Background-opacity percentage steps for the HUD quick overlay (coarse). */ val OPACITY_STEPS: List = listOf(0, 25, 50, 75, 100) + /** Text-opacity percentage steps for the HUD quick overlay (coarse). Starts + * at 25: 1% is the schema floor but reads as no subtitles at all. Matches + * silo-apple's tvOS HUD. */ + val TEXT_OPACITY_STEPS: List = listOf(25, 50, 75, 100) + /** Fine-grained opacity steps for the Settings Appearance block — 0–100 by * 5, matching silo-apple `TVSettingsOptions.backgroundOpacity`. */ val OPACITY_PERCENT_STEPS: List = (0..100 step 5).toList() + /** Fine-grained text-opacity steps for the Settings Appearance block — same + * 5-point cadence as [OPACITY_PERCENT_STEPS], but starting at 5 rather than + * 0 since fully transparent text has no legible affordance to pick it back + * up from. Matches silo-apple `TVSettingsOptions.textOpacity`. */ + val TEXT_OPACITY_PERCENT_STEPS: List = (5..100 step 5).toList() + + /** + * A step list plus the currently saved value, so a value set elsewhere + * (the phone's free-typed percent field) that doesn't fall on this + * picker's cadence is still a selectable option. Without this, opening + * the picker focuses the first step and pressing Select silently + * overwrites the real value with it. + */ + fun percentOptions(steps: List, current: Int): List = + (steps + current).toSortedSet().toList() + /** Font / outline color palette: (hex, label). Matches Apple `fontColors`. */ val FONT_COLORS: List> = listOf( "#ffffff" to "White", @@ -61,6 +82,7 @@ object TvSubtitleAppearanceOptions { "#d946ef" to "Magenta", "#ef4444" to "Red", "#3b82f6" to "Blue", + "#9ca3af" to "Gray", "#000000" to "Black", ) @@ -113,6 +135,14 @@ object TvSubtitleAppearanceOptions { else -> FontFamily.SansSerif } + /** + * Converts a stored opacity percent (0-100 for background, 1-100 for + * text) into the alpha channel the TV preview composables paint with. + * Shared by both the Settings and player HUD previews so the two never + * drift apart on rounding or clamping. + */ + fun previewOpacityAlpha(percent: Int, floor: Int): Float = percent.coerceIn(floor, 100) / 100f + fun previewAlignment(value: SubtitlePositionPreset): Alignment = when (value) { SubtitlePositionPreset.Top -> Alignment.TopCenter SubtitlePositionPreset.LowerThird -> Alignment.Center diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 0ff96ae8ea..27d79aeeec 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -291,6 +291,7 @@ fun TvSettingsScreen( onSubtitleFontSizeChanged = viewModel::setSubtitleFontSize, onSubtitleFontFamilyChanged = viewModel::setSubtitleFontFamily, onSubtitleFontColorChanged = viewModel::setSubtitleFontColor, + onSubtitleTextOpacityChanged = viewModel::setSubtitleTextOpacity, onSubtitleTextOutlineChanged = viewModel::setSubtitleTextOutline, onSubtitleTextOutlineColorChanged = viewModel::setSubtitleTextOutlineColor, onSubtitleBackgroundStyleChanged = viewModel::setSubtitleBackgroundStyle, @@ -435,6 +436,7 @@ private fun SettingsSplitLayout( onSubtitleFontSizeChanged: (SubtitleFontSizePreset) -> Unit, onSubtitleFontFamilyChanged: (String) -> Unit, onSubtitleFontColorChanged: (String) -> Unit, + onSubtitleTextOpacityChanged: (Int) -> Unit, onSubtitleTextOutlineChanged: (Boolean) -> Unit, onSubtitleTextOutlineColorChanged: (String) -> Unit, onSubtitleBackgroundStyleChanged: (SubtitleBackgroundStylePreset) -> Unit, @@ -513,6 +515,7 @@ private fun SettingsSplitLayout( onSubtitleFontSizeChanged = onSubtitleFontSizeChanged, onSubtitleFontFamilyChanged = onSubtitleFontFamilyChanged, onSubtitleFontColorChanged = onSubtitleFontColorChanged, + onSubtitleTextOpacityChanged = onSubtitleTextOpacityChanged, onSubtitleTextOutlineChanged = onSubtitleTextOutlineChanged, onSubtitleTextOutlineColorChanged = onSubtitleTextOutlineColorChanged, onSubtitleBackgroundStyleChanged = onSubtitleBackgroundStyleChanged, @@ -781,6 +784,7 @@ private fun SettingsDetailPane( onSubtitleFontSizeChanged: (SubtitleFontSizePreset) -> Unit, onSubtitleFontFamilyChanged: (String) -> Unit, onSubtitleFontColorChanged: (String) -> Unit, + onSubtitleTextOpacityChanged: (Int) -> Unit, onSubtitleTextOutlineChanged: (Boolean) -> Unit, onSubtitleTextOutlineColorChanged: (String) -> Unit, onSubtitleBackgroundStyleChanged: (SubtitleBackgroundStylePreset) -> Unit, @@ -852,6 +856,7 @@ private fun SettingsDetailPane( onSubtitleFontSizeChanged = onSubtitleFontSizeChanged, onSubtitleFontFamilyChanged = onSubtitleFontFamilyChanged, onSubtitleFontColorChanged = onSubtitleFontColorChanged, + onSubtitleTextOpacityChanged = onSubtitleTextOpacityChanged, onSubtitleTextOutlineChanged = onSubtitleTextOutlineChanged, onSubtitleTextOutlineColorChanged = onSubtitleTextOutlineColorChanged, onSubtitleBackgroundStyleChanged = onSubtitleBackgroundStyleChanged, @@ -1477,6 +1482,7 @@ private fun TvSubtitleSettingsPane( onSubtitleFontSizeChanged: (SubtitleFontSizePreset) -> Unit, onSubtitleFontFamilyChanged: (String) -> Unit, onSubtitleFontColorChanged: (String) -> Unit, + onSubtitleTextOpacityChanged: (Int) -> Unit, onSubtitleTextOutlineChanged: (Boolean) -> Unit, onSubtitleTextOutlineColorChanged: (String) -> Unit, onSubtitleBackgroundStyleChanged: (SubtitleBackgroundStylePreset) -> Unit, @@ -1587,6 +1593,14 @@ private fun TvSubtitleSettingsPane( onClick = { activePicker = SubtitlePicker.FontColor }, enabled = state.subtitleUsesDeviceOverride, ) + if (state.subtitleTextOpacitySupported) { + SettingsValueRow( + label = "Text Opacity", + value = "${appearance.textOpacity}%", + onClick = { activePicker = SubtitlePicker.TextOpacity }, + enabled = state.subtitleUsesDeviceOverride, + ) + } SettingsToggleRow( label = "Text Outline", checked = appearance.textOutline, @@ -1702,6 +1716,19 @@ private fun TvSubtitleSettingsPane( }, onDismiss = { activePicker = null }, ) + SubtitlePicker.TextOpacity -> TvSettingsPickerSheet( + title = "Text Opacity", + options = TvSubtitleAppearanceOptions.percentOptions( + TvSubtitleAppearanceOptions.TEXT_OPACITY_PERCENT_STEPS, + appearance.textOpacity, + ).map { PickerOption(it.toString(), "$it%") }, + selectedId = appearance.textOpacity.toString(), + onSelect = { id -> + id.toIntOrNull()?.let { onSubtitleTextOpacityChanged(it) } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) SubtitlePicker.OutlineColor -> TvSettingsPickerSheet( title = "Outline Color", options = TvSubtitleAppearanceOptions.OUTLINE_COLORS.map { PickerOption(it.first, it.second) }, @@ -1726,7 +1753,10 @@ private fun TvSubtitleSettingsPane( ) SubtitlePicker.BackgroundOpacity -> TvSettingsPickerSheet( title = "Background Opacity", - options = TvSubtitleAppearanceOptions.OPACITY_PERCENT_STEPS.map { PickerOption(it.toString(), "$it%") }, + options = TvSubtitleAppearanceOptions.percentOptions( + TvSubtitleAppearanceOptions.OPACITY_PERCENT_STEPS, + appearance.backgroundOpacity, + ).map { PickerOption(it.toString(), "$it%") }, selectedId = appearance.backgroundOpacity.toString(), onSelect = { id -> id.toIntOrNull()?.let { onSubtitleBackgroundOpacityChanged(it) } @@ -1789,12 +1819,14 @@ private fun TvSettingsSubtitlePreview(appearance: SubtitleAppearance) { else -> FontFamily.SansSerif } val fontSize = (safe.fontSize.pointSize * 0.36).sp - val foreground = settingsHexColor(safe.fontColor) + val foreground = settingsHexColor(safe.fontColor).copy( + alpha = TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.textOpacity, floor = 1), + ) val outline = settingsHexColor(safe.textOutlineColor) val showOutline = safe.textOutline || safe.backgroundStyle == SubtitleBackgroundStylePreset.Outline val boxColor = settingsHexColor(safe.backgroundColor).copy( alpha = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) { - safe.backgroundOpacity.coerceIn(0, 100) / 100f + TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.backgroundOpacity, floor = 0) } else { 0f }, @@ -1931,6 +1963,7 @@ private enum class SubtitlePicker { FontSize, FontFamily, FontColor, + TextOpacity, OutlineColor, BackgroundStyle, BackgroundOpacity, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 19c9f0e935..2f4ccf75fa 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -115,6 +114,8 @@ class TvSettingsViewModel( val subtitleAppearance: SubtitleAppearance = SubtitleAppearance.DEFAULT, val effectiveSubtitleAppearance: SubtitleAppearance = SubtitleAppearance.DEFAULT, val subtitleUsesDeviceOverride: Boolean = false, + /** False when the server is known to discard subtitle text opacity. */ + val subtitleTextOpacitySupported: Boolean = true, val autoPlayNext: Boolean = true, val introSkipMode: IntroSkipMode = IntroSkipMode.Default, val matchContentFrameRate: Boolean = false, @@ -409,6 +410,11 @@ class TvSettingsViewModel( _uiState.update { it.copy(effectiveSubtitleAppearance = appearance) } } } + viewModelScope.launch { + playerSettingsStore.subtitleTextOpacitySupportedFlow.collect { supported -> + _uiState.update { it.copy(subtitleTextOpacitySupported = supported) } + } + } } /** Mirror the card-presentation store into UI state (single source of truth). */ @@ -572,15 +578,15 @@ class TvSettingsViewModel( } /** - * Per-field appearance setters. Each reads the freshest appearance from the - * store before copying the single changed field, so a concurrent edit (e.g. - * a HUD change while a Settings picker is open) is not clobbered by a stale - * composable-captured snapshot. Mirrors [onSubtitleSizeChanged]. + * Per-field appearance setters. Each applies its change atomically inside + * the store's own write transaction ([PlayerSettingsStore.updateSubtitleAppearance]), + * so a concurrent edit (e.g. a HUD change while a Settings picker is + * open) can't race on a snapshot read before either writes. Mirrors + * [onSubtitleSizeChanged]. */ private fun editAppearance(transform: (SubtitleAppearance) -> SubtitleAppearance) { viewModelScope.launch { - val current = playerSettingsStore.subtitleAppearanceFlow.first() - playerSettingsStore.setSubtitleAppearance(transform(current)) + playerSettingsStore.updateSubtitleAppearance(transform) // 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. @@ -594,6 +600,8 @@ class TvSettingsViewModel( fun setSubtitleFontColor(value: String) = editAppearance { it.copy(fontColor = value) } + fun setSubtitleTextOpacity(value: Int) = editAppearance { it.copy(textOpacity = value) } + fun setSubtitleTextOutline(value: Boolean) = editAppearance { it.copy(textOutline = value) } fun setSubtitleTextOutlineColor(value: String) = editAppearance { it.copy(textOutlineColor = value) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt index e6aba83503..5da298e271 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt @@ -54,6 +54,7 @@ object PlaybackSettingsKeys { const val SubtitleFontSize = "subtitle.font_size" const val SubtitleFontFamily = "subtitle.font_family" const val SubtitleTextColor = "subtitle.text_color" + const val SubtitleTextOpacity = "subtitle.text_opacity" const val SubtitleBackgroundColor = "subtitle.background_color" const val SubtitleBackgroundStyle = "subtitle.background_style" const val SubtitleBackgroundOpacity = "subtitle.background_opacity" @@ -173,6 +174,7 @@ object PlaybackSettingsKeys { SubtitleFontSize, SubtitleFontFamily, SubtitleTextColor, + SubtitleTextOpacity, SubtitleBackgroundColor, SubtitleBackgroundStyle, SubtitleBackgroundOpacity, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt index f8f594499c..487f75b21a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt @@ -24,18 +24,22 @@ data class SettingPresentation( ) object SettingKeys { - const val REVISION = 9 + const val REVISION = 14 /** Metadata language */ const val CATALOG_METADATA_LANGUAGE = "catalog.metadata_language" /** Metadata language exceptions */ const val CATALOG_METADATA_LANGUAGE_OVERRIDES = "catalog.metadata_language_overrides" + /** Show advisory age */ + const val CATALOG_SHOW_ADVISORY_AGE = "catalog.show_advisory_age" /** 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" + /** Hide watched items from Home */ + const val HOME_HIDE_WATCHED_ITEMS = "home.hide_watched_items" /** Primary menu */ const val NAV_PRIMARY_MENU = "nav.primary_menu" /** Navigation shortcuts */ @@ -146,6 +150,10 @@ object SettingKeys { const val UI_TEXT_WEIGHT = "ui.text_weight" /** Theme */ const val UI_THEME = "ui.theme" + /** Theme music */ + const val UI_THEME_MUSIC_ENABLED = "ui.theme_music_enabled" + /** Loop theme music */ + const val UI_THEME_MUSIC_LOOP = "ui.theme_music_loop" /** Time format */ const val UI_TIME_FORMAT = "ui.time_format" @@ -153,6 +161,8 @@ object SettingKeys { val REMOTE: List = listOf( CATALOG_METADATA_LANGUAGE, CATALOG_METADATA_LANGUAGE_OVERRIDES, + CATALOG_SHOW_ADVISORY_AGE, + HOME_HIDE_WATCHED_ITEMS, NAV_PRIMARY_MENU, NAV_SHORTCUTS, PLAYBACK_AUDIO_LANGUAGE, @@ -203,6 +213,8 @@ object SettingKeys { UI_TEXT_SCALE, UI_TEXT_WEIGHT, UI_THEME, + UI_THEME_MUSIC_ENABLED, + UI_THEME_MUSIC_LOOP, UI_TIME_FORMAT, ) @@ -223,9 +235,14 @@ object SettingKeys { * pair at write time, so editing either would rewrite the other. */ val DEPRECATED: Set = setOf( PLAYBACK_AUTO_SKIP_INTRO, + UI_CUSTOM_CSS, + UI_CUSTOM_THEME_VARS, + UI_THEME, ) val BOOLEAN_KEYS: Set = setOf( + CATALOG_SHOW_ADVISORY_AGE, + HOME_HIDE_WATCHED_ITEMS, PLAYBACK_AUTO_PLAY_NEXT, PLAYBACK_AUTO_PLAY_NEXT_PREVIEW, PLAYBACK_AUTO_SKIP_CREDITS, @@ -241,6 +258,8 @@ object SettingKeys { UI_CARD_QUICK_ACTIONS_ENABLED, UI_HIGH_CONTRAST, UI_REMEMBER_LIBRARY_PAGE_STATE, + UI_THEME_MUSIC_ENABLED, + UI_THEME_MUSIC_LOOP, ) val INT_KEYS: Set = setOf( diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearance.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearance.kt index b852e43493..ac0070dbd8 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearance.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearance.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject /** * `wire` repeats what each `@SerialName` declares. It is spelled out as a @@ -56,6 +57,7 @@ data class SubtitleAppearance( val fontSize: SubtitleFontSizePreset = SubtitleFontSizePreset.Large, val fontFamily: String = SANS_SERIF, val fontColor: String = "#ffffff", + val textOpacity: Int = 100, val backgroundColor: String = "#000000", val backgroundStyle: SubtitleBackgroundStylePreset = SubtitleBackgroundStylePreset.Shadow, val backgroundOpacity: Int = 75, @@ -68,11 +70,13 @@ data class SubtitleAppearance( val safeBackgroundColor = if (isValidHex(backgroundColor)) backgroundColor else DEFAULT.backgroundColor val safeOutlineColor = if (isValidHex(textOutlineColor)) textOutlineColor else DEFAULT.textOutlineColor val clampedOpacity = backgroundOpacity.coerceIn(0, 100) + val clampedTextOpacity = textOpacity.coerceIn(1, 100) return if ( safeFontColor == fontColor && safeBackgroundColor == backgroundColor && safeOutlineColor == textOutlineColor && - clampedOpacity == backgroundOpacity + clampedOpacity == backgroundOpacity && + clampedTextOpacity == textOpacity ) { this } else { @@ -81,6 +85,7 @@ data class SubtitleAppearance( backgroundColor = safeBackgroundColor, textOutlineColor = safeOutlineColor, backgroundOpacity = clampedOpacity, + textOpacity = clampedTextOpacity, ) } } @@ -94,6 +99,31 @@ data class SubtitleAppearance( val DEFAULT: SubtitleAppearance = SubtitleAppearance() + /** Wire name of [textOpacity] inside the `playback.subtitle_appearance` object. */ + const val TEXT_OPACITY_FIELD: String = "textOpacity" + + /** + * First settings manifest revision whose `playback.subtitle_appearance` + * schema knows [textOpacity]. The schema sets additionalProperties to + * false, so an older server rejects the whole object when it carries + * the field. + */ + const val TEXT_OPACITY_MIN_MANIFEST_REVISION: Int = 14 + + fun supportsTextOpacity(manifestRevision: Int): Boolean = + manifestRevision >= TEXT_OPACITY_MIN_MANIFEST_REVISION + + /** + * [wire] as a server at [manifestRevision] accepts it: [TEXT_OPACITY_FIELD] + * is removed below [TEXT_OPACITY_MIN_MANIFEST_REVISION] and kept otherwise. + */ + fun wireObjectForRevision(wire: JsonObject, manifestRevision: Int): JsonObject = + if (supportsTextOpacity(manifestRevision) || TEXT_OPACITY_FIELD !in wire) { + wire + } else { + JsonObject(wire - TEXT_OPACITY_FIELD) + } + private val JSON = Json { encodeDefaults = true ignoreUnknownKeys = true diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjection.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjection.kt index d1bf041036..ac1648952e 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjection.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjection.kt @@ -21,6 +21,7 @@ object SubtitleAppearanceProjection { PlaybackSettingsKeys.SubtitleFontSize, PlaybackSettingsKeys.SubtitleFontFamily, PlaybackSettingsKeys.SubtitleTextColor, + PlaybackSettingsKeys.SubtitleTextOpacity, PlaybackSettingsKeys.SubtitleBackgroundColor, PlaybackSettingsKeys.SubtitleBackgroundStyle, PlaybackSettingsKeys.SubtitleBackgroundOpacity, @@ -49,6 +50,9 @@ object SubtitleAppearanceProjection { fields[PlaybackSettingsKeys.SubtitleTextColor]?.let { raw -> hexColor(raw)?.let { out = out.copy(fontColor = it) } } + fields[PlaybackSettingsKeys.SubtitleTextOpacity]?.let { raw -> + raw.trim().toIntOrNull()?.let { out = out.copy(textOpacity = it.coerceIn(1, 100)) } + } fields[PlaybackSettingsKeys.SubtitleBackgroundColor]?.let { raw -> hexColor(raw)?.let { out = out.copy(backgroundColor = it) } } @@ -80,6 +84,7 @@ object SubtitleAppearanceProjection { PlaybackSettingsKeys.SubtitleFontSize to safe.fontSize.wire, PlaybackSettingsKeys.SubtitleFontFamily to safe.fontFamily, PlaybackSettingsKeys.SubtitleTextColor to safe.fontColor, + PlaybackSettingsKeys.SubtitleTextOpacity to safe.textOpacity.toString(), PlaybackSettingsKeys.SubtitleBackgroundColor to safe.backgroundColor, PlaybackSettingsKeys.SubtitleBackgroundStyle to safe.backgroundStyle.wire, PlaybackSettingsKeys.SubtitleBackgroundOpacity to safe.backgroundOpacity.toString(), diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjectionTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjectionTest.kt index 9a2a3e94a3..e36d48cda6 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjectionTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceProjectionTest.kt @@ -21,6 +21,7 @@ class SubtitleAppearanceProjectionTest { PlaybackSettingsKeys.SubtitleBackgroundColor to "#101010", PlaybackSettingsKeys.SubtitleBackgroundStyle to "box", PlaybackSettingsKeys.SubtitleBackgroundOpacity to "40", + PlaybackSettingsKeys.SubtitleTextOpacity to "65", PlaybackSettingsKeys.SubtitleTextOutline to "true", PlaybackSettingsKeys.SubtitleTextOutlineColor to "#001122", PlaybackSettingsKeys.SubtitlePosition to "lower-third", @@ -33,6 +34,7 @@ class SubtitleAppearanceProjectionTest { assertEquals("#101010", projected.backgroundColor) assertEquals(SubtitleBackgroundStylePreset.Box, projected.backgroundStyle) assertEquals(40, projected.backgroundOpacity) + assertEquals(65, projected.textOpacity) assertEquals(true, projected.textOutline) assertEquals("#001122", projected.textOutlineColor) assertEquals(SubtitlePositionPreset.LowerThird, projected.position) @@ -99,6 +101,30 @@ class SubtitleAppearanceProjectionTest { ) } + @Test + fun `text opacity floors at 1 rather than 0`() { + // Unlike backgroundOpacity (0-100), textOpacity's floor is 1: fully + // invisible text isn't a state this field can express. + assertEquals( + 100, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOpacity to "180"), + ).textOpacity, + ) + assertEquals( + 1, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOpacity to "0"), + ).textOpacity, + ) + assertEquals( + 1, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOpacity to "-5"), + ).textOpacity, + ) + } + @Test fun `the legacy numeric position maps onto the enum`() { fun positionFor(raw: String) = SubtitleAppearanceProjection.project( diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceTest.kt index b840bd8642..0e18184364 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/SubtitleAppearanceTest.kt @@ -1,7 +1,13 @@ package org.siloserver.silo.model.settings +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue class SubtitleAppearanceTest { @@ -49,4 +55,27 @@ class SubtitleAppearanceTest { assertEquals(SubtitleBackgroundStylePreset.Shadow, decoded.backgroundStyle) } + + @Test + fun textOpacityIsRemovedFromTheWireObjectBelowRevision14() { + val wire = wireObject(SubtitleAppearance.DEFAULT.copy(textOpacity = 40)) + + val forOldServer = SubtitleAppearance.wireObjectForRevision(wire, manifestRevision = 13) + + assertFalse(SubtitleAppearance.TEXT_OPACITY_FIELD in forOldServer) + // Every other field still reaches the server. + assertEquals(wire - SubtitleAppearance.TEXT_OPACITY_FIELD, forOldServer.toMap()) + } + + @Test + fun textOpacityIsKeptInTheWireObjectFromRevision14() { + val wire = wireObject(SubtitleAppearance.DEFAULT.copy(textOpacity = 40)) + + assertSame(wire, SubtitleAppearance.wireObjectForRevision(wire, manifestRevision = 14)) + assertTrue(SubtitleAppearance.supportsTextOpacity(14)) + assertFalse(SubtitleAppearance.supportsTextOpacity(13)) + } + + private fun wireObject(appearance: SubtitleAppearance): JsonObject = + Json.parseToJsonElement(appearance.toJsonString()).jsonObject } diff --git a/shared/src/commonTest/resources/settings/v1/SOURCE b/shared/src/commonTest/resources/settings/v1/SOURCE index 2be9199c50..215b078000 100644 --- a/shared/src/commonTest/resources/settings/v1/SOURCE +++ b/shared/src/commonTest/resources/settings/v1/SOURCE @@ -1,6 +1,6 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/settings/v1 -manifest_revision=9 +manifest_revision=14 fixture_version=1 Both files are byte-identical copies of the server's canonical contract; do not @@ -10,9 +10,9 @@ 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=a015631ba22e48e64d8efd4830177687610c6a50 -manifest.json commit=a015631ba22e48e64d8efd4830177687610c6a50 -copied from commit=a015631ba22e48e64d8efd4830177687610c6a50 +conformance.json commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9 +manifest.json commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9 +copied from commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9 manifest.json is vendored whole, including the maintainer `notes` the server strips before serving /api/v1/settings/contract. Keeping it byte-identical is diff --git a/shared/src/commonTest/resources/settings/v1/conformance.json b/shared/src/commonTest/resources/settings/v1/conformance.json index 0c4fd8f945..f63d46fa00 100644 --- a/shared/src/commonTest/resources/settings/v1/conformance.json +++ b/shared/src/commonTest/resources/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 9, + "manifest_revision": 14, "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": [ { @@ -849,6 +849,7 @@ "fontSize": "large", "fontFamily": "sans-serif", "fontColor": "#ffffff", + "textOpacity": 100, "backgroundColor": "#000000", "backgroundStyle": "box", "backgroundOpacity": 75, @@ -1002,6 +1003,68 @@ "source": "default" } ] + }, + { + "name": "advisory_age_badge_is_off_until_a_profile_asks", + "description": "The advisory-age badge is per profile and off unless that profile turned it on. p1 opted in, so it resolves true from the profile; p2 stored nothing and falls to the contract default false. A profile row must never leak across profiles here, because the setting decides what a household parent sees about a child's viewing.", + "keys": ["catalog.show_advisory_age"], + "context": { "profile_id": "p2", "device_id": "d1" }, + "stored": [ + { + "key": "catalog.show_advisory_age", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "catalog.show_advisory_age", + "value": false, + "source": "default" + } + ] + }, + { + "name": "advisory_age_badge_resolves_from_the_opted_in_profile", + "description": "The same stored row resolves true for the profile that owns it, with source \"profile\" rather than the default.", + "keys": ["catalog.show_advisory_age"], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [ + { + "key": "catalog.show_advisory_age", + "scope": "profile", + "profile_id": "p1", + "value": true + } + ], + "expected": [ + { + "key": "catalog.show_advisory_age", + "value": true, + "source": "profile" + } + ] + }, + { + "name": "theme_music_is_opt_in", + "keys": ["ui.theme_music_enabled", "ui.theme_music_loop"], + "context": {"profile_id": "p1", "device_id": "browser"}, + "stored": [], + "expected": [ + {"key": "ui.theme_music_enabled", "value": false, "source": "default"}, + {"key": "ui.theme_music_loop", "value": false, "source": "default"} + ] + }, + { + "name": "theme_music_device_overrides_profile", + "keys": ["ui.theme_music_enabled"], + "context": {"profile_id": "p1", "device_id": "browser"}, + "stored": [ + {"key": "ui.theme_music_enabled", "scope": "profile", "profile_id": "p1", "value": true}, + {"key": "ui.theme_music_enabled", "scope": "profile_device", "profile_id": "p1", "device_id": "browser", "value": false} + ], + "expected": [{"key": "ui.theme_music_enabled", "value": false, "source": "profile_device"}] } ] } diff --git a/shared/src/commonTest/resources/settings/v1/manifest.json b/shared/src/commonTest/resources/settings/v1/manifest.json index 3d6dd22240..427ab69649 100644 --- a/shared/src/commonTest/resources/settings/v1/manifest.json +++ b/shared/src/commonTest/resources/settings/v1/manifest.json @@ -1,6 +1,6 @@ { "api_version": 1, - "revision": 9, + "revision": 14, "option_sets": { "playback_audio_languages": { "type": "language_tag", @@ -365,7 +365,7 @@ "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." + "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. From revision 9, the private-use tag x-silo-original means play each item in its original language: the server resolves it against the item's original_language (falling back to the profile choice when that is unknown), as it already did for the legacy \"original\" value. It is a valid value of the existing language_tag schema, so this is additive; clients that do not recognize it should label it \"Original language\" rather than as a language. The Jellyfin-compatible API maps Jellyfin's AudioLanguagePreference \"OriginalLanguage\" to it." }, { "key": "playback.subtitle_language", @@ -450,6 +450,7 @@ "backgroundColor": "#000000", "backgroundStyle": "box", "backgroundOpacity": 75, + "textOpacity": 100, "textOutline": false, "textOutlineColor": "#000000", "position": "bottom" @@ -458,7 +459,7 @@ "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. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative." + "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. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative. Revision 14 adds the optional textOpacity property (1-100, default 100) controlling the opacity of the subtitle text itself, independent of backgroundOpacity; a value stored without it resolves to fully opaque text, matching pre-14 rendering." }, { "key": "playback.preferred_quality", @@ -645,6 +646,20 @@ "recommended_control": "panel", "notes": "Keys are canonical catalog original_language codes. Values are target BCP 47 language tags; x-silo-original means retain that source language. This key is separate from catalog.metadata_language so existing clients can continue changing the fallback without rewriting or discarding exceptions." }, + { + "key": "catalog.show_advisory_age", + "introduced_in": 10, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "catalog", + "label": "Show advisory age", + "description": "Show a recommended minimum viewer age from an advisory service, such as Common Sense Media, on item detail.", + "recommended_control": "switch", + "notes": "Profile scope because the reader is a household parent deciding what a child may watch, and they browse on their own profile. Default false: the advisory is extra chrome most viewers do not want, and only the profiles that ask for it should carry it. This setting only controls whether the badge is shown: turning it on or off never changes what a profile is allowed to watch. Restricting a profile by advisory age is a separate access field, the profile's max_advisory_age, which a household manager sets on the profile itself. Coverage is partial by nature: the provider that supplies the advisory is rate limited per day, so on a large library some titles carry one and others do not." + }, { "key": "player.hdr_enabled", "introduced_in": 1, @@ -836,7 +851,8 @@ "label": "Theme", "description": "Colour theme for the Silo 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-Silo-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." + "deprecated": true, + "notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_theme\" at the settings cutover." }, { "key": "ui.text_scale", @@ -914,7 +930,8 @@ "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." + "deprecated": true, + "notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON." }, { "key": "ui.custom_css", @@ -929,7 +946,8 @@ "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." + "deprecated": true, + "notes": "Deprecated in revision 12: the web client has one theme, Cinema Dark, which only the admin customizes (accent color plus the ui.admin_theme_vars and ui.admin_custom_css server settings), and it no longer reads or writes this key. Web-only (platforms: web). Stored values were deleted by migration 20260926233851_retire_profile_themes (SQLite user store schema v29). The definition stays published so a stale cached web bundle's write is accepted rather than failing; nothing reads what it stores. Renamed from the unregistered legacy key \"ui_custom_css\". The contract only bounds length." }, { "key": "ui.date_format", @@ -974,6 +992,20 @@ "recommended_control": "select", "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." }, + { + "key": "home.hide_watched_items", + "introduced_in": 12, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "navigation", + "label": "Hide watched items from Home", + "description": "Remove watched items from ordinary Home sections while keeping Featured and watch-history sections unchanged.", + "recommended_control": "switch" + }, { "key": "ui.library_page_state", "introduced_in": 1, @@ -1043,7 +1075,7 @@ "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 server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled." + "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 server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled. Revision 13 adds the advisory_age overlay id; a server below revision 13 rejects a value that contains it, so a client offers that badge only when manifest_revision is at least 13." }, { "key": "ui.card_overlays_enabled", @@ -1098,6 +1130,56 @@ "recommended_control": "switch", "notes": "null means the profile has not chosen whether quick actions are enabled, so the server-wide default from the overlay-config endpoint applies. An explicit profile choice overrides the server-wide default in either direction." }, + { + "key": "ui.theme_music_enabled", + "introduced_in": 11, + "persistence": "remote", + "allowed_scopes": [ + "profile", + "profile_device" + ], + "resolution_order": [ + "profile_device", + "profile", + "default" + ], + "value_schema": { + "type": "boolean" + }, + "default_value": false, + "platforms": [ + "web" + ], + "category": "playback", + "label": "Theme music", + "description": "Play local theme music while browsing movie and show detail pages.", + "recommended_control": "switch" + }, + { + "key": "ui.theme_music_loop", + "introduced_in": 11, + "persistence": "remote", + "allowed_scopes": [ + "profile", + "profile_device" + ], + "resolution_order": [ + "profile_device", + "profile", + "default" + ], + "value_schema": { + "type": "boolean" + }, + "default_value": false, + "platforms": [ + "web" + ], + "category": "playback", + "label": "Loop theme music", + "description": "Repeat theme music while the detail page stays open.", + "recommended_control": "switch" + }, { "key": "ui.next_up_mode", "introduced_in": 1, From f0cfae43a8ef11e441bec621df72e4d417577fbc Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:49:08 -0400 Subject: [PATCH 02/87] ci: add trusted reusable Linux workflow (#425) --- .github/workflows/trusted-linux-ci.yml | 260 +++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 .github/workflows/trusted-linux-ci.yml diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml new file mode 100644 index 0000000000..0c1bae0065 --- /dev/null +++ b/.github/workflows/trusted-linux-ci.yml @@ -0,0 +1,260 @@ +name: Trusted Linux CI + +# Runner-group policy allows this file only at refs/heads/main. +# The host controller checks live membership and creates a runner for this run. +on: + workflow_call: + inputs: + self_hosted: + type: boolean + required: false + default: false + build_ffmpeg_aar: + type: boolean + required: false + default: false + +permissions: + contents: read + +jobs: + ffmpeg-aar: + name: Media3 FFmpeg AAR + if: github.event_name == 'workflow_dispatch' && inputs.build_ffmpeg_aar + runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-ffmpeg-aar"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + timeout-minutes: 45 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Install pinned native toolchain + shell: bash + run: | + set -euo pipefail + "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --install \ + "ndk;26.3.11579264" \ + "cmake;3.22.1" + + - name: Build FFmpeg decoder extension + shell: bash + run: | + set -euo pipefail + export ANDROID_NDK_HOME="${ANDROID_HOME}/ndk/26.3.11579264" + ./scripts/build-ffmpeg-aar.sh + + - name: Verify packaged ABIs and renderer + shell: bash + run: | + set -euo pipefail + aar="android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar" + unzip -l "${aar}" | grep -F "jni/arm64-v8a/libffmpegJNI.so" + unzip -l "${aar}" | grep -F "jni/armeabi-v7a/libffmpegJNI.so" + unzip -l "${aar}" | grep -F "jni/x86_64/libffmpegJNI.so" + unzip -p "${aar}" classes.jar > /tmp/media3-ffmpeg-classes.jar + unzip -l /tmp/media3-ffmpeg-classes.jar | \ + grep -F "androidx/media3/decoder/ffmpeg/FfmpegAudioRenderer.class" + readelf="${ANDROID_HOME}/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" + for abi in arm64-v8a armeabi-v7a x86_64; do + so="/tmp/libffmpegJNI-${abi}.so" + unzip -p "${aar}" "jni/${abi}/libffmpegJNI.so" > "${so}" + "${readelf}" -lW "${so}" | python3 -c ' + import sys + alignments = [ + int(line.split()[-1], 16) + for line in sys.stdin + if line.lstrip().startswith("LOAD ") + ] + if not alignments or min(alignments) < 0x4000: + raise SystemExit(f"ELF LOAD alignment is below 16 KB: {alignments}") + ' + done + sha256sum "${aar}" + + - name: Upload FFmpeg AAR + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: media3-decoder-ffmpeg-1.11.0 + path: android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar + if-no-files-found: error + + release-readiness: + name: Release readiness + if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-release-readiness"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + timeout-minutes: 45 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + # Match release.yml: the pinned Ruby and frozen lockfile must work + # before a release reaches the Google Play job. + - name: Set up Ruby and install release dependencies + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + with: + bundler-cache: true + + - name: Load the release lane and Play client + run: | + bundle exec fastlane lanes + bundle exec ruby -e "require 'supply'" + env: + FASTLANE_SKIP_UPDATE_CHECK: "true" + FASTLANE_OPT_OUT_USAGE: "true" + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + # Exercise both Play artifacts, including R8 and bundle packaging. + # Signing and publishing remain in release.yml. + - name: Build phone and TV release bundles + run: | + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + :androidApp:bundleRelease :androidTvApp:bundleRelease \ + --max-workers=2 + test -s androidApp/build/outputs/bundle/release/androidApp-release.aab + test -s androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + + unit-tests: + name: Unit tests + if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-unit-tests"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Check build supply chain + run: | + ./scripts/test-check-build-supply-chain.sh + ./scripts/check-build-supply-chain.sh + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + # Robolectric does NOT resolve its Android runtimes through Gradle. At test + # execution time it fetches android-all-instrumented straight from Maven + # Central into ~/.m2, which setup-gradle's cache does not cover — so every + # run re-downloaded 85-204 MB per SDK level. On 2026-08-11 Maven Central + # answered 403 and took out 31 unrelated tests in android-shared, and the + # download time is a large part of this job. + # + # Which runtimes get fetched depends on the Robolectric version AND on the + # SDK each test resolves to, so the key hashes the version catalogue and + # the module build files (min/target SDK) together. A new @Config(sdk=NN) + # in a test is deliberately NOT in the key: hashing test sources would + # bust this cache on nearly every pull request, which costs more than it + # saves. The residual gap is bounded — that one runtime is re-fetched each + # run until something else moves the key — and the restore-key prefix + # still seeds from the previous cache rather than starting cold. + # + # A run-id-suffixed key would close the gap by saving every run, but this + # cache is hundreds of MB; a new entry per run would evict the Gradle + # cache against the repository's 10 GB budget. + - name: Cache Robolectric Android runtimes + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', '**/build.gradle.kts') }} + restore-keys: | + robolectric-${{ runner.os }}- + + - name: Run unit tests + shell: bash + run: | + set -euo pipefail + + # Debug-variant unit tests only. The release variant compiles and runs + # the exact same sources for no extra signal, and doubles CI time. + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + testDebugUnitTest \ + --max-workers=2 + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: unit-test-reports + path: | + **/build/reports/tests/** + if-no-files-found: ignore + + lint: + name: Lint + if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-lint"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + - name: Run lint + shell: bash + run: | + set -euo pipefail + + # NewApi and InlinedApi are fatal here. Two startup crashes reached + # users because nothing checked API levels: a Spatializer call gated + # at 31 for a class introduced at 32, and an unguarded API 28 call. + # Existing findings are held in per-module lint-baseline.xml, so only + # NEW violations fail. If this job fails, fix the call — do not + # regenerate the baseline to make it pass. + # + # lintVitalRelease runs here too. checkReleaseBuilds makes it part of + # assembleRelease, which only ever runs from release.yml on a v* tag — + # so without this the release variant is gated by a check that no pull + # request exercises, and the first run would be mid-release. It also + # covers the release-only source sets that lintDebug never sees. + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ + :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease \ + --max-workers=2 + + - name: Upload lint reports + if: failure() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: lint-reports + path: | + **/build/reports/lint-results-*.html + **/build/reports/lint-results-*.xml + if-no-files-found: ignore From e24d9e1fdfdfbfa02f6bda9141a3b24cfd1bd039 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:16:46 -0400 Subject: [PATCH 03/87] ci: route member builds to private Linux runners (#426) --- .github/workflows/android-build.yml | 245 +--------------------------- 1 file changed, 5 insertions(+), 240 deletions(-) diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 56a6fdb6ea..daaebbc194 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -25,243 +25,8 @@ concurrency: cancel-in-progress: true jobs: - ffmpeg-aar: - name: Media3 FFmpeg AAR - if: github.event_name == 'workflow_dispatch' && inputs.build_ffmpeg_aar - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Check out sources - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - - name: Set up JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 - with: - distribution: temurin - java-version: "21" - - - name: Install pinned native toolchain - shell: bash - run: | - set -euo pipefail - "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --install \ - "ndk;26.3.11579264" \ - "cmake;3.22.1" - - - name: Build FFmpeg decoder extension - shell: bash - run: | - set -euo pipefail - export ANDROID_NDK_HOME="${ANDROID_HOME}/ndk/26.3.11579264" - ./scripts/build-ffmpeg-aar.sh - - - name: Verify packaged ABIs and renderer - shell: bash - run: | - set -euo pipefail - aar="android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar" - unzip -l "${aar}" | grep -F "jni/arm64-v8a/libffmpegJNI.so" - unzip -l "${aar}" | grep -F "jni/armeabi-v7a/libffmpegJNI.so" - unzip -l "${aar}" | grep -F "jni/x86_64/libffmpegJNI.so" - unzip -p "${aar}" classes.jar > /tmp/media3-ffmpeg-classes.jar - unzip -l /tmp/media3-ffmpeg-classes.jar | \ - grep -F "androidx/media3/decoder/ffmpeg/FfmpegAudioRenderer.class" - readelf="${ANDROID_HOME}/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" - for abi in arm64-v8a armeabi-v7a x86_64; do - so="/tmp/libffmpegJNI-${abi}.so" - unzip -p "${aar}" "jni/${abi}/libffmpegJNI.so" > "${so}" - "${readelf}" -lW "${so}" | python3 -c ' - import sys - alignments = [ - int(line.split()[-1], 16) - for line in sys.stdin - if line.lstrip().startswith("LOAD ") - ] - if not alignments or min(alignments) < 0x4000: - raise SystemExit(f"ELF LOAD alignment is below 16 KB: {alignments}") - ' - done - sha256sum "${aar}" - - - name: Upload FFmpeg AAR - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 - with: - name: media3-decoder-ffmpeg-1.11.0 - path: android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar - if-no-files-found: error - - release-readiness: - name: Release readiness - if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Check out sources - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - # Match release.yml: the pinned Ruby and frozen lockfile must work - # before a release reaches the Google Play job. - - name: Set up Ruby and install release dependencies - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 - with: - bundler-cache: true - - - name: Load the release lane and Play client - run: | - bundle exec fastlane lanes - bundle exec ruby -e "require 'supply'" - env: - FASTLANE_SKIP_UPDATE_CHECK: "true" - FASTLANE_OPT_OUT_USAGE: "true" - - - name: Set up JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 - with: - distribution: temurin - java-version: "21" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - - # Exercise both Play artifacts, including R8 and bundle packaging. - # Signing and publishing remain in release.yml. - - name: Build phone and TV release bundles - run: | - ./gradlew \ - -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ - :androidApp:bundleRelease :androidTvApp:bundleRelease \ - --max-workers=2 - test -s androidApp/build/outputs/bundle/release/androidApp-release.aab - test -s androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab - - unit-tests: - name: Unit tests - if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar - runs-on: ubuntu-latest - - steps: - - name: Check out sources - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - - name: Check build supply chain - run: | - ./scripts/test-check-build-supply-chain.sh - ./scripts/check-build-supply-chain.sh - - - name: Set up JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 - with: - distribution: temurin - java-version: "21" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - - # Robolectric does NOT resolve its Android runtimes through Gradle. At test - # execution time it fetches android-all-instrumented straight from Maven - # Central into ~/.m2, which setup-gradle's cache does not cover — so every - # run re-downloaded 85-204 MB per SDK level. On 2026-08-11 Maven Central - # answered 403 and took out 31 unrelated tests in android-shared, and the - # download time is a large part of this job. - # - # Which runtimes get fetched depends on the Robolectric version AND on the - # SDK each test resolves to, so the key hashes the version catalogue and - # the module build files (min/target SDK) together. A new @Config(sdk=NN) - # in a test is deliberately NOT in the key: hashing test sources would - # bust this cache on nearly every pull request, which costs more than it - # saves. The residual gap is bounded — that one runtime is re-fetched each - # run until something else moves the key — and the restore-key prefix - # still seeds from the previous cache rather than starting cold. - # - # A run-id-suffixed key would close the gap by saving every run, but this - # cache is hundreds of MB; a new entry per run would evict the Gradle - # cache against the repository's 10 GB budget. - - name: Cache Robolectric Android runtimes - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.m2/repository/org/robolectric - key: robolectric-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', '**/build.gradle.kts') }} - restore-keys: | - robolectric-${{ runner.os }}- - - - name: Run unit tests - shell: bash - run: | - set -euo pipefail - - # Debug-variant unit tests only. The release variant compiles and runs - # the exact same sources for no extra signal, and doubles CI time. - ./gradlew \ - -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ - testDebugUnitTest \ - --max-workers=2 - - - name: Upload test reports - if: failure() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 - with: - name: unit-test-reports - path: | - **/build/reports/tests/** - if-no-files-found: ignore - - lint: - name: Lint - if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar - runs-on: ubuntu-latest - - steps: - - name: Check out sources - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - - name: Set up JDK 21 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 - with: - distribution: temurin - java-version: "21" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - - - name: Run lint - shell: bash - run: | - set -euo pipefail - - # NewApi and InlinedApi are fatal here. Two startup crashes reached - # users because nothing checked API levels: a Spatializer call gated - # at 31 for a class introduced at 32, and an unguarded API 28 call. - # Existing findings are held in per-module lint-baseline.xml, so only - # NEW violations fail. If this job fails, fix the call — do not - # regenerate the baseline to make it pass. - # - # lintVitalRelease runs here too. checkReleaseBuilds makes it part of - # assembleRelease, which only ever runs from release.yml on a v* tag — - # so without this the release variant is gated by a check that no pull - # request exercises, and the first run would be mid-release. It also - # covers the release-only source sets that lintDebug never sees. - ./gradlew \ - -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ - :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ - :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease \ - --max-workers=2 - - - name: Upload lint reports - if: failure() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 - with: - name: lint-reports - path: | - **/build/reports/lint-results-*.html - **/build/reports/lint-results-*.xml - if-no-files-found: ignore + ci: + uses: Silo-Server/silo-android/.github/workflows/trusted-linux-ci.yml@main + with: + self_hosted: ${{ github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main')) }} + build_ffmpeg_aar: ${{ inputs.build_ffmpeg_aar || false }} From 41f5d40a14234b6799d64bf731e30e54a75c0ad5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:57:39 -0400 Subject: [PATCH 04/87] feat(detail): show the same external ratings on every title page (#423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(detail): show the same external ratings on every title page Title pages now show the server's ratings row: each source's mark and score in the order the server sends them. On the phone and TV detail pages the row replaces the IMDb-only token. TMDB's mark is its approved logo, and every other source's mark is its name as dimmed text. Screen readers hear "IMDb 8.5". A server without the row falls back to IMDb, then TMDB, never Rotten Tomatoes. On a TV series page, the ratings stay in the hero when an episode is focused. The TV focus marquee shows one rating with its mark, IMDb or else TMDB, where it used to show a bare number. Request pages show the TMDB vote average as a TMDB entry. Poster rating badges now carry their source in the label ("IMDb 8.5", "RT 93%", "RT Audience 95%") and draw no icon, so the tomato mark is gone. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(detail): show at most three ratings on one line on phones With an administrator's extra rating sources turned on, the phone title page wrapped the external ratings onto a second line: Back to the Future with Rotten Tomatoes and Metacritic on showed three entries with two more underneath. The phone layout now shows the first three ratings in the server's order, on one line. When even those do not fit (a narrow screen, large text, or a long source name from a plugin), whole entries drop from the end instead of wrapping or being cut off. The tablet and fold layout and Android TV still show every rating. Web and iOS apply the same limit. The limit is ExternalRatings.PHONE_LIMIT with a forPhone helper, covered by a unit test. The TV's single-line row that drops whole tokens moves to android-shared so both apps use it; it relies only on stable Compose layout APIs. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(detail): keep formats visible and one bad rating from breaking detail - TV hero: ratings drop before playback formats and sources when the facts row runs out of width. - The hero, Home marquee and TV request line share one facts row, so all three drop whole tokens instead of clipping; a long episode title still ends in an ellipsis. - Decode the ratings list entry by entry and make score optional, so a malformed entry no longer fails the whole item detail. - Expanded phone hero: ratings sit on one line, and the text block takes only the height the actions leave. - Phone request: no availability-label fallback when a TMDB rating shows. - Move the poster-badge label changes out of this PR. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(detail): let any TV rating drop first and wrap tablet ratings - WholeTokenRow now places "·" separators itself, only between the tokens it shows, so the TV facts row can drop a rating that comes first without leaving an orphaned divider. - The tablet and foldable hero wraps its ratings again; the text block is already clipped to the height the actions leave. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(detail): keep the ratings row visible on foldable and small tablet layouts Beside a portrait poster, the expanded detail hero gave its whole text block a fixed share of the height and clipped it, so at fold and small tablet widths (about 664dp) a tall title logo pushed the ratings row out of view. Only the title is weighted now: the facts and ratings rows are measured first, and the logo scales down to the height that is left. Play still lines up with the bottom of the poster. The server now sends at most three ratings, so the tablet row no longer wraps. Phone and tablet share one single-line row that drops whole entries from the end when space runs out, matching TV. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): let genres give way before ratings in the detail facts row When the TV detail facts row ran out of width, every rating dropped before the type and genre words, so Back to the Future lost RT and the Moon Knight series page kept only IMDb. The source tokens (type, genres, episode numbers) now give way first, then ratings from the end of the list, so IMDb stays longest. Other facts and any playback formats stay visible, as before. This matches tvOS. WholeTokenRow's dropFirst takes a rank so a row can drop one group of tokens before another. The foldable and small-tablet hero also gets a 12dp gap between the ratings row and Play. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(ratings): say every layout shows ratings on one line The PHONE_LIMIT doc still said tablet, fold and TV layouts show the whole ratings list. They now share the one-line row that drops whole entries from the end, and the server sends at most three ratings. PHONE_LIMIT stays as a guard against an older server that sends more. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- THIRD_PARTY_NOTICES.md | 4 + .../siloserver/silo/common/ui/RatingEntry.kt | 105 +++++++++++++ .../silo/common/ui/WholeTokenRow.kt | 123 +++++++++++++++ .../androidMain/res/drawable/tmdb_logo.xml | 34 ++++ .../screens/detail/DetailSharedComponents.kt | 68 ++++++-- .../screens/requests/RequestDetailScreen.kt | 32 +++- .../silo/tv/ui/components/TvFactsRow.kt | 145 ++++++++++++++++++ .../silo/tv/ui/components/TvFocusMarquee.kt | 22 ++- .../tv/ui/components/TvFocusMarqueeModel.kt | 33 ++-- .../silo/tv/ui/screens/detail/TvDetailHero.kt | 113 +++----------- .../tv/ui/screens/detail/TvDetailMetadata.kt | 21 ++- .../ui/screens/detail/TvItemDetailScreen.kt | 2 + .../screens/detail/TvItemDetailViewModel.kt | 2 + .../screens/requests/TvRequestDetailScreen.kt | 40 +++-- .../ui/components/TvFocusMarqueeModelTest.kt | 39 +++-- .../ui/screens/detail/TvDetailMetadataTest.kt | 1 + .../silo/model/catalog/CatalogModels.kt | 6 + .../silo/model/catalog/ExternalRatings.kt | 128 ++++++++++++++++ .../apiv2/CatalogDetailReadV2Models.kt | 4 + .../silo/model/catalog/ExternalRatingsTest.kt | 129 ++++++++++++++++ 20 files changed, 871 insertions(+), 180 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/RatingEntry.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/WholeTokenRow.kt create mode 100644 android-shared/src/androidMain/res/drawable/tmdb_logo.xml create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFactsRow.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ExternalRatings.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/ExternalRatingsTest.kt diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0b30bf9721..38a329a6e6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -18,6 +18,10 @@ Silo uses [`ass-media` 0.5.1](https://github.com/peerless2012/libass-android) un The Dolby Vision bridge AAR is built from the `dolby_vision` crate in [`quietvoid/dovi_tool` 2.3.1](https://github.com/quietvoid/dovi_tool/tree/b25558062e4a56973482ec70133bd7b891320e48/dolby_vision), distributed under the MIT license. The upstream source commit, archive and lockfiles, the OSV-clean build lock, toolchains, native outputs, and packaged AAR are pinned in `android-shared/src/native/dovi/provenance.json`. The build lock records remediations for RUSTSEC-2026-0190, RUSTSEC-2026-0105, and RUSTSEC-2026-0204. +## TMDB Logo + +`android-shared/src/androidMain/res/drawable/tmdb_logo.xml` is TMDB's approved "alt short" logo from , converted to a VectorDrawable without changing its shape or colours. It is a trademark of TMDB and is not covered by this repository's license. Silo shows it next to TMDB scores; keep it unmodified and less prominent than Silo's own branding. + ## Gradle Wrapper The Gradle wrapper scripts retain their upstream Apache-2.0 license. diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/RatingEntry.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/RatingEntry.kt new file mode 100644 index 0000000000..4d552d0490 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/RatingEntry.kt @@ -0,0 +1,105 @@ +package org.siloserver.silo.common.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp +import org.siloserver.silo.common.R +import org.siloserver.silo.model.catalog.DisplayRating +import org.siloserver.silo.model.catalog.ExternalRatings + +/** + * One external rating as its source's mark and its score: `IMDb 8.5`, + * `RT 93%`. The mark is the source's name as plain text, never a star or the + * source's artwork, except TMDB, whose approved logo its terms allow. The + * score is [DisplayRating.display] as the server formatted it. Screen readers + * hear "IMDb 8.5". + * + * [style] is the score's text style and should carry a color and font size; + * the mark is drawn from it semibold and dimmed, at [markFontSize] or a + * little smaller than the score, and the TMDB logo is sized to about the + * score's cap height. + * + * Callers lay entries out themselves; [WholeTokenRow] keeps them on one line. + * Do not add a FlowRow-based row here: FlowRow is experimental, and this + * module compiles against an older Compose foundation than the apps ship, so + * the call fails at runtime with NoSuchMethodError. + */ +@Composable +fun RatingEntry( + rating: DisplayRating, + style: TextStyle, + modifier: Modifier = Modifier, + markFontSize: TextUnit = TextUnit.Unspecified, +) { + val fontSize = style.fontSize.takeIf { it.isSpecified } ?: DefaultScoreSize + val scoreStyle = style.merge( + TextStyle( + fontSize = fontSize, + fontWeight = FontWeight.Bold, + fontFeatureSettings = "tnum", + ), + ) + Row( + modifier = modifier.clearAndSetSemantics { + contentDescription = "${rating.name} ${rating.display}" + }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (rating.source == ExternalRatings.SOURCE_TMDB) { + val logoHeight = with(LocalDensity.current) { (fontSize * TmdbLogoCapHeight).toDp() } + Image( + painter = painterResource(R.drawable.tmdb_logo), + contentDescription = rating.name, + contentScale = ContentScale.Fit, + modifier = Modifier.size(width = logoHeight * TmdbLogoAspectRatio, height = logoHeight), + ) + } else { + BasicText( + text = rating.name, + style = style.merge( + TextStyle( + fontSize = markFontSize.takeIf { it.isSpecified } ?: (fontSize * MarkScale), + fontWeight = FontWeight.SemiBold, + ), + ), + maxLines = 1, + softWrap = false, + modifier = Modifier.alpha(MarkAlpha), + ) + } + BasicText( + text = rating.display, + style = scoreStyle, + maxLines = 1, + softWrap = false, + ) + } +} + +private val DefaultScoreSize = 14.sp +private const val MarkScale = 0.87f +private const val MarkAlpha = 0.75f + +/** About a cap height of the score text, like the web client's logo. */ +private const val TmdbLogoCapHeight = 0.66f + +/** The logo's own proportions (its viewBox), kept unmodified. */ +private const val TmdbLogoAspectRatio = 273.42f / 35.52f diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/WholeTokenRow.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/WholeTokenRow.kt new file mode 100644 index 0000000000..499d135374 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/WholeTokenRow.kt @@ -0,0 +1,123 @@ +package org.siloserver.silo.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp + +/** + * A single-line row that shows as many whole children as fit, in order, and + * drops the rest. A child is never moved to a second line, and is never cut + * partway unless it opts in with [WholeTokenRowScope.shrinkable]. The row is + * as wide as the children it shows, so a centering parent centers them. + * Children are vertically centered. + * + * Children marked [WholeTokenRowScope.separator] (a "·") are not tokens: the + * row shows one only between two shown tokens, so dropping a token never + * leaves a separator at either end or two in a row. + */ +@Composable +fun WholeTokenRow( + spacing: Dp, + modifier: Modifier = Modifier, + content: @Composable WholeTokenRowScope.() -> Unit, +) { + Layout(content = { WholeTokenRowScope.content() }, modifier = modifier) { measurables, constraints -> + val gap = spacing.roundToPx() + val flags = measurables.map { it.parentData as? TokenFlags ?: TokenFlags() } + val widths = measurables.map { it.maxIntrinsicWidth(Constraints.Infinity) } + + // The separator written right after token [index], if any. A shown + // token keeps it only when another token is shown after it. + fun separatorAfter(index: Int): Int? = (index + 1).takeIf { it < measurables.size && flags[it].separator } + + // Width a token adds after [previous]: the gap, plus previous's separator and its gap. + fun joinWidth(previous: Int?): Int = if (previous == null) { + 0 + } else { + gap + (separatorAfter(previous)?.let { widths[it] + gap } ?: 0) + } + + fun widthOf(tokens: List): Int = + tokens.withIndex().sumOf { (k, index) -> joinWidth(tokens.getOrNull(k - 1)) + widths[index] } + + // Too wide: drop the tokens marked dropFirst before any other, the + // highest rank first and, within a rank, the last first. + val kept = measurables.indices.filterNot { flags[it].separator }.toMutableList() + while (widthOf(kept) > constraints.maxWidth) { + val rank = kept.maxOfOrNull { flags[it].dropRank }?.takeIf { it > 0 } ?: break + kept.remove(kept.last { flags[it].dropRank == rank }) + } + + // Then keep the leading tokens that fit. A shrinkable token takes the + // width left and handles its own overflow; any other stops the row. + val shown = mutableListOf>() + var width = 0 + var previous: Int? = null + for (index in kept) { + val start = width + joinWidth(previous) + val left = constraints.maxWidth - start + val fits = widths[index] <= left + if (!fits && !(flags[index].shrinkable && left > 0)) break + previous?.let(::separatorAfter)?.let { shown += it to widths[it] } + shown += index to if (fits) widths[index] else left + if (!fits) break + width = start + widths[index] + previous = index + } + + val placeables = shown.map { (index, maxWidth) -> + measurables[index].measure(Constraints(maxWidth = maxWidth, maxHeight = constraints.maxHeight)) + } + val rowWidth = placeables.sumOf { it.width } + gap * (placeables.size - 1).coerceAtLeast(0) + val height = (placeables.maxOfOrNull { it.height } ?: 0) + .coerceIn(constraints.minHeight, constraints.maxHeight) + layout(rowWidth.coerceIn(constraints.minWidth, constraints.maxWidth), height) { + var x = 0 + placeables.forEach { placeable -> + placeable.placeRelative(x, (height - placeable.height) / 2) + x += placeable.width + gap + } + } + } +} + +object WholeTokenRowScope { + /** + * When the row is too wide, drop this token before any unmarked one, + * starting from the end. Tokens with a higher [rank] drop before those + * with a lower one. + */ + fun Modifier.dropFirst(rank: Int = 1): Modifier = then(TokenFlags(dropRank = rank.coerceAtLeast(1))) + + /** + * When this token does not fit whole, give it the width left instead of + * dropping it. The token must end its own overflow, usually with an + * ellipsis. + */ + fun Modifier.shrinkable(): Modifier = then(TokenFlags(shrinkable = true)) + + /** + * This child separates the tokens either side of it, and shows only when + * both of them do. + */ + fun Modifier.separator(): Modifier = then(TokenFlags(separator = true)) +} + +private data class TokenFlags( + val dropRank: Int = 0, + val shrinkable: Boolean = false, + val separator: Boolean = false, +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + val current = parentData as? TokenFlags ?: TokenFlags() + return TokenFlags( + dropRank = maxOf(current.dropRank, dropRank), + shrinkable = current.shrinkable || shrinkable, + separator = current.separator || separator, + ) + } +} diff --git a/android-shared/src/androidMain/res/drawable/tmdb_logo.xml b/android-shared/src/androidMain/res/drawable/tmdb_logo.xml new file mode 100644 index 0000000000..0d5f523bb4 --- /dev/null +++ b/android-shared/src/androidMain/res/drawable/tmdb_logo.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt index 1192477d8f..8da1755177 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Surface import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text @@ -51,6 +52,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.graphics.Brush import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.CompositingStrategy @@ -60,6 +62,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -76,8 +79,13 @@ import org.siloserver.silo.android.ui.theme.SiloOpaqueControlBorder import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated import org.siloserver.silo.android.ui.theme.PillShape +import org.siloserver.silo.common.ui.RatingEntry +import org.siloserver.silo.common.ui.WholeTokenRow import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.catalog.DisplayRating +import org.siloserver.silo.model.catalog.ExternalRatings import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.catalog.titleRatings import org.siloserver.silo.model.catalog.Season import org.siloserver.silo.model.catalog.isSpecialsForDisplay @@ -259,15 +267,27 @@ private fun ExpandedDetailHero( .then(if (hasPortrait) Modifier.height(posterHeight) else Modifier), horizontalAlignment = Alignment.CenterHorizontally, ) { + // Beside a portrait, the text block takes only the height + // the actions leave, so Play stays level with the poster. + // Only the title gives up height: it is weighted, so the + // facts and ratings rows are measured first and never clip, + // and a logo scales down to what is left. Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .then(if (hasPortrait) Modifier.weight(1f).clipToBounds() else Modifier), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (!eyebrow.isNullOrBlank()) { EyebrowChip(text = eyebrow) } - ExpandedHeroTitle(detail = detail) + Box( + modifier = if (hasPortrait) Modifier.weight(1f, fill = false).clipToBounds() else Modifier, + contentAlignment = Alignment.Center, + ) { + ExpandedHeroTitle(detail = detail) + } val metadataTokens = (factsLine + sourceTokens).distinct() if (metadataTokens.isNotEmpty() || detail.contentRating != null) { SourceRow( @@ -276,12 +296,9 @@ private fun ExpandedDetailHero( horizontalAlignment = Alignment.CenterHorizontally, ) } + DetailRatingsRow(ratings = detail.titleRatings()) } - if (hasPortrait) { - Spacer(modifier = Modifier.weight(1f)) - } else { - Spacer(modifier = Modifier.height(20.dp)) - } + Spacer(modifier = Modifier.height(if (hasPortrait) 12.dp else 20.dp)) // Expanded/tablet only: Play and its bottom action row end // no lower than the portrait. The compact phone branch is // intentionally unchanged. @@ -514,8 +531,17 @@ fun DetailHero( verticalArrangement = Arrangement.spacedBy(16.dp), ) { val metadataTokens = (factsLine + sourceTokens).distinct() - if (metadataTokens.isNotEmpty() || detail.contentRating != null) { - SourceRow(tokens = metadataTokens, ratingChip = detail.contentRating) + val ratings = detail.titleRatings() + if (metadataTokens.isNotEmpty() || detail.contentRating != null || ratings.isNotEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (metadataTokens.isNotEmpty() || detail.contentRating != null) { + SourceRow(tokens = metadataTokens, ratingChip = detail.contentRating) + } + DetailRatingsRow(ratings = ExternalRatings.forPhone(ratings)) + } } actions() if (reserveOverviewSpace || !overviewText.isNullOrBlank()) { @@ -917,6 +943,28 @@ private fun SourceRow( } } +/** + * External ratings centered on one line, in the order given. When they do not + * all fit, whole entries drop from the end; the row never wraps. The server + * sends at most three; the phone layout also caps at + * [ExternalRatings.PHONE_LIMIT]. + */ +@Composable +private fun DetailRatingsRow(ratings: List) { + if (ratings.isEmpty()) return + val style = detailRatingStyle() + WholeTokenRow(spacing = DetailRatingSpacing) { + ratings.forEach { rating -> RatingEntry(rating = rating, style = style) } + } +} + +@Composable +private fun detailRatingStyle(): TextStyle = LocalTextStyle.current.merge( + TextStyle(color = DetailPrimaryText, fontSize = 15.sp, lineHeight = 20.sp), +) + +private val DetailRatingSpacing = 20.dp + @Composable private fun ContentRatingChip(text: String) { Box( @@ -1447,7 +1495,6 @@ object HeroMetadata { ): List = buildList { if (detail.year > 0) add(detail.year.toString()) if (runtimeMinutes > 0) add(formatRuntime(runtimeMinutes)) - detail.ratingImdb?.let { add("IMDb %.1f".format(it)) } } fun seriesFactsLine(detail: ItemDetail): List = buildList { @@ -1455,7 +1502,6 @@ object HeroMetadata { detail.seasonCount?.takeIf { it > 0 }?.let { add("$it Season${if (it > 1) "s" else ""}") } - detail.ratingImdb?.let { add("IMDb %.1f".format(it)) } } private fun formatRuntime(minutes: Int): String { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt index 495d2e61ee..fc2833c6e2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt @@ -38,8 +38,10 @@ import androidx.compose.ui.unit.dp import org.siloserver.silo.android.ui.components.SiloTopBar import org.siloserver.silo.android.ui.components.EmptyStateView import org.siloserver.silo.android.ui.components.LoadingIndicator +import org.siloserver.silo.common.ui.RatingEntry import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.catalog.ExternalRatings import org.siloserver.silo.model.request.RequestMediaDetail import org.siloserver.silo.model.request.RequestMediaResult import org.siloserver.silo.model.request.reasonMessage @@ -175,11 +177,22 @@ private fun RequestDetailContent( fontWeight = FontWeight.Bold, ) RequestMetaLine(mediaType = detail.mediaType, year = detail.year) - Text( - text = detail.basicFacts(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val tmdbRating = ExternalRatings.tmdb(detail.voteAverage) + detail.basicFacts(hasRating = tmdbRating != null)?.let { facts -> + Text( + text = facts, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + tmdbRating?.let { rating -> + RatingEntry( + rating = rating, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + ) + } RequestActions( detail = detail, isSubmitting = isSubmitting, @@ -336,12 +349,15 @@ private fun RequestDetailStatus(detail: RequestMediaDetail) { ) } -private fun RequestMediaDetail.basicFacts(): String { +/** + * Runtime, genres and seasons. With none of those, the availability label + * fills the line, unless a rating already shows below it. + */ +private fun RequestMediaDetail.basicFacts(hasRating: Boolean): String? { val facts = buildList { runtime?.let { add("${it}m") } if (genres.isNotEmpty()) add(genres.take(2).joinToString(", ")) - voteAverage?.let { add("TMDB ${"%.1f".format(it)}") } if (numberOfSeasons != null) add("$numberOfSeasons seasons") } - return facts.joinToString(" • ").ifBlank { availability.requestDisplayLabel() } + return facts.joinToString(" • ").ifBlank { if (hasRating) null else availability.requestDisplayLabel() } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFactsRow.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFactsRow.kt new file mode 100644 index 0000000000..608b32da39 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFactsRow.kt @@ -0,0 +1,145 @@ +package org.siloserver.silo.tv.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +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 androidx.tv.material3.Icon +import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.RatingEntry +import org.siloserver.silo.common.ui.WholeTokenRow +import org.siloserver.silo.model.catalog.DisplayRating +import org.siloserver.silo.tv.ui.theme.SuccessGreen + +/** + * Tokens for a hero facts row, mirroring tvOS `TVHeroFactToken`. + * + * - [TextToken] plain text (year / runtime / genre). [TextToken.truncates] + * lets a long value (an episode title) end in "…" instead of dropping. + * [TextToken.givesWayFirst] marks a source token (type, genre, episode + * number) that drops before any rating or format. + * - [ExternalRating] one external rating as its source's mark and score. + * - [Rating] a maturity/check token: green check icon + label. + * - [Chip] a playback-format value (4K / HDR / DOLBY VISION / ATMOS / CC). + * Detail renders these with the same quiet monospaced treatment as Home's + * format line rather than promoting every value to an outlined badge. + */ +sealed class TvHeroFactToken { + data class TextToken( + val value: String, + val truncates: Boolean = false, + val givesWayFirst: Boolean = false, + ) : TvHeroFactToken() + data class ExternalRating(val rating: DisplayRating) : TvHeroFactToken() + data class Rating(val value: String) : TvHeroFactToken() + data class Chip(val value: String) : TvHeroFactToken() +} + +/** + * One line of [tokens] with a "·" between them, used by the detail hero, the + * Home marquee and request detail. Past the line's width, whole tokens drop + * from the end. Text tokens marked [TvHeroFactToken.TextToken.givesWayFirst] + * go first; then, with [ratingsDropFirst], external ratings go, from the end of + * the list, before the other facts and formats. [leading] (a content-rating + * chip) sits before the first token with no divider. + * + * [style] should carry the text's color and font size. Ratings keep their mark + * at that size too: the ten-foot text floor rules out the smaller mark + * [RatingEntry] draws by default. + */ +@Composable +internal fun TvFactsRow( + tokens: List, + style: TextStyle, + modifier: Modifier = Modifier, + spacing: Dp = 7.dp, + dividerColor: Color = style.color, + ratingsDropFirst: Boolean = false, + leading: (@Composable () -> Unit)? = null, +) { + WholeTokenRow(spacing = spacing, modifier = modifier) { + leading?.invoke() + tokens.forEachIndexed { index, token -> + // The row shows a "·" only between two facts it keeps. + if (index > 0) { + Text( + text = "·", + style = style.merge(TextStyle(fontWeight = FontWeight.SemiBold, color = dividerColor)), + maxLines = 1, + modifier = Modifier.separator(), + ) + } + val tokenModifier = Modifier + .then( + when { + token is TvHeroFactToken.TextToken && token.givesWayFirst -> Modifier.dropFirst(rank = 2) + ratingsDropFirst && token is TvHeroFactToken.ExternalRating -> Modifier.dropFirst() + else -> Modifier + }, + ) + .then(if (token is TvHeroFactToken.TextToken && token.truncates) Modifier.shrinkable() else Modifier) + Box(modifier = tokenModifier, contentAlignment = Alignment.CenterStart) { + FactToken(token = token, style = style) + } + } + } +} + +@Composable +private fun FactToken(token: TvHeroFactToken, style: TextStyle) { + when (token) { + is TvHeroFactToken.TextToken -> Text( + text = token.value, + style = style, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + is TvHeroFactToken.ExternalRating -> RatingEntry( + rating = token.rating, + style = style, + markFontSize = style.fontSize, + ) + is TvHeroFactToken.Rating -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = SuccessGreen.copy(alpha = 0.9f), + modifier = Modifier.height(12.dp), + ) + Text(text = token.value, style = style, maxLines = 1) + } + is TvHeroFactToken.Chip -> Text( + text = formatChipLabel(token.value), + style = style.merge( + TextStyle( + fontFamily = FontFamily.Monospace, + letterSpacing = 0.52.sp, + color = Color.White.copy(alpha = 0.55f), + ), + ), + maxLines = 1, + ) + } +} + +private fun formatChipLabel(value: String): String = when (value.uppercase()) { + "DOLBY VISION" -> "Dolby Vision" + "ATMOS" -> "Atmos" + else -> value +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt index b3e6d0b1e4..3e58376999 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt @@ -29,10 +29,12 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.Text import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.tv.ui.theme.SiloOnSurface @@ -192,13 +194,16 @@ private fun TvMarqueeBlock( ) { content.badges.forEach { badge -> MarqueeBadge(badge) } if (content.metaParts.isNotEmpty()) { - Text( - text = content.metaParts.joinToString(" · "), - color = SiloSecondaryText, - fontSize = MarqueeMetaSize, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + TvFactsRow( + tokens = content.metaParts, + style = LocalTextStyle.current.merge( + TextStyle( + color = SiloSecondaryText, + fontSize = MarqueeMetaSize, + fontWeight = FontWeight.Medium, + ), + ), + spacing = MarqueeMetaGap, ) } } @@ -302,6 +307,9 @@ private val MarqueeLogoMaxHeight = 84.dp private val MarqueeDetailLineHeight = 20.dp private val MarqueeTitleSize = 44.sp private val MarqueeMetaSize = 14.sp + +/** About a space at [MarqueeMetaSize], so `·` spacing matches the joined text. */ +private val MarqueeMetaGap = 4.dp private val MarqueeDetailSize = 14.sp private val MarqueeSynopsisSize = 16.sp private val MarqueeBadgeSize = 10.5.sp diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt index 1445a8a8cb..8119ee23ee 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import org.siloserver.silo.model.catalog.ExternalRatings import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.OverlaySummary import org.siloserver.silo.model.section.SectionItem @@ -30,8 +31,11 @@ data class TvMarqueeContent( val logoUrl: String?, /** Optional uppercase content-classification badge before the editorial metadata. */ val badges: List, - /** Dot-joined editorial metadata after the badge. */ - val metaParts: List, + /** + * Dot-separated editorial metadata after the badge. A title's one external + * rating (IMDb, else TMDB) sits after its length, before the genre. + */ + val metaParts: List, val synopsis: String?, /** A quieter detail line: cast / air-date when carried by the payload. */ val detailLine: String?, @@ -88,15 +92,15 @@ data class TvMarqueeContent( ): TvMarqueeContent { val isEpisode = item.type.equals("episode", ignoreCase = true) - val meta = mutableListOf() + val meta = mutableListOf() if (isEpisode) { - episodeToken(item.seasonNumber, item.episodeNumber)?.let(meta::add) - if (item.title.isNotBlank()) meta.add(item.title) + episodeToken(item.seasonNumber, item.episodeNumber)?.let { meta += TvHeroFactToken.TextToken(it) } + if (item.title.isNotBlank()) meta += TvHeroFactToken.TextToken(item.title, truncates = true) } 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) + if (item.year > 0) meta += TvHeroFactToken.TextToken(item.year.toString()) + lengthText(item.runtime, item.durationSeconds)?.let { meta += TvHeroFactToken.TextToken(it) } + ExternalRatings.primary(item.ratingImdb, item.ratingTmdb)?.let { meta += TvHeroFactToken.ExternalRating(it) } + item.genres.firstOrNull { it.isNotBlank() }?.let { meta += TvHeroFactToken.TextToken(it) } } val badges = item.contentRating @@ -193,17 +197,6 @@ data class TvMarqueeContent( "$minutes min" } } - - 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() - } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt index 612705c545..0a361aed0f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt @@ -38,31 +38,14 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Icon +import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle import coil3.compose.AsyncImage import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.tv.R -import org.siloserver.silo.tv.ui.theme.SuccessGreen - -/** - * Tokens for the hero facts row, mirroring tvOS `TVHeroFactToken`. - * - * - [TextToken] plain text (year / runtime / ★rating); consecutive text - * tokens get a "·" divider between them. - * - [Rating] a maturity/check token: green check icon + label. - * - [Chip] a playback-format value (4K / HDR / DOLBY VISION / ATMOS / CC). - * Detail renders these with the same quiet monospaced treatment as Home's - * format line rather than promoting every value to an outlined badge. - */ -internal sealed class TvHeroFactToken { - data class TextToken(val value: String) : TvHeroFactToken() - data class Rating(val value: String) : TvHeroFactToken() - data class Chip(val value: String) : TvHeroFactToken() -} +import org.siloserver.silo.tv.ui.components.TvFactsRow +import org.siloserver.silo.tv.ui.components.TvHeroFactToken /** * Approved tvOS detail hero, mapped onto Android TV's half-scale layout @@ -467,78 +450,24 @@ private fun MetadataRow( ratingChip: String?, compactRating: Boolean = false, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), - ) { - ratingChip?.takeIf { it.isNotBlank() }?.let { rating -> - RatingChip(text = rating, compact = compactRating) - } - tokens.forEachIndexed { index, token -> - if (index > 0) MetadataDivider() - when (token) { - is TvHeroFactToken.TextToken -> Text( - text = token.value, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.88f), - maxLines = 1, - ) - is TvHeroFactToken.Rating -> Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp), - ) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = SuccessGreen.copy(alpha = 0.9f), - modifier = Modifier.height(12.dp), - ) - Text( - text = token.value, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.88f), - maxLines = 1, - ) - } - is TvHeroFactToken.Chip -> Text( - text = homeStyleFormatLabel(token.value), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 16.sp, - fontFamily = FontFamily.Monospace, - letterSpacing = 0.52.sp, - color = Color.White.copy(alpha = 0.55f), - maxLines = 1, - ) - } - } - sourceTokens.forEachIndexed { index, token -> - if (tokens.isNotEmpty() || index > 0) MetadataDivider() - Text( - text = token, + // Like tvOS: the source tokens (type, genres) give way first, then + // ratings from the end of the list, so the other facts and any playback + // formats stay visible. + TvFactsRow( + tokens = tokens + sourceTokens.map { TvHeroFactToken.TextToken(it, givesWayFirst = true) }, + style = LocalTextStyle.current.merge( + TextStyle( fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.90f), - maxLines = 1, - ) - } - - } -} - -@Composable -private fun MetadataDivider() { - Text( - text = "·", - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - lineHeight = 16.sp, - color = Color.White.copy(alpha = 0.45f), + color = Color.White.copy(alpha = 0.88f), + ), + ), + dividerColor = Color.White.copy(alpha = 0.45f), + ratingsDropFirst = true, + leading = ratingChip?.takeIf { it.isNotBlank() }?.let { rating -> + { RatingChip(text = rating, compact = compactRating) } + }, ) } @@ -570,12 +499,6 @@ private fun RatingChip(text: String, compact: Boolean) { } } -private fun homeStyleFormatLabel(value: String): String = when (value.uppercase()) { - "DOLBY VISION" -> "Dolby Vision" - "ATMOS" -> "Atmos" - else -> value -} - private fun splitDisplayTitle(raw: String): Pair { val separators = listOf(": ", " — ", " – ", " - ") for (sep in separators) { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadata.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadata.kt index be2cd97af1..de32a358ac 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadata.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadata.kt @@ -1,16 +1,18 @@ package org.siloserver.silo.tv.ui.screens.detail +import org.siloserver.silo.model.catalog.DisplayRating import org.siloserver.silo.model.catalog.FileVersion import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.isAudiobookItemType import org.siloserver.silo.model.catalog.selectedMediaRuntimeMinutes +import org.siloserver.silo.model.catalog.titleRatings +import org.siloserver.silo.tv.ui.components.TvHeroFactToken import java.time.Instant import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneId import java.time.ZoneOffset -import kotlin.math.round /** * Pure functions that build the hero metadata for the cinematic detail @@ -57,16 +59,20 @@ internal object TvDetailMetadata { /** Episode editorial facts used by the combined Series page. These mirror * tvOS: the episode's air date and runtime replace the Show facts, while - * version choices remain directly beneath the episode carousel. */ + * version choices remain directly beneath the episode carousel. The Show's + * [ratings] stay, like its content-rating chip, so focusing an episode + * never drops the ratings row. */ fun seriesEpisodeFactsLine( episode: EpisodeListItem, runtimeMinutes: Int = episode.runtime, + ratings: List = emptyList(), zone: ZoneId = ZoneId.systemDefault(), ): List = buildList { abbreviatedDate(episode.airDate, zone)?.let { add(TvHeroFactToken.TextToken(it)) } runtimeLabel(runtimeMinutes)?.let { add(TvHeroFactToken.TextToken(it)) } + ratings.forEach { add(TvHeroFactToken.ExternalRating(it)) } } fun factsLine( @@ -95,9 +101,7 @@ internal object TvDetailMetadata { tokens += TvHeroFactToken.TextToken(it) } } - detail.ratingImdb?.let { - tokens += TvHeroFactToken.TextToken("★ ${formatOneDecimal(it)}") - } + detail.titleRatings().forEach { tokens += TvHeroFactToken.ExternalRating(it) } if (includePlaybackFormats) { tokens += qualityTokens(selectedVersion) } @@ -159,13 +163,6 @@ internal object TvDetailMetadata { ?: runCatching { OffsetDateTime.parse(raw).toInstant() }.getOrNull() ?: runCatching { LocalDate.parse(raw).atStartOfDay(ZoneOffset.UTC).toInstant() }.getOrNull() - private fun formatOneDecimal(value: Double): String { - val rounded = round(value * 10.0) / 10.0 - val whole = rounded.toInt() - val tenths = (round((rounded - whole) * 10.0)).toInt().coerceIn(0, 9) - return "$whole.$tenths" - } - private fun qualityTokens(version: FileVersion?): List { version ?: return emptyList() val tokens = mutableListOf() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index 9cf4dfcfe8..67f78db0d1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -109,6 +109,7 @@ import org.siloserver.silo.model.catalog.VersionChapter import org.siloserver.silo.model.catalog.isAudiobookItemType import org.siloserver.silo.model.catalog.isSpecialsForDisplay import org.siloserver.silo.model.catalog.selectedMediaRuntimeMinutes +import org.siloserver.silo.model.catalog.titleRatings import org.siloserver.silo.model.catalog.trailerRailEntries import org.siloserver.silo.model.ebook.MediaRelatedItem import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED @@ -643,6 +644,7 @@ private fun TvDetailContent( runtimeMinutes = activeSeriesPlaybackDetail?.let { playbackDetail -> selectedMediaRuntimeMinutes(playbackDetail, activeSeriesSelectedVersion) } ?: episode.runtime, + ratings = detail.titleRatings(), ) } ?: TvDetailMetadata.factsLine( detail = detail, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt index dc61266260..1910d241d4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -2100,6 +2100,7 @@ private fun ItemDetail.toSectionItem(): SectionItem = SectionItem( genres = genres, status = status, ratingImdb = ratingImdb, + ratingTmdb = ratingTmdb, contentRating = contentRating, overview = overview, posterUrl = posterUrl, @@ -2116,6 +2117,7 @@ private fun BrowseItem.toSectionItem(): SectionItem = SectionItem( genres = genres, status = status, ratingImdb = ratingImdb, + ratingTmdb = ratingTmdb, contentRating = contentRating, overlaySummary = overlaySummary, overview = overview, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt index 02de68d411..7e43666c5c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt @@ -44,6 +44,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.catalog.ExternalRatings import org.siloserver.silo.model.request.RequestMediaDetail import org.siloserver.silo.model.request.RequestState import org.siloserver.silo.model.request.reasonMessage @@ -51,6 +52,8 @@ import org.siloserver.silo.model.request.requestBackdropUrl import org.siloserver.silo.model.request.requestDisplayLabel import org.siloserver.silo.model.request.requestPosterUrl import org.siloserver.silo.tv.ui.components.TvErrorScreen +import org.siloserver.silo.tv.ui.components.TvFactsRow +import org.siloserver.silo.tv.ui.components.TvHeroFactToken import org.siloserver.silo.tv.ui.components.TvLoadingScreen import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult @@ -283,22 +286,7 @@ private fun RequestDetailContent( overflow = TextOverflow.Ellipsis, ) - val meta = buildList { - detail.year?.takeIf { it > 0 }?.let { add(it.toString()) } - detail.runtime?.takeIf { it > 0 }?.let { add("${it} min") } - detail.contentRating.takeIf { it.isNotBlank() }?.let { add(it) } - detail.voteAverage?.takeIf { it > 0 }?.let { add("★ ${"%.1f".format(it)}") } - detail.genres.take(3).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(" · ")) } - }.joinToString(" · ") - if (meta.isNotBlank()) { - Text( - text = meta, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + RequestMetaLine(detail) if (detail.tagline.isNotBlank()) { Text( @@ -351,6 +339,26 @@ private fun RequestDetailContent( } } +/** `2024 · 128 min · PG-13 · TMDB 7.8 · Drama · Crime`; past the width, whole facts drop from the end. */ +@Composable +private fun RequestMetaLine(detail: RequestMediaDetail) { + val tokens = buildList { + detail.year?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(it.toString())) } + detail.runtime?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken("$it min")) } + detail.contentRating.takeIf { it.isNotBlank() }?.let { add(TvHeroFactToken.TextToken(it)) } + ExternalRatings.tmdb(detail.voteAverage)?.let { add(TvHeroFactToken.ExternalRating(it)) } + detail.genres.take(3).forEach { add(TvHeroFactToken.TextToken(it)) } + } + if (tokens.isEmpty()) return + TvFactsRow( + tokens = tokens, + style = MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + spacing = 10.dp, + ) +} + /** * Label for the detail's primary action: Request while the title is * requestable, otherwise the existing request's status, otherwise the reason diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt index 5693e4c969..68be999c00 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.tv.ui.components import java.io.File +import org.siloserver.silo.model.catalog.DisplayRating import org.siloserver.silo.model.catalog.OverlaySummary import org.siloserver.silo.model.section.SectionItem import kotlin.test.Test @@ -32,8 +33,12 @@ class TvFocusMarqueeModelTest { assertEquals(listOf("PG-13"), content.badges) assertEquals( - listOf("2016", "1h 56m", "7.9", "Science Fiction"), - content.metaParts, + listOf("2016", "1h 56m", "IMDb 7.9", "Science Fiction"), + content.metaText(), + ) + assertEquals( + TvHeroFactToken.ExternalRating(DisplayRating("imdb", "IMDb", 79.0, "7.9")), + content.metaParts[2], ) assertEquals("4K · Dolby Vision · Atmos", content.specLine) } @@ -61,7 +66,10 @@ class TvFocusMarqueeModelTest { assertEquals("The Last of Us", content.title) assertEquals(listOf("TV-MA"), content.badges) - assertEquals(listOf("S01E03", "Long, Long Time"), content.metaParts) + assertEquals( + listOf(TvHeroFactToken.TextToken("S01E03"), TvHeroFactToken.TextToken("Long, Long Time", truncates = true)), + content.metaParts, + ) assertEquals("1080P · EAC3", content.specLine) } @@ -82,9 +90,9 @@ class TvFocusMarqueeModelTest { rowTitle = "Continue Watching", ) - assertEquals(listOf("S03E01", "Persuader"), content.metaParts) - assertFalse(content.metaParts.any { it.contains("left", ignoreCase = true) }) - assertFalse(content.metaParts.any { it.contains("min", ignoreCase = true) }) + assertEquals(listOf("S03E01", "Persuader"), content.metaText()) + assertFalse(content.metaText().any { it.contains("left", ignoreCase = true) }) + assertFalse(content.metaText().any { it.contains("min", ignoreCase = true) }) } @Test @@ -144,7 +152,7 @@ class TvFocusMarqueeModelTest { ) assertEquals(emptyList(), content.metaParts) - } + } } @Test @@ -160,7 +168,7 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("2h"), content.metaParts) + assertEquals(listOf("2h"), content.metaText()) } @Test @@ -176,7 +184,7 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("8.4"), content.metaParts) + assertEquals(listOf("IMDb 8.4"), content.metaText()) } @Test @@ -192,7 +200,7 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("2h 5m"), content.metaParts) + assertEquals(listOf("2h 5m"), content.metaText()) } @Test @@ -208,6 +216,15 @@ class TvFocusMarqueeModelTest { rowTitle = "Row", ) - assertEquals(listOf("1h 56m"), content.metaParts) + assertEquals(listOf("1h 56m"), content.metaText()) + } + + private fun TvMarqueeContent.metaText(): List = metaParts.map { token -> + when (token) { + is TvHeroFactToken.TextToken -> token.value + is TvHeroFactToken.ExternalRating -> "${token.rating.name} ${token.rating.display}" + is TvHeroFactToken.Rating -> token.value + is TvHeroFactToken.Chip -> token.value + } } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadataTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadataTest.kt index 44c9667af2..9d93c90af7 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadataTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadataTest.kt @@ -12,6 +12,7 @@ import org.siloserver.silo.model.catalog.VideoTrack import org.siloserver.silo.model.catalog.selectedMediaRuntimeMinutes import org.siloserver.silo.model.catalog.trailerRailEntries import org.siloserver.silo.model.ebook.MediaPerson +import org.siloserver.silo.tv.ui.components.TvHeroFactToken import java.time.ZoneId import kotlin.test.Test import kotlin.test.assertEquals diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt index 4c42db9ead..0e0f6c5957 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt @@ -170,6 +170,12 @@ data class ItemDetail( @SerialName("rating_tmdb") val ratingTmdb: Double? = null, @SerialName("rating_rt_critic") val ratingRtCritic: Int? = null, @SerialName("rating_rt_audience") val ratingRtAudience: Int? = null, + /** + * The server's title-page ratings row, in display order. `null` when an + * older server omits it; read it through [titleRatings]. + */ + @Serializable(with = DisplayRatingListSerializer::class) + val ratings: List? = null, @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ExternalRatings.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ExternalRatings.kt new file mode 100644 index 0000000000..d43878fa77 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ExternalRatings.kt @@ -0,0 +1,128 @@ +package org.siloserver.silo.model.catalog + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * One external rating as a title page shows it: the source key (`imdb`, + * `tmdb`, `rt_critic`, or one a plugin adds later), the plain-text mark + * (`IMDb`, `RT Audience`), a 0-100 score, and the value already formatted on + * the source's own scale (`8.5`, `93%`). Show [display] as it comes. The + * client never reads [score], so an entry without one still shows. + */ +@Serializable +data class DisplayRating( + val source: String, + val name: String, + val score: Double? = null, + val display: String, +) + +/** + * Decodes a `ratings` list entry by entry, so one malformed entry (a plugin + * source without `display`, a blank name) is dropped instead of failing the + * whole item detail. + */ +internal object DisplayRatingListSerializer : KSerializer> { + private val strict = ListSerializer(DisplayRating.serializer()) + + override val descriptor = strict.descriptor + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder ?: return strict.deserialize(decoder) + val entries = jsonDecoder.decodeJsonElement() as? JsonArray ?: return emptyList() + return entries.mapNotNull { entry -> + try { + jsonDecoder.json.decodeFromJsonElement(DisplayRating.serializer(), entry) + .takeIf { it.name.isNotBlank() && it.display.isNotBlank() } + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } + } + + override fun serialize(encoder: Encoder, value: List) = + strict.serialize(encoder, value) +} + +/** + * The external ratings a title page or hero shows, shared by the phone and TV + * apps so both read the same list. + */ +object ExternalRatings { + const val SOURCE_IMDB = "imdb" + const val SOURCE_TMDB = "tmdb" + + /** + * The most ratings a phone-width title page shows. Web and iOS use the + * same limit. The server already sends at most three, so this only guards + * against an older server that sends more. Every layout (phone, tablet, + * fold, TV) shows ratings on one line and drops whole entries from the + * end when they do not fit. + */ + const val PHONE_LIMIT = 3 + + /** + * The ratings row for a title page. A current server sends [ratings] + * (empty when there is nothing to show), already chosen and formatted: + * it is returned untouched. An older server omits the member (`null`), so + * the row falls back to IMDb then TMDB from the raw out-of-ten scores. + * Never Rotten Tomatoes: item detail carries raw RT values for metadata + * editors even when an administrator has not chosen to show them. + */ + fun forTitle( + ratings: List?, + ratingImdb: Double?, + ratingTmdb: Double?, + ): List = ratings ?: listOfNotNull(imdb(ratingImdb), tmdb(ratingTmdb)) + + /** + * The ratings a phone-width title page shows: the first [PHONE_LIMIT] + * of [ratings], in the server's order. Wider layouts pass the server's + * list as is. + */ + fun forPhone(ratings: List): List = ratings.take(PHONE_LIMIT) + + /** The one rating a hero shows from card fields: IMDb, else TMDB. */ + fun primary(ratingImdb: Double?, ratingTmdb: Double?): DisplayRating? = + imdb(ratingImdb) ?: tmdb(ratingTmdb) + + fun imdb(value: Double?): DisplayRating? = outOfTen(SOURCE_IMDB, "IMDb", value) + + fun tmdb(value: Double?): DisplayRating? = outOfTen(SOURCE_TMDB, "TMDB", value) + + /** + * One decimal with a `.` separator whatever the device locale (`8.5`, + * never `8,5`), rounding half up like the web client. + */ + fun formatOneDecimal(value: Double): String { + val tenths = (value * 10.0).roundToLong() + val sign = if (tenths < 0) "-" else "" + val magnitude = abs(tenths) + return "$sign${magnitude / 10}.${magnitude % 10}" + } + + private fun outOfTen(source: String, name: String, value: Double?): DisplayRating? { + if (value == null || !value.isFinite() || value <= 0.0 || value > 10.0) return null + return DisplayRating( + source = source, + name = name, + score = value * 10.0, + display = formatOneDecimal(value), + ) + } +} + +/** This title's ratings row; see [ExternalRatings.forTitle]. */ +fun ItemDetail.titleRatings(): List = + ExternalRatings.forTitle(ratings, ratingImdb, ratingTmdb) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt index d33119f0eb..d87cf2d56e 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt @@ -35,6 +35,9 @@ internal data class ItemDetailReadV2( @SerialName("rating_tmdb") val ratingTmdb: Double? = null, @SerialName("rating_rt_critic") val ratingRtCritic: Int? = null, @SerialName("rating_rt_audience") val ratingRtAudience: Int? = null, + // Absent on servers that predate the title-page ratings row. + @Serializable(with = DisplayRatingListSerializer::class) + val ratings: List? = null, @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, @@ -96,6 +99,7 @@ internal data class ItemDetailReadV2( ratingTmdb = ratingTmdb, ratingRtCritic = ratingRtCritic, ratingRtAudience = ratingRtAudience, + ratings = ratings, imdbId = imdbId, tmdbId = tmdbId, tvdbId = tvdbId, diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/ExternalRatingsTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/ExternalRatingsTest.kt new file mode 100644 index 0000000000..7a04ddb163 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/ExternalRatingsTest.kt @@ -0,0 +1,129 @@ +package org.siloserver.silo.model.catalog + +import kotlinx.serialization.json.Json +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.apiv2.ItemDetailReadV2 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ExternalRatingsTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun serverListIsShownVerbatimAndInOrder() { + val server = listOf( + DisplayRating("rt_critic", "RT", 93.0, "93%"), + DisplayRating("imdb", "IMDb", 85.0, "8.5"), + DisplayRating("letterboxd", "Letterboxd", 84.0, "4.2"), + ) + + assertEquals(server, ExternalRatings.forTitle(server, ratingImdb = 1.0, ratingTmdb = 2.0)) + } + + @Test + fun emptyServerListMeansNothingToShow() { + assertEquals(emptyList(), ExternalRatings.forTitle(emptyList(), ratingImdb = 8.5, ratingTmdb = 8.3)) + } + + @Test + fun olderServerFallsBackToImdbThenTmdbOnly() { + val detail = json.decodeFromString( + """ + {"content_id":"movie-1","type":"movie","title":"Movie", + "rating_imdb":8.45,"rating_tmdb":8.25, + "rating_rt_critic":93,"rating_rt_audience":95} + """.trimIndent(), + ) + + assertNull(detail.ratings) + assertEquals( + listOf( + DisplayRating("imdb", "IMDb", 84.5, "8.5"), + DisplayRating("tmdb", "TMDB", 82.5, "8.3"), + ), + detail.titleRatings(), + ) + } + + @Test + fun fallbackSkipsMissingAndOutOfRangeScores() { + listOf(null, Double.NaN, Double.POSITIVE_INFINITY, 0.0, -1.0, 10.5).forEach { invalid -> + assertEquals( + listOf("TMDB"), + ExternalRatings.forTitle(null, ratingImdb = invalid, ratingTmdb = 7.0).map { it.name }, + ) + assertNull(ExternalRatings.primary(invalid, invalid)) + } + assertEquals("10.0", ExternalRatings.imdb(10.0)?.display) + } + + @Test + fun phoneShowsTheFirstThreeInServerOrder() { + val server = listOf( + DisplayRating("imdb", "IMDb", 85.0, "8.5"), + DisplayRating("tmdb", "TMDB", 82.5, "8.3"), + DisplayRating("rt_critic", "RT", 93.0, "93%"), + DisplayRating("rt_audience", "RT Audience", 95.0, "95%"), + DisplayRating("metacritic", "Metacritic", 87.0, "87"), + ) + + assertEquals(3, ExternalRatings.PHONE_LIMIT) + assertEquals(server.take(3), ExternalRatings.forPhone(server)) + assertEquals(server.take(2), ExternalRatings.forPhone(server.take(2))) + assertEquals(emptyList(), ExternalRatings.forPhone(emptyList())) + } + + @Test + fun primaryPrefersImdbAndFallsBackToTmdb() { + assertEquals(DisplayRating("imdb", "IMDb", 79.0, "7.9"), ExternalRatings.primary(7.9, 8.2)) + assertEquals(DisplayRating("tmdb", "TMDB", 82.0, "8.2"), ExternalRatings.primary(null, 8.2)) + assertEquals("TMDB", ExternalRatings.primary(0.0, 8.2)?.name) + } + + @Test + fun oneDecimalIsLocaleIndependent() { + assertEquals("8.5", ExternalRatings.formatOneDecimal(8.45)) + assertEquals("7.0", ExternalRatings.formatOneDecimal(7.0)) + assertEquals("7.0", ExternalRatings.formatOneDecimal(6.96)) + assertEquals("0.1", ExternalRatings.formatOneDecimal(0.05)) + } + + @Test + fun v2DetailCarriesTheServerRatingsList() { + val detail = json.decodeFromString( + """ + {"content_id":"movie-1","type":"movie","title":"Movie","rating_imdb":1.0, + "cast":[],"crew":[],"versions":[],"subtitles":[], + "ratings":[{"source":"tmdb","name":"TMDB","score":82.5,"display":"8.3"}]} + """.trimIndent(), + ).toDomain() + + assertEquals(listOf(DisplayRating("tmdb", "TMDB", 82.5, "8.3")), detail.titleRatings()) + } + + @Test + fun malformedRatingEntriesAreDroppedOneByOne() { + val detail = SiloJson.decodeFromString( + """ + {"content_id":"movie-1","type":"movie","title":"Movie", + "cast":[],"crew":[],"versions":[],"subtitles":[], + "ratings":[ + {"source":"imdb","name":"IMDb","score":85.0,"display":"8.5"}, + {"source":"plugin","name":"Plugin","score":null}, + {"source":"blank","name":"","score":50,"display":"5"}, + "not an object", + {"source":"letterboxd","name":"Letterboxd","score":null,"display":"4.2"} + ]} + """.trimIndent(), + ).toDomain() + + assertEquals( + listOf( + DisplayRating("imdb", "IMDb", 85.0, "8.5"), + DisplayRating("letterboxd", "Letterboxd", null, "4.2"), + ), + detail.titleRatings(), + ) + } +} From 354058b8164029cf519387793bd2b233edd3eb63 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:49:19 -0400 Subject: [PATCH 05/87] feat(requests): make requests look and behave like the rest of the app (#439) * feat(requests): make requests look and behave like the rest of the app Port the iOS and tvOS Requests redesign (silo-apple #558, #561, #540) to Android phone and Android TV. - Shared: the server's `state` decides display status; a four-step progress track; My Requests buckets; a session detail cache with prefetch; admin approve/decline/retry with a 60-second hold after a lost response; create, cancel and moderation are never resent. - Phone: large-title hub with a status summary, My Requests in grouped rows with a filter menu and swipe to cancel, a new Approvals page, and request detail on the library title hero. - TV: Requests becomes a top-bar tab in the Skyline layout, with a preview panel that shows status and progress, admin rows, and request detail on the library detail hero. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): address review findings on request state and routing - Clear request session state on every feature reset (sign-in, sign-out, server and profile switches) and drop reads or mutation broadcasts that answer after a reset. - Approval reads: a newer read wins, and a read sent before a decision can't bring the decided row back. - Unconfirmed create and cancel holds now end only when a read shows the result, or after 60 seconds, like admin decisions. - A proxy's 502/503/504 without the server's problem body counts as an uncertain outcome. - Request detail reuses a /requests/mine read from the last 30 seconds, re-reading only to settle an uncertain action; the hub counts pending approvals from one page; a page's own decision no longer triggers a second read. - TV long-press actions are computed by the caller, so the dialog no longer writes parent state during composition. - RequestRouter moves to android-shared and handles every request tap on phone and TV, including both search screens. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): address PR review findings - TV: each request detail opened from another one gets its own back-stack entry and view model, instead of reusing the current one. - TV: a failed action's message no longer replays when the page returns, and an approval queue that fails to load says so. - Request detail applies a create, cancel or decision result as soon as the server returns it, so a failed re-read can't bring back Request, Cancel or the decision buttons. - A failed or declined title the server calls requestable offers Request again instead of a blocking status. - A /requests/mine read that a create or cancel overtook keeps the patched list and doesn't count as fresh. - Phone: My Requests pull-to-refresh also refreshes the approvals card; grouped rows are separate lazy items; the row error text comes from the caller. - FEATURES.md no longer says the admin stats dashboard is exposed. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 +- FEATURES.md | 8 +- README.md | 6 +- .../silo/common/requests/RequestRouter.kt | 52 ++ .../silo/common/requests/RequestUi.kt | 133 +++ .../silo/android/di/AndroidModule.kt | 6 +- .../android/ui/navigation/AppNavigation.kt | 43 +- .../silo/android/ui/navigation/Routes.kt | 1 + .../ui/screens/detail/DetailTopChrome.kt | 138 +++ .../ui/screens/detail/ItemDetailScreen.kt | 203 ++--- .../ui/screens/requests/MyRequestsScreen.kt | 279 ++++-- .../requests/RequestApprovalsScreen.kt | 153 ++++ .../ui/screens/requests/RequestComponents.kt | 666 ++++++++++---- .../screens/requests/RequestDetailScreen.kt | 693 +++++++++------ .../ui/screens/requests/RequestRows.kt | 407 +++++++++ .../requests/RequestsLargeTitlePage.kt | 108 +++ .../ui/screens/requests/RequestsScreen.kt | 471 ++++------ .../ui/screens/search/RequestSearchSection.kt | 14 +- .../android/ui/screens/search/SearchScreen.kt | 3 +- .../siloserver/silo/tv/di/AndroidTvModule.kt | 14 +- .../silo/tv/ui/components/TvFocusMarquee.kt | 9 +- .../tv/ui/components/TvSkylineSectionFeed.kt | 16 +- .../silo/tv/ui/navigation/TvRoute.kt | 1 - .../silo/tv/ui/screens/detail/TvDetailHero.kt | 6 +- .../ui/screens/requests/TvMyRequestsScreen.kt | 261 ------ .../screens/requests/TvRequestComponents.kt | 372 ++++---- .../screens/requests/TvRequestDetailScreen.kt | 549 +++++++----- .../tv/ui/screens/requests/TvRequestsPage.kt | 832 ++++++++++++++++++ .../ui/screens/requests/TvRequestsScreen.kt | 653 -------------- .../screens/requests/TvRequestsViewModel.kt | 99 --- .../tv/ui/screens/search/TvSearchScreen.kt | 9 +- .../silo/tv/ui/shell/TvMainShell.kt | 117 ++- .../silo/tv/ui/shell/TvMediaDestinations.kt | 8 +- .../silo/tv/ui/shell/TvRootDestination.kt | 8 +- .../silo/tv/ui/shell/TvTopMenuBar.kt | 21 + .../silo/tv/ui/TvUsabilityGuardTest.kt | 5 +- .../tv/ui/shell/TvMediaDestinationsTest.kt | 16 + .../siloserver/silo/di/RepositoryModule.kt | 12 +- .../model/feature/RequestsFeatureStore.kt | 50 +- .../silo/model/request/RequestDisplayState.kt | 328 +++++++ .../silo/model/request/RequestModels.kt | 56 +- .../silo/model/request/RequestMutations.kt | 131 +++ .../silo/model/request/RequestPresentation.kt | 71 +- .../silo/network/api/RequestsApi.kt | 100 ++- .../silo/repository/RequestDetailCache.kt | 208 +++++ .../silo/repository/RequestsRepository.kt | 190 +++- .../viewmodel/RequestApprovalsViewModel.kt | 246 ++++++ .../silo/viewmodel/RequestDetailViewModel.kt | 550 ++++++++++++ .../silo/viewmodel/RequestsViewModels.kt | 511 ++++++----- .../model/feature/RequestsFeatureStoreTest.kt | 70 +- .../model/request/RequestDisplayStateTest.kt | 168 ++++ .../model/request/RequestPresentationTest.kt | 98 +-- .../silo/network/apiv2/RequestsV2Test.kt | 40 + .../silo/repository/RequestsRepositoryTest.kt | 76 ++ .../silo/viewmodel/RequestDetailStatusTest.kt | 85 ++ .../silo/viewmodel/RequestsViewModelTest.kt | 125 ++- 56 files changed, 6550 insertions(+), 2949 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestRouter.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestUi.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailTopChrome.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsLargeTitlePage.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvMyRequestsScreen.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsViewModel.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestDisplayState.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestMutations.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestDetailCache.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestApprovalsViewModel.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestDetailViewModel.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestDisplayStateTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestDetailStatusTest.kt diff --git a/AGENTS.md b/AGENTS.md index 6326a88bce..db783e1eb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ This repository contains only the Silo Android clients. Shared Kotlin logic live - Ebooks/Reading are phone-only. Do not expose ebooks or Reading on Android TV. - Android mobile navigation is Home, Libraries, For You, Calendar, and Downloads only when the active profile has downloads. Video, Audio, and Reading are library modes reached through Libraries, not bottom-nav tabs. -- Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), and Calendar, plus search and profile actions. Reading/ebooks are excluded. -- Requests is live on phone and TV, server-gated by `requests_enabled` (profile menu + search entry points, matching Apple). Admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans) — and neither is Watch Together. Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. +- Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), Calendar, and Requests while the server enables it (mirroring tvOS `.requests`), plus search and profile actions. Reading/ebooks are excluded. +- Requests is live on phone and TV, server-gated by `requests_enabled` (phone: profile menu + search; TV: a top-bar tab + search, matching Apple). Admins who can moderate requests approve, decline, and retry them in the Requests screens, as on Apple; limits and auto-approval stay in web admin. Other admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans) — and neither is Watch Together. Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. ## Build, Test, and Development Commands diff --git a/FEATURES.md b/FEATURES.md index 05a3f3cba9..fc6270559d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -9,7 +9,7 @@ A detailed inventory of what the Android **phone** and **TV** clients do today. File pointers are repository-relative. -> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v1/requests/status`), and reached from the profile menu and search — matching the Apple clients. The admin stats dashboard is live for acting admins via Settings. The richer admin screens (users/sessions/logs/scans) and Watch Together remain inaccessible. +> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV — matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. The richer admin screens (users/sessions/logs/scans) and Watch Together remain inaccessible. --- @@ -82,7 +82,7 @@ File pointers are repository-relative. | Release calendar | ✅ | ✅ | Top-level mobile tab and TV top-menu tab | | Live home refresh (events websocket) | ✅ | ✅ | `HomeRealtimeCoordinator`: user_state/catalog channels, 2s debounce; TV also refreshes on resume | | System "Watch Next" row integration | ➖ | ✅ | `WatchNextRepository` (tvprovider) | -| Requests | ✅ | ✅ | Server-gated by `requests_enabled`; profile menu + search entry points | +| Requests | ✅ | ✅ | Server-gated by `requests_enabled`; phone: profile menu + search; TV: top-bar tab + search; admin approve/decline/retry | ## Reading (ebooks) @@ -128,7 +128,7 @@ File pointers are repository-relative. | Favorites & watchlist | ✅ | ✅ | TV: from Settings | | Ratings | ✅ | ✅ | | | Watch history | ✅ | ✅ | | -| Content requests (browse/search TMDB, status tracking) | 🚧 | 🚧 | Not currently accessible in either Android app | +| Content requests (browse/search TMDB, status tracking) | ✅ | ✅ | Four-step status track, My Requests, admin approvals; see Requests above | | Release calendar | ✅ | ✅ | | | Notifications inbox (paginated, realtime updates, mark-read) | ✅ | ✅ | REST + WebSocket | @@ -153,6 +153,6 @@ File pointers are repository-relative. **TV** is a 10-foot, D-pad client focused on browsing and playback, including audiobooks, calendar, the subtitle suite, person detail, and system Watch Next integration. It intentionally omits ebooks/reading and downloads management. -**Not currently exposed on either Android surface:** full admin management (users/sessions/logs/scans) and Watch Together. The admin **stats dashboard** is exposed (Settings → Admin, acting admins only). +**Not currently exposed on either Android surface:** the admin stats dashboard, full admin management (users/sessions/logs/scans), and Watch Together. Request moderation (approve, decline, retry) is the one admin action the Requests screens offer. Both apps share the same networking, auth, repositories, most ViewModels, and the entire Media3 playback/capability stack. diff --git a/README.md b/README.md index fd8dac03c7..b918c5b1d9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Built as a Kotlin Multiplatform project: one shared business-logic core, two Jet > **Status:** WIP (`v0.3.x`). Silo Android is pre-1.0 and some areas remain under active development (see [Roadmap](#roadmap)). > -> **Current exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v1/requests/status`), and reached from the profile menu and search — matching the Apple clients. Admin surfaces, session management, and Watch Together are not exposed in the Android clients. +> **Current exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV — matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. Other admin surfaces, session management, and Watch Together are not exposed in the Android clients. --- @@ -84,7 +84,7 @@ WorkManager-backed downloads of video, audiobooks, and books to public device st - **Browse** with genre/rating filters, sorting, and infinite-scroll grids; **collections** are browse-only in the Android clients, while collection authoring/management remains web-only. - **Item detail** for movies and series includes seasons → episodes, multi-version files, cast/crew, local download controls, and phone-to-TV playback handoff. - **Search** scoped by media type, debounced and paginated. -- **Requests** — live on phone and TV behind the server's `requests_enabled` flag (profile menu + search). **Not exposed** — admin surfaces, session management, and Watch Together are not reachable app surfaces today. +- **Requests** — live on phone and TV behind the server's `requests_enabled` flag (phone: profile menu + search; TV: top-bar tab + search), with request moderation for admins. **Not exposed** — other admin surfaces, session management, and Watch Together are not reachable app surfaces today. ### 📖 Reading & 🎧 Audio - **Ebook reader (phone only)** — EPUB, PDF, CBZ (comics), TXT/Markdown, FB2/FBZ, plus MOBI/AZW/AZW3 when the server can convert to EPUB; CBR and unsupported originals can be downloaded/opened externally. Themes, text size, margins, table of contents, bookmarks, and progress are supported. @@ -240,7 +240,7 @@ Active design work lives in `docs/superpowers/specs/` with phased plans in `docs - **Ebook reader enhancements** — in-text search, highlights, and notes, with coordinated server work where the shared contract changes. - **Admin management (users/sessions/logs/scans), Watch Together** — code/design work exists, but these are not currently exposed to users in the Android apps and need product/navigation decisions before being treated as live features. -Known gaps the docs track: TV has no reader/ebooks and no downloads management by design; admin surfaces, session management, and Watch Together are not accessible on either Android surface today. +Known gaps the docs track: TV has no reader/ebooks and no downloads management by design; admin surfaces other than request moderation, session management, and Watch Together are not accessible on either Android surface today. --- diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestRouter.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestRouter.kt new file mode 100644 index 0000000000..77b9314ea4 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestRouter.kt @@ -0,0 +1,52 @@ +package org.siloserver.silo.common.requests + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestMediaResult +import org.siloserver.silo.model.request.libraryItemToOpen +import org.siloserver.silo.repository.RequestDetailCache +import org.siloserver.silo.repository.RequestsRepository +import org.siloserver.silo.repository.cacheKey + +/** + * The standard taps on request cards and rows, on phone and TV: a title that + * reads "In library" opens the library item; anything else opens the request + * detail, seeded with what the tapped card already knows so the page is + * complete on its first frame. Only an approval queue opens a page pinned to + * one request; every other way in opens an ordinary page. + */ +class RequestRouter internal constructor( + private val repository: RequestsRepository, + private val openLibraryItem: () -> (String) -> Unit, + private val openRequestDetail: () -> (mediaType: String, tmdbId: Int) -> Unit, +) { + fun openResult(result: RequestMediaResult) { + repository.cache.seed(result) + repository.cache.unpinModeration(RequestDetailCache.Key(result.mediaType, result.tmdbId)) + result.libraryItemToOpen()?.let { openLibraryItem()(it) } ?: openRequestDetail()(result.mediaType, result.tmdbId) + } + + fun openRecord(record: MediaRequest) { + repository.cache.unpinModeration(record.cacheKey()) + record.libraryItemToOpen()?.let { openLibraryItem()(it) } ?: openRequestDetail()(record.mediaType, record.tmdbId) + } + + /** Opens someone else's request from an approval queue: the page decides on that exact request. */ + fun openModerationRecord(record: MediaRequest) { + repository.cache.pinModeration(record) + openRequestDetail()(record.mediaType, record.tmdbId) + } +} + +@Composable +fun rememberRequestRouter( + repository: RequestsRepository, + onLibraryItemClick: (String) -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, +): RequestRouter { + val library = rememberUpdatedState(onLibraryItemClick) + val detail = rememberUpdatedState(onRequestDetailClick) + return remember(repository) { RequestRouter(repository, { library.value }, { detail.value }) } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestUi.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestUi.kt new file mode 100644 index 0000000000..510e2e71fd --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/requests/RequestUi.kt @@ -0,0 +1,133 @@ +package org.siloserver.silo.common.requests + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestDisplayState +import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.RequestStatusTint +import org.siloserver.silo.model.request.RequestStep +import org.siloserver.silo.model.request.RequestTargetSummary +import org.siloserver.silo.model.request.requestReasonCopy + +/** + * The request status colors, shared by phone and TV and matching the Apple + * clients. Status text stays monochrome; the dot and the current stage are the + * only chromatic elements. + */ +object RequestColors { + val Amber = Color(0xFFF59E0B) + val Sky = Color(0xFF38BDF8) + val Emerald = Color(0xFF34D399) + val Rose = Color(0xFFFB7185) +} + +fun RequestStatusTint.color(neutral: Color): Color = when (this) { + RequestStatusTint.Amber -> RequestColors.Amber + RequestStatusTint.Sky -> RequestColors.Sky + RequestStatusTint.Emerald -> RequestColors.Emerald + RequestStatusTint.Rose -> RequestColors.Rose + RequestStatusTint.Neutral -> neutral +} + +/** + * Four-segment stage track: steps behind the request are solid, the step it's + * on carries the status tint, and the rest stay dim. + */ +@Composable +fun RequestStageTrack( + progress: RequestProgress, + modifier: Modifier = Modifier, + height: Dp = 4.dp, + doneColor: Color = Color(0xFFEDEDED).copy(alpha = 0.82f), + restColor: Color = Color.White.copy(alpha = 0.14f), +) { + Row( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = progress.longLabel }, + horizontalArrangement = Arrangement.spacedBy(height), + ) { + RequestStep.entries.forEach { step -> + val fill = when { + step == progress.currentStep -> progress.tint.color(restColor) + step.ordinal < progress.completedSteps -> doneColor + else -> restColor + } + Box( + modifier = Modifier + .weight(1f) + .height(height) + .background(fill, RoundedCornerShape(50)), + ) + } + } +} + +/** Row and caption copy shared by the phone list and the TV preview panel. */ +object RequestRowCopy { + private val dayFormat = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) + private val stampFormat = DateTimeFormatter.ofPattern("MMM d 'at' h:mm a", Locale.getDefault()) + + fun mediaTypeLabel(mediaType: String): String = when (mediaType) { + RequestMediaType.Movie -> "Movie" + RequestMediaType.Series -> "Series" + else -> "Title" + } + + /** "Sep 24" for a server timestamp, or null when it can't be read. */ + fun day(timestamp: String?): String? = format(timestamp, dayFormat) + + /** "Sep 10 at 8:50 AM", for the detail page's step timestamps. */ + fun stamp(timestamp: String?): String? = format(timestamp, stampFormat) + + /** "Movie · Requested Sep 24", or "Movie · Added Sep 13" once it landed. */ + fun meta(record: MediaRequest, progress: RequestProgress): String { + val kind = mediaTypeLabel(record.mediaType) + if (progress.display == RequestDisplayState.InLibrary) { + day(record.completedAt)?.let { return "$kind · Added $it" } + } + return day(record.createdAt)?.let { "$kind · Requested $it" } ?: kind + } + + /** + * The status line under the stage track: the long label, plus the + * per-quality summary while in flight or the reason when stuck. + */ + fun status(record: MediaRequest, progress: RequestProgress): String { + val display = progress.display + if (display is RequestDisplayState.NeedsAttention) { + requestReasonCopy(display.reason)?.let { return "${progress.longLabel} · $it" } + } + if (display == RequestDisplayState.OnTheWay) { + RequestTargetSummary.text(record.targets)?.let { return "${progress.longLabel} · $it" } + } + return progress.longLabel + } + + /** "Series · 1080p · 4K" for admin rows. */ + fun kindAndQuality(record: MediaRequest): String = + listOfNotNull(mediaTypeLabel(record.mediaType), RequestTargetSummary.qualities(record.targets)).joinToString(" · ") + + private fun format(timestamp: String?, formatter: DateTimeFormatter): String? { + val value = timestamp?.takeIf { it.isNotBlank() } ?: return null + return runCatching { formatter.format(Instant.parse(value).atZone(ZoneId.systemDefault())) }.getOrNull() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index ff509080c7..014172bc9e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -442,9 +442,12 @@ val androidModule = module { viewModel { HistoryViewModel(get(), get()) } viewModel { CollectionsViewModel(get()) } viewModel { params -> CollectionDetailViewModel(get(), get(), params.get()) } - viewModel { RequestsViewModel(get()) } + viewModel { RequestsViewModel(get(), get()) } viewModel { RequestSearchViewModel(get()) } viewModel { MyRequestsViewModel(get()) } + viewModel { params -> + org.siloserver.silo.viewmodel.RequestApprovalsViewModel(get(), loadOnInit = params.getOrNull() ?: true) + } // Platform supplies "today" and the IANA timezone; the shared ViewModel's // week math stays deterministic in commonTest (no Clock.System default). viewModel { @@ -461,6 +464,7 @@ val androidModule = module { repository = get(), mediaType = args.first, tmdbId = args.second, + featureStore = get(), ) } viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 60a0cd030d..4ae580dccb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -83,6 +83,7 @@ import org.siloserver.silo.android.ui.screens.profiles.CreateProfileScreen import org.siloserver.silo.android.ui.screens.profiles.EditProfileScreen import org.siloserver.silo.android.ui.screens.profiles.ProfileSelectionScreen import org.siloserver.silo.android.ui.screens.requests.MyRequestsScreen +import org.siloserver.silo.android.ui.screens.requests.RequestApprovalsScreen import org.siloserver.silo.android.ui.screens.requests.RequestDetailScreen import org.siloserver.silo.android.ui.screens.requests.RequestsScreen import org.siloserver.silo.android.ui.screens.search.MobileSearchMediaType @@ -798,8 +799,8 @@ fun AppNavigation( onPersonClick = { personId -> navController.navigate(Route.PersonDetail(personId).route) }, - onRequestMediaClick = { item -> - navController.navigate(Route.RequestDetail(item.mediaType, item.tmdbId).route) + onRequestMediaClick = { mediaType, tmdbId -> + navController.navigate(Route.RequestDetail(mediaType, tmdbId).route) }, onRequestLibraryItemClick = { contentId -> navController.navigate(Route.ItemDetail(contentId).route) @@ -813,26 +814,34 @@ fun AppNavigation( } // ---- Requests ---- + val openRequestDetail: (String, Int) -> Unit = { mediaType, tmdbId -> + navController.navigate(Route.RequestDetail(mediaType, tmdbId).route) + } + val openRequestLibraryItem: (String) -> Unit = { contentId -> + navController.navigate(Route.ItemDetail(contentId).route) + } composable(Route.Requests.route) { RequestsScreen( onBackClick = { navController.popBackStack() }, onMyRequestsClick = { navController.navigate(Route.MyRequests.route) }, - onMediaClick = { item -> - navController.navigate(Route.RequestDetail(item.mediaType, item.tmdbId).route) - }, - onLibraryItemClick = { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - }, + onApprovalsClick = { navController.navigate(Route.RequestApprovals.route) }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, ) } composable(Route.MyRequests.route) { MyRequestsScreen( onBackClick = { navController.popBackStack() }, - onRequestClick = { request -> - request.libraryContentId?.takeIf { it.isNotBlank() }?.let { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - } ?: navController.navigate(Route.RequestDetail(request.mediaType, request.tmdbId).route) - }, + onApprovalsClick = { navController.navigate(Route.RequestApprovals.route) }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, + ) + } + composable(Route.RequestApprovals.route) { + RequestApprovalsScreen( + onBackClick = { navController.popBackStack() }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, ) } composable( @@ -848,12 +857,8 @@ fun AppNavigation( mediaType = mediaType, tmdbId = tmdbId, onBackClick = { navController.popBackStack() }, - onMediaClick = { item -> - navController.navigate(Route.RequestDetail(item.mediaType, item.tmdbId).route) - }, - onLibraryItemClick = { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - }, + onRequestDetailClick = openRequestDetail, + onLibraryItemClick = openRequestLibraryItem, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt index 2282a17b91..bd1462ceb5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt @@ -112,6 +112,7 @@ sealed class Route(val route: String) { // --- Requests --- data object Requests : Route("requests") data object MyRequests : Route("requests/mine") + data object RequestApprovals : Route("requests/approvals") data class RequestDetail( val mediaType: String, val tmdbId: Int, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailTopChrome.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailTopChrome.kt new file mode 100644 index 0000000000..f232d345fc --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailTopChrome.kt @@ -0,0 +1,138 @@ +package org.siloserver.silo.android.ui.screens.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +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.statusBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.siloserver.silo.android.ui.theme.SiloNavPillBorder +import org.siloserver.silo.android.ui.theme.SiloOverlayPillSurface +import org.siloserver.silo.android.ui.theme.SiloPageBackground + +/** + * The controls floating over a detail page's artwork: a back button, an + * optional trailing control, and a pinned strip and title that fade in as the + * hero scrolls away. Shared by library titles and request details, so both + * pages carry the same chrome. + */ +@Composable +internal fun DetailTopChrome( + title: String, + scroll: DetailScrollState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, +) { + Box(modifier = modifier.fillMaxSize()) { + // The strip fades in first so the controls gain a backing as the + // artwork leaves, then the title arrives once the hero is mostly gone — + // the two ranges and the smoothstep are iOS's. + val barAlpha = detailHeaderProgress(scroll.offsetDp, HeaderBarFadeFromDp, HeaderBarFadeToDp) + val titleAlpha = detailHeaderProgress(scroll.offsetDp, HeaderTitleFadeFromDp, HeaderTitleFadeToDp) + if (barAlpha > 0f) { + // Runs from the very top of the window, not from below the status + // bar: the page is edge to edge, so insetting the strip left the + // status-bar band uncovered above it. + val statusBarHeight = WindowInsets.statusBars + .asPaddingValues() + .calculateTopPadding() + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight + DetailHeaderBarHeight) + .graphicsLayer { alpha = barAlpha } + .background(SiloPageBackground) + .drawBehind { + drawRect( + color = Color.White.copy(alpha = 0.10f), + topLeft = Offset(0f, size.height - 1f), + size = Size(size.width, 1f), + ) + }, + ) + } + if (titleAlpha > 0f && title.isNotBlank()) { + Text( + text = title, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .height(DetailHeaderBarHeight) + .fillMaxWidth() + // Clear of the controls on either side, and centred on + // them: both sit in the same strip. + .padding(horizontal = 72.dp) + .wrapContentHeight(Alignment.CenterVertically) + .graphicsLayer { alpha = titleAlpha }, + ) + } + + // These glyphs sit on hero artwork that can be any colour, so they keep + // a disc — the bottom-nav pill, made translucent. A dark disc holds a + // white glyph over a pale poster and still lets the artwork through. + IconButton( + onClick = onBackClick, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + // Same geometry as the Home header's actions: a 40dp target + // 16dp from the edge, sitting directly below the status bar. + .padding(horizontal = 16.dp) + .size(40.dp) + .clip(CircleShape) + .background(SiloOverlayPillSurface) + .border(1.dp, SiloNavPillBorder, CircleShape), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White, + ) + } + if (trailing != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(horizontal = 16.dp), + ) { + trailing() + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index 291acf1a36..e88e628b77 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -1026,152 +1026,69 @@ fun ItemDetailScreen( } } - // Pinned header. The strip fades in first so the controls gain a - // backing as the artwork leaves, then the title arrives once the hero - // is mostly gone — the two ranges and the smoothstep are iOS's. - val headerTitle = state.detail?.title.orEmpty() - val barAlpha = detailHeaderProgress( - detailScroll.offsetDp, - HeaderBarFadeFromDp, - HeaderBarFadeToDp, - ) - val titleAlpha = detailHeaderProgress( - detailScroll.offsetDp, - HeaderTitleFadeFromDp, - HeaderTitleFadeToDp, - ) - if (barAlpha > 0f) { - // Runs from the very top of the window, not from below the status - // bar: the page is edge to edge, so insetting the strip left the - // status-bar band uncovered above it. - val statusBarHeight = WindowInsets.statusBars - .asPaddingValues() - .calculateTopPadding() - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .height(statusBarHeight + DetailHeaderBarHeight) - .graphicsLayer { alpha = barAlpha } - .background(SiloPageBackground) - .drawBehind { - drawRect( - color = Color.White.copy(alpha = 0.10f), - topLeft = Offset(0f, size.height - 1f), - size = Size(size.width, 1f), - ) - }, - ) - } - if (titleAlpha > 0f && headerTitle.isNotBlank()) { - Text( - text = headerTitle, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.TopCenter) - .statusBarsPadding() - .height(DetailHeaderBarHeight) - .fillMaxWidth() - // Clear of the back and remote controls on either side, - // and centred on them: both sit in the same strip. - .padding(horizontal = 72.dp) - .wrapContentHeight(Alignment.CenterVertically) - .graphicsLayer { alpha = titleAlpha }, - ) - } - - // These two glyphs sit on hero artwork that can be any colour, so they - // keep a disc — the bottom-nav pill, made translucent. A dark disc - // holds a white glyph over a pale poster and still lets the artwork - // through, which the previous white-tinted wash could not. - IconButton( - onClick = onBackClick, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - // Same geometry as the Home header's actions: a 40dp target - // 16dp from the edge, sitting directly below the status bar, - // so the controls do not jump when moving between the two. - .padding(horizontal = 16.dp) - .size(40.dp) - .clip(CircleShape) - .background(SiloOverlayPillSurface) - .border(1.dp, SiloNavPillBorder, CircleShape), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = Color.White, - ) - } - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .padding(horizontal = 16.dp), - ) { - IconButton( - onClick = { - if (siloCastState.hasActiveSession) { - remoteMenuExpanded = true - } else { - showRemoteTargetPicker = true - } - }, - // Solid pill while a cast session is live, translucent at - // rest, so the fill still reports state. - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background( + DetailTopChrome( + title = state.detail?.title.orEmpty(), + scroll = detailScroll, + onBackClick = onBackClick, + trailing = { + IconButton( + onClick = { if (siloCastState.hasActiveSession) { - SiloNavPillSurface + remoteMenuExpanded = true } else { - SiloOverlayPillSurface + showRemoteTargetPicker = true + } + }, + // Solid pill while a cast session is live, translucent at + // rest, so the fill still reports state. + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + if (siloCastState.hasActiveSession) { + SiloNavPillSurface + } else { + SiloOverlayPillSurface + }, + ) + .border(1.dp, SiloNavPillBorder, CircleShape), + ) { + Icon( + imageVector = Icons.Outlined.SettingsRemote, + contentDescription = "Remote Control", + tint = Color.White, + ) + } + DropdownMenu( + expanded = remoteMenuExpanded, + onDismissRequest = { remoteMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("Remote Control") }, + onClick = { + remoteMenuExpanded = false + onOpenCastRemote() }, ) - .border(1.dp, SiloNavPillBorder, CircleShape), - ) { - Icon( - imageVector = Icons.Outlined.SettingsRemote, - contentDescription = "Remote Control", - tint = Color.White, - ) - } - DropdownMenu( - expanded = remoteMenuExpanded, - onDismissRequest = { remoteMenuExpanded = false }, - ) { - DropdownMenuItem( - text = { Text("Remote Control") }, - onClick = { - remoteMenuExpanded = false - onOpenCastRemote() - }, - ) - DropdownMenuItem( - text = { Text("Choose TV") }, - onClick = { - remoteMenuExpanded = false - showRemoteTargetPicker = true - }, - ) - HorizontalDivider() - DropdownMenuItem( - text = { - Text("Turn Off Control Mode", color = MaterialTheme.colorScheme.error) - }, - onClick = { - remoteMenuExpanded = false - siloCastController.disconnect() - }, - ) - } - } + DropdownMenuItem( + text = { Text("Choose TV") }, + onClick = { + remoteMenuExpanded = false + showRemoteTargetPicker = true + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Text("Turn Off Control Mode", color = MaterialTheme.colorScheme.error) + }, + onClick = { + remoteMenuExpanded = false + siloCastController.disconnect() + }, + ) + } + }, + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt index 8f880443ed..070b8ce859 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt @@ -1,125 +1,220 @@ package org.siloserver.silo.android.ui.screens.requests -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize +import org.siloserver.silo.common.requests.rememberRequestRouter +import org.siloserver.silo.model.request.MediaRequest +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.DownloadForOffline +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.HowToReg import androidx.compose.material.icons.outlined.Inbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold +import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material.icons.outlined.SyncProblem +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox 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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.siloserver.silo.android.ui.components.SiloTopBar +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf import org.siloserver.silo.android.ui.components.EmptyStateView -import org.siloserver.silo.android.ui.components.LoadingIndicator -import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling -import org.siloserver.silo.model.request.MediaRequest -import org.siloserver.silo.model.request.canCancel +import org.siloserver.silo.android.ui.components.ErrorView +import org.siloserver.silo.android.ui.components.rememberShimmerProgress +import org.siloserver.silo.android.ui.theme.SiloNavPillBorder +import org.siloserver.silo.android.ui.theme.SiloOverlayPillSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.common.requests.RequestColors +import org.siloserver.silo.model.feature.RequestsFeatureStore +import org.siloserver.silo.model.request.MyRequestsBucket import org.siloserver.silo.viewmodel.MyRequestsViewModel -import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.viewmodel.RequestApprovalsViewModel -@OptIn(ExperimentalMaterial3Api::class) +/** + * The signed-in user's requests as a grouped list in the Downloads manager's + * shape: a stage track on every row, swipe to cancel, Open once a title + * lands, and a filter menu in the bar. Admins get a card into the approval + * queue. + */ @Composable fun MyRequestsScreen( onBackClick: () -> Unit, - onRequestClick: (MediaRequest) -> Unit, + onApprovalsClick: () -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, + onLibraryItemClick: (String) -> Unit, viewModel: MyRequestsViewModel = koinViewModel(), + featureStore: RequestsFeatureStore = koinInject(), + approvals: RequestApprovalsViewModel = koinViewModel(key = "my-requests-approvals") { parametersOf(false) }, ) { val state by viewModel.uiState.collectAsState() + val canModerate by featureStore.canModerate.collectAsState() + val approvalsState by approvals.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) + var filter by remember { mutableStateOf(null) } - Scaffold( - topBar = { - SiloTopBar( - title = "My Requests", - onBackClick = onBackClick, - ) + // For the approvals card; a failed read just hides it. + LaunchedEffect(canModerate) { if (canModerate) approvals.load() } + // A filter whose last request moved on would leave a blank list. + LaunchedEffect(state.buckets) { + if (filter != null && state.buckets.none { it.first == filter }) filter = null + } + + RequestsLargeTitlePage( + title = "My Requests", + onBackClick = onBackClick, + isRefreshing = state.isRefreshing, + onRefresh = { + viewModel.refresh() + // The approvals card is this page's only way into the queue. + if (canModerate) approvals.refresh() + }, + itemSpacing = 0.dp, + actions = { + if (state.buckets.size > 1) { + FilterMenu(buckets = state.buckets, filter = filter, onFilter = { filter = it }) + } }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> + ) { + // Independent of the admin's own list: an admin with no requests of + // their own still needs the way into the queue. + val pending = approvalsState.awaitingApproval.size + val failed = approvalsState.failed.size + if (canModerate && approvalsState.hasLoaded && (pending > 0 || failed > 0)) { + item(key = "approvals-card") { + RequestSummaryCard( + modifier = Modifier.padding(top = RequestGroupGap), + title = when { + pending == 1 -> "1 request needs your approval" + pending > 1 -> "$pending requests need your approval" + failed == 1 -> "1 failed request to review" + else -> "$failed failed requests to review" + }, + leadingIcon = if (pending > 0) Icons.Outlined.HowToReg else Icons.Outlined.SyncProblem, + leadingTint = if (pending > 0) RequestColors.Amber else RequestColors.Rose, + onClick = onApprovalsClick, + ) + } + } when { - state.isLoading && state.requests.isEmpty() -> { - LoadingIndicator( - modifier = Modifier - .fillMaxSize() - .padding(padding), + state.error != null && state.buckets.isEmpty() -> item(key = "error") { + ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load, modifier = Modifier.padding(top = 60.dp)) + } + state.isLoading && state.buckets.isEmpty() -> item(key = "loading") { + Box(modifier = Modifier.padding(top = RequestGroupGap)) { RequestRowsSkeleton(progress = rememberShimmerProgress()) } + } + state.isEmpty -> item(key = "empty") { + EmptyStateView( + title = "No requests yet", + subtitle = "Movies and series you request will show up here", + icon = Icons.Outlined.Inbox, + modifier = Modifier.padding(top = 80.dp), ) } else -> { - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - val listState = rememberLazyListState() - DeferImagePresentationWhileScrolling(listState) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - state.error?.let { error -> - item { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - if (state.requests.isEmpty()) { - item { - EmptyStateView( - title = "No requests yet", - subtitle = "Movies and series you request will show up here with their status.", - icon = Icons.Outlined.Inbox, - ) - } - } else { - items(state.requests, key = { it.id }) { request -> - RequestListItem( - request = request, - onClick = { onRequestClick(request) }, - trailing = { - if (request.canCancel()) { - Column { - OutlinedButton( - onClick = { viewModel.cancel(request.id) }, - enabled = state.actionInFlightId != request.id, - ) { - Text( - text = if (state.actionInFlightId == request.id) { - "Canceling" - } else { - "Cancel" - }, - maxLines = 1, - ) - } - } - } - }, - ) - } - } + state.actionErrorMessage?.let { message -> + item(key = "action-error") { + Text( + text = message, + fontSize = 12.sp, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = RequestGroupGap).padding(horizontal = 16.dp), + ) } + } + state.buckets.filter { filter == null || it.first == filter }.forEach { (bucket, requests) -> + requestGroupedSection(key = "bucket:${bucket.name}", title = bucket.title, items = requests, itemKey = { it.id }) { record, showDivider -> + MyRequestRow( + record = record, + isBusy = state.cancellingId == record.id, + showDivider = showDivider, + onOpen = { router.openRecord(record) }, + onOpenInLibrary = record.libraryContentId?.takeIf { it.isNotBlank() }?.let { id -> { onLibraryItemClick(id) } }, + onCancel = if (state.canCancel(record)) ({ viewModel.cancel(record) }) else null, + ) } } } } } } + +/** One control in the bar instead of a chip row that repeats the section headers. */ +@Composable +private fun FilterMenu( + buckets: List>>, + filter: MyRequestsBucket?, + onFilter: (MyRequestsBucket?) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { open = true }, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (filter != null) Color.White.copy(alpha = 0.30f) else SiloOverlayPillSurface) + .border(1.dp, SiloNavPillBorder, CircleShape), + ) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = filter?.let { "Filter: ${it.title}" } ?: "Filter", + tint = Color.White, + ) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + FilterItem(label = "All Requests", icon = Icons.Outlined.Inventory2, selected = filter == null) { + open = false + onFilter(null) + } + buckets.forEach { (bucket, requests) -> + val icon = when (bucket) { + MyRequestsBucket.InMotion -> Icons.Outlined.DownloadForOffline + MyRequestsBucket.NeedsAttention -> Icons.Outlined.ErrorOutline + MyRequestsBucket.Landed -> Icons.Outlined.CheckCircle + } + FilterItem(label = "${bucket.title} (${requests.size})", icon = icon, selected = filter == bucket) { + open = false + onFilter(bucket) + } + } + } + } +} + +@Composable +private fun FilterItem(label: String, icon: ImageVector, selected: Boolean, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(label) }, + leadingIcon = { + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + if (selected) Icon(Icons.Filled.Check, contentDescription = "Selected") else Icon(icon, contentDescription = null) + } + }, + onClick = onClick, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt new file mode 100644 index 0000000000..9093373f94 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt @@ -0,0 +1,153 @@ +package org.siloserver.silo.android.ui.screens.requests + +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.android.ui.components.EmptyStateView +import org.siloserver.silo.android.ui.components.ErrorView +import org.siloserver.silo.android.ui.components.rememberShimmerProgress +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.common.requests.RequestColors +import org.siloserver.silo.common.requests.rememberRequestRouter +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.viewmodel.RequestApprovalsViewModel + +/** + * Everyone's requests waiting on a decision, and failed ones that can be + * retried. Only reachable for admins who can moderate. Only the tapped row + * fades while its action runs; the button shows the result before the row + * leaves, and a failure shakes the button and shows the reason on the row. + */ +@Composable +fun RequestApprovalsScreen( + onBackClick: () -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, + onLibraryItemClick: (String) -> Unit, + viewModel: RequestApprovalsViewModel = koinViewModel(key = "approvals") { parametersOf(true) }, +) { + val state by viewModel.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) + var pendingDecline by remember { mutableStateOf(null) } + val view = LocalView.current + + // Counters the view model keeps; remembered so a return or rotation + // doesn't replay the last action's haptic. + var seenCompleted by rememberSaveable { mutableStateOf(state.completedActions) } + var seenFailed by rememberSaveable { mutableStateOf(state.failedActions) } + LaunchedEffect(state.completedActions) { + if (state.completedActions > seenCompleted) view.performHapticFeedback(confirmHaptic()) + seenCompleted = state.completedActions + } + LaunchedEffect(state.failedActions) { + if (state.failedActions > seenFailed) view.performHapticFeedback(rejectHaptic()) + seenFailed = state.failedActions + } + + pendingDecline?.let { request -> + AlertDialog( + onDismissRequest = { pendingDecline = null }, + title = { Text("Decline this request?") }, + text = { Text("The requester will see it as declined.") }, + confirmButton = { + TextButton(onClick = { + pendingDecline = null + viewModel.perform(AdminRequestAction.Decline, request) + }) { Text("Decline ${request.title}", color = RequestColors.Rose) } + }, + dismissButton = { TextButton(onClick = { pendingDecline = null }) { Text("Keep") } }, + ) + } + + RequestsLargeTitlePage( + title = "Approvals", + onBackClick = onBackClick, + isRefreshing = state.isRefreshing, + onRefresh = viewModel::refresh, + itemSpacing = 0.dp, + ) { + when { + state.error != null && state.awaitingApproval.isEmpty() && state.failed.isEmpty() -> item(key = "error") { + ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load, modifier = Modifier.padding(top = 60.dp)) + } + !state.hasLoaded -> item(key = "loading") { + Box(modifier = Modifier.padding(top = RequestGroupGap)) { RequestRowsSkeleton(progress = rememberShimmerProgress()) } + } + state.isEmpty -> item(key = "empty") { + EmptyStateView( + title = "Nothing waiting on you", + subtitle = "New requests that need approval will show up here", + icon = Icons.Outlined.CheckCircle, + modifier = Modifier.padding(top = 80.dp), + ) + } + else -> { + state.actionErrorMessage?.let { message -> + item(key = "action-error") { + Text( + text = message, + fontSize = 12.sp, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = RequestGroupGap).padding(horizontal = 16.dp), + ) + } + } + requestGroupedSection(key = "awaiting", title = "Waiting for approval", items = state.awaitingApproval, itemKey = { it.id }) { record, showDivider -> + RequestApprovalRow( + record = record, + isBusy = !state.canAct(record), + phase = state.phases[record.id], + actionError = state.rowErrors[record.id], + shakeTrigger = state.failureCounts[record.id] ?: 0, + showDivider = showDivider, + onOpen = { router.openModerationRecord(record) }, + onApprove = { viewModel.perform(AdminRequestAction.Approve, record) }, + onDecline = { pendingDecline = record }, + ) + } + requestGroupedSection(key = "failed", title = "Failed", items = state.failed, itemKey = { it.id }) { record, showDivider -> + MyRequestRow( + record = record, + isBusy = !state.canAct(record), + actionPhase = state.phases[record.id], + actionError = state.rowErrors[record.id]?.let { "Couldn't retry · $it" }, + shakeTrigger = state.failureCounts[record.id] ?: 0, + showDivider = showDivider, + onOpen = { router.openModerationRecord(record) }, + onRetry = { viewModel.perform(AdminRequestAction.Retry, record) }, + ) + } + } + } + } +} + +internal fun confirmHaptic(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.LONG_PRESS + +internal fun rejectHaptic(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) HapticFeedbackConstants.REJECT else HapticFeedbackConstants.LONG_PRESS diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestComponents.kt index fe488068f4..a53468f17d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestComponents.kt @@ -1,10 +1,12 @@ package org.siloserver.silo.android.ui.screens.requests 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.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -12,240 +14,606 @@ 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.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Tv -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilterChip +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PriorityHigh +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.siloserver.silo.android.ui.components.MediaGridDefaults +import org.siloserver.silo.android.ui.components.skeleton +import org.siloserver.silo.android.ui.theme.SiloNavPillBorder +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloOverlayPillSurface +import org.siloserver.silo.android.ui.theme.SiloPageBackground +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.android.ui.theme.SiloSurfaceContainer +import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated import org.siloserver.silo.common.cards.LocalCardPresentation +import org.siloserver.silo.common.requests.RequestColors +import org.siloserver.silo.common.requests.color +import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestDisplayState import org.siloserver.silo.model.request.RequestMediaResult -import org.siloserver.silo.model.request.RequestMediaType -import org.siloserver.silo.model.request.badgeStatus -import org.siloserver.silo.model.request.requestDisplayLabel +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.RequestStatusTint import org.siloserver.silo.model.request.requestPosterUrl -import org.siloserver.silo.model.request.targetSummary +/** Card geometry shared with the library rows ([org.siloserver.silo.android.ui.components.MediaCard]). */ +internal const val RequestPosterAspect = 2f / 3.3f + +@Composable +internal fun requestCardWidth(): Dp = 120.dp * LocalCardPresentation.current.posterSize.posterScale + +/** Chrome fills shared by the summary cards and quiet buttons (Apple's resting/selected chrome). */ +internal val RequestChromeRestingFill = Color.White.copy(alpha = 0.07f) +internal val RequestChromeRestingBorder = Color.White.copy(alpha = 0.09f) +internal val RequestChromeSelectedFill = Color.White.copy(alpha = 0.14f) + +internal fun RequestStatusTint.uiColor(): Color = color(neutral = SiloSecondaryText) + +/** + * Poster card for a TMDB search/discover result, in the library card's + * grammar. A title that's already requested or in the library gets a small + * corner badge instead of a status caption. + */ @Composable fun RequestMediaCard( item: RequestMediaResult, onClick: () -> Unit, modifier: Modifier = Modifier, + width: Dp = requestCardWidth(), +) { + RequestPosterCard( + title = item.title, + year = item.year, + posterPath = item.posterPath, + progress = RequestProgress.of(item.availability, item.request), + showsStatusInCaption = false, + onClick = onClick, + modifier = modifier, + width = width, + ) +} + +/** Poster card for one of the user's own requests: its status is the caption. */ +@Composable +fun RequestRecordCard( + record: MediaRequest, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = requestCardWidth(), ) { - // Caption gating mirrors RequestMediaCard.swift: showsTitle wraps the - // whole caption block, showsMetadata the type/year line inside it. - val cardPresentation = LocalCardPresentation.current + RequestPosterCard( + title = record.title, + year = record.year, + posterPath = record.posterPath, + progress = RequestProgress.of(record), + showsStatusInCaption = true, + onClick = onClick, + modifier = modifier, + width = width, + ) +} + +@Composable +private fun RequestPosterCard( + title: String, + year: Int?, + posterPath: String?, + progress: RequestProgress?, + showsStatusInCaption: Boolean, + onClick: () -> Unit, + modifier: Modifier, + width: Dp, +) { + val caption = LocalCardPresentation.current.caption + val label = listOfNotNull(title, year?.takeIf { it > 0 }?.toString(), progress?.shortLabel).joinToString(", ") Column( modifier = modifier - .width(132.dp * cardPresentation.posterSize.posterScale) - .clickable(onClick = onClick), + .width(width) + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { contentDescription = label }, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(2f / 3f) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), + .aspectRatio(RequestPosterAspect) + .clip(RoundedCornerShape(8.dp)) + .background(SiloSurfaceElevated), ) { - ThumbhashImage( - url = requestPosterUrl(item.posterPath), - thumbhash = null, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), - ) - RequestBadge( - text = item.badgeStatus().requestDisplayLabel(), - modifier = Modifier - .align(Alignment.TopStart) - .padding(6.dp), - ) + val url = requestPosterUrl(posterPath) + if (url != null) { + ThumbhashImage( + url = url, + thumbhash = null, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } else { + Text( + text = title, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Center) + .padding(12.dp), + ) + } + if (!showsStatusInCaption && progress != null) { + RequestPosterBadge( + state = progress.display, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp), + ) + } } - if (cardPresentation.caption.showsTitle) { - // The spacer belongs to the caption: artwork-only cards must not - // leave a gap under the poster. - Spacer(modifier = Modifier.height(8.dp)) + if (caption.showsTitle) { Text( - text = item.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, + text = title, + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Bold, + color = SiloOnSurface, maxLines = 2, + minLines = 2, overflow = TextOverflow.Ellipsis, ) - if (cardPresentation.caption.showsMetadata) { - RequestMetaLine( - mediaType = item.mediaType, - year = item.year, + when { + showsStatusInCaption && progress != null -> RequestStatusLabel(progress = progress) + caption.showsMetadata && year != null && year > 0 -> Text( + text = year.toString(), + fontSize = 12.sp, + color = SiloSecondaryText, + maxLines = 1, ) } } } } +/** + * Corner badge for a discovery poster whose title is already requested or in + * the library: a white glyph on a filled status disc, in the watched badge's + * shape. + */ @Composable -fun RequestListItem( - request: MediaRequest, - onClick: (() -> Unit)?, - trailing: @Composable (() -> Unit)? = null, +fun RequestPosterBadge( + state: RequestDisplayState, modifier: Modifier = Modifier, + size: Dp = 20.dp, ) { - Card( + val icon = when (state) { + RequestDisplayState.Pending -> Icons.Filled.Schedule + RequestDisplayState.OnTheWay -> Icons.Filled.ArrowDownward + RequestDisplayState.InLibrary -> Icons.Filled.Check + is RequestDisplayState.NeedsAttention -> Icons.Filled.PriorityHigh + is RequestDisplayState.Unavailable -> return + } + Box( modifier = modifier - .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), - ), - shape = RoundedCornerShape(8.dp), + .size(size) + .shadow(4.dp, CircleShape, ambientColor = Color.Black.copy(alpha = 0.35f), spotColor = Color.Black.copy(alpha = 0.35f)) + .clip(CircleShape) + .background(state.tint.uiColor()), + contentAlignment = Alignment.Center, ) { - Row( - modifier = Modifier.padding(12.dp), + Icon(imageVector = icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(size * 0.6f)) + } +} + +/** Status dot and label: the caption under request cards and the line under row tracks. */ +@Composable +fun RequestStatusLabel( + progress: RequestProgress, + modifier: Modifier = Modifier, + text: String = progress.shortLabel, + fontSize: TextUnit = 12.sp, + fontWeight: FontWeight = FontWeight.Normal, + color: Color = SiloSecondaryText, + maxLines: Int = 1, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = Modifier + .padding(top = (fontSize.value * 0.45f).dp) + .size(6.dp) + .background(progress.tint.uiColor(), CircleShape), + ) + Text( + text = text, + fontSize = fontSize, + lineHeight = (fontSize.value * 1.3f).sp, + fontWeight = fontWeight, + color = color, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Horizontal poster rail shared by every request surface. */ +@Composable +internal fun RequestCardRail( + items: List, + key: (T) -> Any, + card: @Composable (T) -> Unit, +) { + val rowState = rememberLazyListState() + DeferImagePresentationWhileScrolling(rowState) { + LazyRow( + state = rowState, horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp), ) { - Box( - modifier = Modifier - .width(64.dp) - .aspectRatio(2f / 3f) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - ThumbhashImage( - url = requestPosterUrl(request.posterPath), - thumbhash = null, - contentDescription = request.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + items(items, key = key) { card(it) } + } + } +} + +/** + * Section header in the detail pages' editorial grammar: an optional tracked + * eyebrow over the title, and an optional trailing link. + */ +@Composable +internal fun RequestsSectionHeader( + title: String, + modifier: Modifier = Modifier, + label: String? = null, + trailing: String? = null, + onTrailingClick: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.Bottom, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!label.isNullOrBlank()) { Text( - text = request.title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + text = label.uppercase(), + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.6.sp, + color = SiloOnSurface.copy(alpha = 0.55f), + maxLines = 1, ) - RequestMetaLine( - mediaType = request.mediaType, - year = request.year, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - request.status.takeIf { it.isNotBlank() }?.let { - RequestBadge(text = it.requestDisplayLabel()) - } - request.outcome.takeIf { it.isNotBlank() }?.let { - RequestBadge(text = it.requestDisplayLabel()) - } - } - request.targetSummary()?.let { summary -> - Text( - text = summary, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - request.lastError.takeIf { it.isNotBlank() }?.let { error -> - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } } - trailing?.invoke() + Text( + text = title, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (trailing != null && onTrailingClick != null) { + Text( + text = "$trailing ›", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = SiloSecondaryText, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onTrailingClick) + .padding(horizontal = 4.dp, vertical = 4.dp), + ) } } } +/** + * The pinned top row of a large-title page: a back disc, the page title faded + * in once the large title scrolls away, and optional actions. The page's own + * first item is the large title. + */ @Composable -fun RequestBadge( - text: String, +internal fun RequestsLargeTitleBar( + title: String, + titleProgress: Float, + onBackClick: () -> Unit, modifier: Modifier = Modifier, + actions: @Composable () -> Unit = {}, ) { Box( modifier = modifier - .clip(RoundedCornerShape(6.dp)) - .background(Color.Black.copy(alpha = 0.62f)) - .padding(horizontal = 8.dp, vertical = 4.dp), + .fillMaxWidth() + .background(SiloPageBackground.copy(alpha = titleProgress)) + .statusBarsPadding() + .height(56.dp) + .padding(horizontal = 16.dp), ) { + IconButton( + onClick = onBackClick, + modifier = Modifier + .align(Alignment.CenterStart) + .size(40.dp) + .clip(CircleShape) + .background(SiloOverlayPillSurface) + .border(1.dp, SiloNavPillBorder, CircleShape), + ) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White) + } Text( - text = text, - style = MaterialTheme.typography.labelSmall, + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, color = Color.White, maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 56.dp) + .graphicsLayer { alpha = titleProgress }, ) + Box(modifier = Modifier.align(Alignment.CenterEnd)) { actions() } } } +/** The large title a page scrolls under its pinned bar. */ @Composable -fun RequestMetaLine( - mediaType: String, - year: Int?, +internal fun RequestsLargeTitle(title: String, modifier: Modifier = Modifier) { + Text( + text = title, + fontSize = 34.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Bold, + color = SiloOnSurface, + modifier = modifier.padding(horizontal = 16.dp), + ) +} + +/** The page's search field, in the library search's capsule. */ +@Composable +internal fun RequestsSearchField( + query: String, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, modifier: Modifier = Modifier, ) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + placeholder = { Text("Search movies & series", color = SiloSecondaryText) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = SiloSecondaryText) }, + trailingIcon = if (query.isNotEmpty()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, contentDescription = "Clear search", tint = SiloSecondaryText) + } + } + } else { + null + }, + singleLine = true, + shape = RoundedCornerShape(50), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + colors = TextFieldDefaults.colors( + focusedContainerColor = RequestChromeSelectedFill, + unfocusedContainerColor = RequestChromeSelectedFill, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = SiloOnSurface, + focusedTextColor = SiloOnSurface, + unfocusedTextColor = SiloOnSurface, + ), + ) +} + +/** A glanceable card with a title, optional status parts, and a chevron. */ +@Composable +internal fun RequestSummaryCard( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + parts: List> = emptyList(), + leadingIcon: ImageVector? = null, + leadingTint: Color = SiloOnSurface, +) { + val shape = RoundedCornerShape(18.dp) Row( - modifier = modifier, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(RequestChromeRestingFill) + .border(1.dp, RequestChromeRestingBorder, shape) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - imageVector = mediaType.icon(), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - Text( - text = listOfNotNull(mediaType.requestDisplayLabel(), year?.toString()).joinToString(" • "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (leadingIcon != null) { + Icon(imageVector = leadingIcon, contentDescription = null, tint = leadingTint, modifier = Modifier.size(24.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + if (parts.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + parts.forEach { (tint, text) -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box(modifier = Modifier.size(6.dp).background(tint.uiColor(), CircleShape)) + Text(text = text, fontSize = 12.sp, color = SiloSecondaryText) + } + } + } + } + } + Text(text = "›", fontSize = 20.sp, color = SiloSecondaryText) + } +} + +/** Space above each grouped section on pages that pass zero item spacing. */ +internal val RequestGroupGap = 24.dp + +/** + * A grouped list section: a muted header with a count over a rounded card of + * rows. Each row is its own lazy item, so a long queue composes only what's on + * screen; the card's corners come from the first and last rows. + */ +internal fun LazyListScope.requestGroupedSection( + key: String, + title: String, + items: List, + itemKey: (T) -> Any, + row: @Composable (item: T, showDivider: Boolean) -> Unit, +) { + if (items.isEmpty()) return + item(key = "$key:header", contentType = "group-header") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = RequestGroupGap) + .padding(horizontal = 32.dp, vertical = 8.dp), + ) { + Text(text = title, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = SiloSecondaryText, modifier = Modifier.weight(1f)) + Text(text = items.size.toString(), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = SiloSecondaryText) + } + } + itemsIndexed(items, key = { _, item -> "$key:${itemKey(item)}" }, contentType = { _, _ -> "group-row" }) { index, item -> + val corner = 20.dp + val shape = RoundedCornerShape( + topStart = if (index == 0) corner else 0.dp, + topEnd = if (index == 0) corner else 0.dp, + bottomStart = if (index == items.lastIndex) corner else 0.dp, + bottomEnd = if (index == items.lastIndex) corner else 0.dp, ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(SiloSurfaceContainer), + ) { + row(item, index > 0) + } } } +/** A quiet error line in the status line's slot. */ @Composable -fun RequestFilterChip( - selected: Boolean, - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - FilterChip( - selected = selected, - onClick = onClick, - label = { - Text(text = label) - }, +internal fun RequestRowErrorLine(text: String, modifier: Modifier = Modifier) { + Text( + text = "⚠ $text", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = RequestColors.Rose, + maxLines = 2, + overflow = TextOverflow.Ellipsis, modifier = modifier, ) } -private fun String.icon(): ImageVector = when (this) { - RequestMediaType.Series -> Icons.Default.Tv - else -> Icons.Default.Movie +// ── Loading placeholders ───────────────────────────────────── + +/** A rail of quiet poster placeholders in the final layout's shape. */ +@Composable +internal fun RequestRailSkeleton(progress: Float, title: String? = null, cardCount: Int = 4) { + val width = requestCardWidth() + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (title != null) { + RequestsSectionHeader(title = title) + } else { + Box(modifier = Modifier.padding(horizontal = 16.dp).width(160.dp).height(22.dp).skeleton(progress)) + } + Row(modifier = Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(cardCount) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Box(modifier = Modifier.width(width).aspectRatio(RequestPosterAspect).skeleton(progress)) + Box(modifier = Modifier.width(width * 0.7f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp))) + } + } + } + } } + +/** Grouped-row placeholders for My Requests and Approvals. */ +@Composable +internal fun RequestRowsSkeleton(progress: Float, rows: Int = 5) { + Column(modifier = Modifier.padding(top = 8.dp)) { + Box(modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp).width(96.dp).height(14.dp).skeleton(progress, RoundedCornerShape(3.dp))) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(SiloSurfaceContainer) + .padding(vertical = 4.dp), + ) { + repeat(rows) { + Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.width(46.dp).height(69.dp).skeleton(progress, RoundedCornerShape(6.dp))) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.fillMaxWidth(0.6f).height(14.dp).skeleton(progress, RoundedCornerShape(3.dp))) + Box(modifier = Modifier.fillMaxWidth(0.4f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp))) + Spacer(modifier = Modifier.height(2.dp)) + Box(modifier = Modifier.fillMaxWidth().height(4.dp).skeleton(progress, RoundedCornerShape(2.dp))) + Box(modifier = Modifier.fillMaxWidth(0.5f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp))) + } + } + } + } + } +} + +/** Column count of the library search grid at [width]. */ +internal fun requestGridColumns(width: Dp, minCellWidth: Dp): Int = + ((width - 32.dp + MediaGridDefaults.PosterGridHorizontalSpacing) / (minCellWidth + MediaGridDefaults.PosterGridHorizontalSpacing)) + .toInt() + .coerceAtLeast(2) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt index fc2833c6e2..333ec80065 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt @@ -1,363 +1,496 @@ package org.siloserver.silo.android.ui.screens.requests +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween 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.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import org.siloserver.silo.android.ui.components.SiloTopBar -import org.siloserver.silo.android.ui.components.EmptyStateView -import org.siloserver.silo.android.ui.components.LoadingIndicator -import org.siloserver.silo.common.ui.RatingEntry +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.android.ui.components.ErrorView +import org.siloserver.silo.android.ui.screens.detail.AdaptiveDetailHero +import org.siloserver.silo.android.ui.screens.detail.DetailLoadingSkeleton +import org.siloserver.silo.android.ui.screens.detail.DetailPageSurface +import org.siloserver.silo.android.ui.screens.detail.DetailScrollState +import org.siloserver.silo.android.ui.screens.detail.DetailTopChrome +import org.siloserver.silo.android.ui.screens.detail.HeaderSettledDp +import org.siloserver.silo.android.ui.screens.detail.HeroMetadata +import org.siloserver.silo.android.ui.theme.SiloBackground +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.android.ui.util.rememberDominantColor +import org.siloserver.silo.common.requests.RequestColors +import org.siloserver.silo.common.requests.RequestRouter +import org.siloserver.silo.common.requests.RequestRowCopy +import org.siloserver.silo.common.requests.rememberRequestRouter import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling -import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.ExternalRatings +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestDisplayState import org.siloserver.silo.model.request.RequestMediaDetail -import org.siloserver.silo.model.request.RequestMediaResult -import org.siloserver.silo.model.request.reasonMessage +import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.RequestStep +import org.siloserver.silo.model.request.RequestTargetSummary import org.siloserver.silo.model.request.requestBackdropUrl -import org.siloserver.silo.model.request.requestDisplayLabel import org.siloserver.silo.model.request.requestPosterUrl +import org.siloserver.silo.model.request.requestReasonCopy +import org.siloserver.silo.viewmodel.RequestDetailUiState import org.siloserver.silo.viewmodel.RequestDetailViewModel -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf +import org.siloserver.silo.viewmodel.RequestPrimaryAction -@OptIn(ExperimentalMaterial3Api::class) +/** + * Detail for a requestable TMDB title, built on the same page surface, hero, + * and floating chrome as a library title. One server-computed primary action, + * a status card with the stage steps once a request exists, and a "More like + * this" rail. Requesting has no confirmation dialog: the button is the + * confirmation and turns into the status in place. + */ @Composable fun RequestDetailScreen( mediaType: String, tmdbId: Int, onBackClick: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, onLibraryItemClick: (String) -> Unit, viewModel: RequestDetailViewModel = koinViewModel( parameters = { parametersOf(mediaType to tmdbId) }, ), ) { val state by viewModel.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) + val scroll = remember { DetailScrollState() } + var confirmingDecline by remember { mutableStateOf(false) } + val view = LocalView.current - Scaffold( - topBar = { - SiloTopBar( - title = "Request Details", - onBackClick = onBackClick, - ) - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when { - state.isLoading && state.detail == null -> { - LoadingIndicator( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) - } - state.error != null && state.detail == null -> { - EmptyStateView( - title = "Details unavailable", - subtitle = state.error ?: "Failed to load this title.", - icon = Icons.Outlined.ErrorOutline, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) - } - state.detail != null -> { - RequestDetailContent( - detail = requireNotNull(state.detail), - isSubmitting = state.isSubmitting, - error = state.error, - notice = state.notice, - onRequest = viewModel::submitRequest, - onRetry = viewModel::load, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - modifier = Modifier.padding(padding), - ) + // Counters the view model keeps; remembered so a return or rotation + // doesn't replay the last action's haptic. + val accepted = state.submittedCount + state.moderatedCount + var seenAccepted by rememberSaveable { mutableStateOf(accepted) } + LaunchedEffect(accepted) { + if (accepted > seenAccepted) view.performHapticFeedback(confirmHaptic()) + seenAccepted = accepted + } + + if (confirmingDecline) { + AlertDialog( + onDismissRequest = { confirmingDecline = false }, + title = { Text("Decline this request?") }, + text = { Text("The person who asked for it will see it as declined.") }, + confirmButton = { + TextButton(onClick = { + confirmingDecline = false + viewModel.moderate(AdminRequestAction.Decline) + }) { Text("Decline", color = RequestColors.Rose) } + }, + dismissButton = { TextButton(onClick = { confirmingDecline = false }) { Text("Keep") } }, + ) + } + + Box(modifier = Modifier.fillMaxSize().background(SiloBackground)) { + val branch = when { + state.detail != null -> "detail" + state.error != null -> "error" + else -> "loading" + } + Crossfade(targetState = branch, animationSpec = tween(180), label = "requestDetailBranch") { current -> + when (current) { + "detail" -> state.detail?.let { detail -> + RequestDetailContent( + detail = detail, + state = state, + scroll = scroll, + onPrimary = { + when (val action = state.primaryAction) { + RequestPrimaryAction.Request -> viewModel.submitRequest() + is RequestPrimaryAction.OpenInLibrary -> onLibraryItemClick(action.contentId) + else -> Unit + } + }, + onModerate = { action -> + if (action == AdminRequestAction.Decline) confirmingDecline = true else viewModel.moderate(action) + }, + onCancel = viewModel::cancel, + router = router, + ) + } + "error" -> ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load) + else -> DetailLoadingSkeleton() } } + DetailTopChrome(title = state.detail?.title.orEmpty(), scroll = scroll, onBackClick = onBackClick) } } @Composable private fun RequestDetailContent( detail: RequestMediaDetail, - isSubmitting: Boolean, - error: String?, - notice: String?, - onRequest: () -> Unit, - onRetry: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, - modifier: Modifier = Modifier, + state: RequestDetailUiState, + scroll: DetailScrollState, + onPrimary: () -> Unit, + onModerate: (AdminRequestAction) -> Unit, + onCancel: () -> Unit, + router: RequestRouter, ) { - val listState = rememberLazyListState() - DeferImagePresentationWhileScrolling(listState) { - LazyColumn( - state = listState, - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - ThumbhashImage( - url = requestBackdropUrl(detail.backdropPath) ?: requestPosterUrl(detail.posterPath), - thumbhash = null, - contentDescription = detail.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), - ) + val itemDetail = remember(detail) { detail.toHeroDetail() } + val dominantColor by rememberDominantColor(imageUrl = itemDetail.backdropUrl, fallback = SiloBackground) + val feedState = rememberLazyListState() + val density = LocalDensity.current + LaunchedEffect(feedState, scroll, density) { + snapshotFlow { + if (feedState.firstVisibleItemIndex > 0) { + HeaderSettledDp + } else { + with(density) { feedState.firstVisibleItemScrollOffset.toDp().value } } - } - item { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + }.collect { scroll.update(it) } + } + DetailPageSurface(backdropUrl = itemDetail.backdropUrl, backdropThumbhash = null, tint = dominantColor) { + DeferImagePresentationWhileScrolling(feedState) { + LazyColumn( + state = feedState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(32.dp), ) { - Box( - modifier = Modifier - .width(116.dp) - .aspectRatio(2f / 3f) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - ThumbhashImage( - url = requestPosterUrl(detail.posterPath), - thumbhash = null, - contentDescription = detail.title, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), + item(key = "hero", contentType = "detail-hero") { + AdaptiveDetailHero( + detail = itemDetail, + eyebrow = state.eyebrow, + // The library hero's facts: year and runtime or + // seasons, then two genres as one token. + sourceTokens = if (itemDetail.type == "series") { + HeroMetadata.seriesSourceTokens(itemDetail) + } else { + HeroMetadata.movieSourceTokens(itemDetail) + }, + factsLine = if (itemDetail.type == "series") { + HeroMetadata.seriesFactsLine(itemDetail) + } else { + HeroMetadata.movieFactsLine(itemDetail) + }, + dominantColor = dominantColor, + directorText = detail.creditText(), + actions = { + RequestDetailActions(state = state, onPrimary = onPrimary, onModerate = onModerate, onCancel = onCancel) + }, ) } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = detail.title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - RequestMetaLine(mediaType = detail.mediaType, year = detail.year) - val tmdbRating = ExternalRatings.tmdb(detail.voteAverage) - detail.basicFacts(hasRating = tmdbRating != null)?.let { facts -> - Text( - text = facts, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - tmdbRating?.let { rating -> - RatingEntry( - rating = rating, - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - ) + val recommendations = state.recommendations + if (recommendations.isNotEmpty()) { + item(key = "more-like-this", contentType = "rail") { + Column( + modifier = Modifier.padding(bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + RequestsSectionHeader(title = "More like this") + RequestCardRail(items = recommendations, key = { "${it.mediaType}:${it.tmdbId}" }) { result -> + RequestMediaCard(item = result, onClick = { router.openResult(result) }) + } + } } - RequestActions( - detail = detail, - isSubmitting = isSubmitting, - onRequest = onRequest, - onLibraryItemClick = onLibraryItemClick, - ) } } } - notice?.let { - item { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } - error?.let { - item { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - OutlinedButton(onClick = onRetry) { - Text("Retry") - } - } - } + } +} + +/** The primary action, its hint or error, the status card, admin decisions, and Cancel. */ +@Composable +private fun RequestDetailActions( + state: RequestDetailUiState, + onPrimary: () -> Unit, + onModerate: (AdminRequestAction) -> Unit, + onCancel: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + PrimaryActionButton(state = state, onClick = onPrimary) + val message = state.actionErrorMessage + if (message != null) { + Text( + text = message, + fontSize = 12.sp, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } else if (state.showsRequestHint) { + Text( + text = "Not in your library yet. Your server admin reviews requests.", + fontSize = 12.sp, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } - if (detail.overview.isNotBlank()) { - item { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Overview", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = detail.overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + val progress = state.progress + if (progress != null && state.showsStatus) { + RequestStatusCard(progress = progress, record = state.displayedRecord) } - item { - if (detail.recommendations.isEmpty()) { - EmptyStateView( - title = "No recommendations", - subtitle = "Similar titles will appear here when the server returns them.", - icon = Icons.Outlined.Info, - ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Recommendations", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp), - ) - val recRowState = rememberLazyListState() - DeferImagePresentationWhileScrolling(recRowState) { - LazyRow( - state = recRowState, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), + val actions = state.moderationActions + if (actions.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + actions.forEach { action -> + val prominent = action != AdminRequestAction.Decline + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = 44.dp) + .clip(CircleShape) + .background(if (prominent) Color.White else RequestChromeSelectedFill) + .clickable { onModerate(action) }, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, ) { - items(detail.recommendations, key = { "${it.mediaType}-${it.tmdbId}" }) { item -> - RequestMediaCard( - item = item, - onClick = { - val libraryId = item.libraryContentId?.takeIf { it.isNotBlank() } - if (libraryId != null) onLibraryItemClick(libraryId) else onMediaClick(item) - }, - ) - } - } + Icon( + imageVector = when (action) { + AdminRequestAction.Approve -> Icons.Filled.Check + AdminRequestAction.Decline -> Icons.Filled.Close + AdminRequestAction.Retry -> Icons.Filled.Refresh + }, + contentDescription = null, + tint = if (prominent) Color.Black else SiloOnSurface, + modifier = Modifier.size(18.dp), + ) + Text( + text = action.name, + fontSize = 15.sp, + fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold, + color = if (prominent) Color.Black else SiloOnSurface, + ) } } } } + if (state.canCancel) { + Text( + text = "Cancel Request", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = RequestColors.Rose, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onCancel) + .padding(vertical = 12.dp), + ) + } } +} + +/** + * One button whose label and look follow [RequestPrimaryAction]: a white pill + * for Request and Open in Library, a quiet capsule with the status dot otherwise. + */ +@Composable +private fun PrimaryActionButton(state: RequestDetailUiState, onClick: () -> Unit) { + val action = state.primaryAction + if (action == RequestPrimaryAction.Loading) return + val interactive = action.isInteractive + val shape = CircleShape + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .clip(shape) + .background(if (interactive) Color.White else RequestChromeRestingFill) + .border(1.dp, if (interactive) Color.Transparent else RequestChromeRestingBorder, shape) + .clickable(enabled = interactive, onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + val content = if (interactive) Color.Black else SiloOnSurface + when (action) { + RequestPrimaryAction.Request -> Icon(Icons.Filled.Add, contentDescription = null, tint = content, modifier = Modifier.size(20.dp)) + RequestPrimaryAction.Submitting -> CircularProgressIndicator(color = SiloSecondaryText, strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + is RequestPrimaryAction.OpenInLibrary -> + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = content, modifier = Modifier.size(18.dp)) + is RequestPrimaryAction.Status -> + Box(modifier = Modifier.size(9.dp).background(action.state.tint.uiColor(), CircleShape)) + RequestPrimaryAction.Loading -> Unit + } + Text( + text = state.primaryActionTitle, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } +/** The four request steps with their timestamps, under the primary action. */ @Composable -private fun RequestActions( - detail: RequestMediaDetail, - isSubmitting: Boolean, - onRequest: () -> Unit, - onLibraryItemClick: (String) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - val libraryContentId = detail.libraryContentId?.takeIf { it.isNotBlank() } - when { - libraryContentId != null -> { - Button(onClick = { onLibraryItemClick(libraryContentId) }) { - Text("Open Library Item") - } - } - detail.request.requestable -> { - Button( - onClick = onRequest, - enabled = !isSubmitting, - ) { - Text(if (isSubmitting) "Requesting..." else "Request") - } +private fun RequestStatusCard(progress: RequestProgress, record: MediaRequest?) { + val shape = RoundedCornerShape(20.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.06f)) + .border(1.dp, Color.White.copy(alpha = 0.08f), shape) + .padding(16.dp), + ) { + Text( + text = "REQUEST STATUS", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.6.sp, + color = SiloOnSurface.copy(alpha = 0.55f), + modifier = Modifier.padding(bottom = 14.dp), + ) + RequestStep.entries.forEach { step -> + StepRow(step = step, progress = progress, record = record, isLast = step == RequestStep.Library) + } + } +} + +@Composable +private fun StepRow(step: RequestStep, progress: RequestProgress, record: MediaRequest?, isLast: Boolean) { + val isDone = step.ordinal < progress.completedSteps + val isCurrent = step == progress.currentStep + Row(modifier = Modifier.height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(modifier = Modifier.width(22.dp).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally) { + StepMarker(isDone = isDone, isCurrent = isCurrent, tint = progress.tint.uiColor()) + if (!isLast) { + Box( + modifier = Modifier + .width(2.dp) + .weight(1f) + .background(if (isDone) SiloOnSurface.copy(alpha = 0.6f) else Color.White.copy(alpha = 0.14f)), + ) } - else -> { - OutlinedButton( - onClick = {}, - enabled = false, - ) { - Text( - text = detail.request.status - ?.takeIf { it.isNotBlank() } - ?.replaceFirstChar { it.uppercase() } - ?: "Unavailable", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + } + Column( + modifier = Modifier.padding(bottom = if (isLast) 0.dp else 14.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stepTitle(step, isCurrent, progress), + fontSize = 15.sp, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, + color = if (isDone || isCurrent) SiloOnSurface else SiloOnSurface.copy(alpha = 0.45f), + ) + stepDetail(step, isDone, isCurrent, progress, record)?.let { + Text(text = it, fontSize = 12.sp, color = SiloSecondaryText) } } - RequestDetailStatus(detail) } } @Composable -private fun RequestDetailStatus(detail: RequestMediaDetail) { - val status = if (detail.request.reason.isBlank()) { - detail.availability.requestDisplayLabel() - } else { - detail.request.reasonMessage() - } ?: return - Text( - text = status, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) +private fun StepMarker(isDone: Boolean, isCurrent: Boolean, tint: Color) { + when { + isDone -> Box( + modifier = Modifier.size(22.dp).background(SiloOnSurface.copy(alpha = 0.9f), CircleShape), + contentAlignment = Alignment.Center, + ) { Icon(Icons.Filled.Check, contentDescription = null, tint = Color.Black, modifier = Modifier.size(13.dp)) } + isCurrent -> Box( + modifier = Modifier.size(22.dp).border(2.dp, tint, CircleShape), + contentAlignment = Alignment.Center, + ) { Box(modifier = Modifier.size(9.dp).background(tint, CircleShape)) } + else -> Box(modifier = Modifier.size(22.dp).border(2.dp, Color.White.copy(alpha = 0.18f), CircleShape)) + } } -/** - * Runtime, genres and seasons. With none of those, the availability label - * fills the line, unless a rating already shows below it. - */ -private fun RequestMediaDetail.basicFacts(hasRating: Boolean): String? { - val facts = buildList { - runtime?.let { add("${it}m") } - if (genres.isNotEmpty()) add(genres.take(2).joinToString(", ")) - if (numberOfSeasons != null) add("$numberOfSeasons seasons") +/** The current step reads as what's happening now: "Declined" instead of "Approved", "Queued" instead of "Downloading". */ +private fun stepTitle(step: RequestStep, isCurrent: Boolean, progress: RequestProgress): String { + if (!isCurrent) return step.title + return when (progress.display) { + is RequestDisplayState.NeedsAttention, RequestDisplayState.OnTheWay -> progress.shortLabel + else -> step.title } - return facts.joinToString(" • ").ifBlank { if (hasRating) null else availability.requestDisplayLabel() } +} + +private fun stepDetail(step: RequestStep, isDone: Boolean, isCurrent: Boolean, progress: RequestProgress, record: MediaRequest?): String? { + val reason = (progress.display as? RequestDisplayState.NeedsAttention)?.reason + return when (step) { + RequestStep.Requested -> RequestRowCopy.stamp(record?.createdAt) + RequestStep.Approval -> when { + isDone -> RequestRowCopy.stamp(record?.approvedAt) + isCurrent && progress.display is RequestDisplayState.NeedsAttention -> requestReasonCopy(reason) + isCurrent -> "Waiting for your server admin" + else -> null + } + RequestStep.Download -> when { + !isCurrent && !isDone -> null + isCurrent && progress.display is RequestDisplayState.NeedsAttention -> requestReasonCopy(reason) + else -> record?.let { RequestTargetSummary.text(it.targets) } + } + RequestStep.Library -> if (isCurrent) "Your library is picking it up" else null + } +} + +/** A catalog-shaped detail for the shared hero: the title, artwork, rating, and facts a request carries. */ +private fun RequestMediaDetail.toHeroDetail(): ItemDetail = ItemDetail( + contentId = "request:$mediaType:$tmdbId", + type = if (mediaType == RequestMediaType.Series) "series" else "movie", + title = title, + year = year ?: 0, + overview = overview.takeIf { it.isNotBlank() }, + contentRating = contentRating.takeIf { it.isNotBlank() }, + genres = genres, + runtime = runtime ?: 0, + seasonCount = numberOfSeasons, + posterUrl = requestPosterUrl(posterPath), + backdropUrl = requestBackdropUrl(backdropPath) ?: requestPosterUrl(posterPath), + ratings = listOfNotNull(ExternalRatings.tmdb(voteAverage)), +) + +private fun RequestMediaDetail.creditText(): String? = when { + director.isNotBlank() -> "Directed by $director" + creators.isNotEmpty() -> "Created by ${creators.take(2).joinToString(", ")}" + else -> null } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt new file mode 100644 index 0000000000..e40d1c9f68 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt @@ -0,0 +1,407 @@ +package org.siloserver.silo.android.ui.screens.requests + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.siloserver.silo.android.ui.theme.SiloBorder +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.android.ui.theme.SiloSurfaceContainer +import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated +import org.siloserver.silo.common.requests.RequestColors +import org.siloserver.silo.common.requests.RequestRowCopy +import org.siloserver.silo.common.requests.RequestStageTrack +import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestDisplayState +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.RequestRowActionPhase +import org.siloserver.silo.model.request.requestPosterUrl + +/** + * One request in a grouped list: poster, title, meta line, the stage track, + * and a status line. The trailing slot is Open (in the library), Retry (admin, + * failed), or a disclosure chevron. Swipe left or long-press to cancel while + * the request waits for approval. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MyRequestRow( + record: MediaRequest, + onOpen: () -> Unit, + modifier: Modifier = Modifier, + isBusy: Boolean = false, + onOpenInLibrary: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onRetry: (() -> Unit)? = null, + actionPhase: RequestRowActionPhase? = null, + actionError: String? = null, + shakeTrigger: Int = 0, + showDivider: Boolean = false, +) { + val progress = RequestProgress.of(record) + val latestCancel by rememberUpdatedState(onCancel) + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) latestCancel?.invoke() + // The row leaves when the list re-reads; it never dismisses itself. + false + }, + ) + var menuOpen by remember { mutableStateOf(false) } + SwipeToDismissBox( + state = dismissState, + modifier = modifier, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = onCancel != null && !isBusy, + backgroundContent = { + Box( + modifier = Modifier + .fillMaxSize() + .background(RequestColors.Rose) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) + Text("Cancel", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + } + } + }, + ) { + Column(modifier = Modifier.background(SiloSurfaceContainer)) { + if (showDivider) HorizontalDivider(modifier = Modifier.padding(start = 74.dp), color = SiloBorder.copy(alpha = 0.55f)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier + .weight(1f) + .alpha(if (isBusy || actionPhase != null) 0.45f else 1f) + .combinedClickable( + enabled = !isBusy, + onClick = onOpen, + onLongClick = if (onCancel != null) ({ menuOpen = true }) else null, + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RequestRowPoster(record.posterPath) + Column(modifier = Modifier.weight(1f)) { + Text( + text = record.title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = RequestRowCopy.meta(record, progress), + fontSize = 12.sp, + color = SiloSecondaryText, + maxLines = 1, + modifier = Modifier.padding(top = 2.dp), + ) + RequestStageTrack(progress = progress, modifier = Modifier.padding(top = 9.dp)) + AnimatedContent( + targetState = actionError, + transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, + label = "rowStatus", + modifier = Modifier.padding(top = 6.dp), + ) { error -> + if (error != null) { + RequestRowErrorLine(text = error) + } else { + RequestStatusLabel( + progress = progress, + text = RequestRowCopy.status(record, progress), + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + maxLines = 2, + ) + } + } + } + } + when { + onRetry != null -> RequestRowActionButton( + action = AdminRequestAction.Retry, + phase = actionPhase, + shakeTrigger = shakeTrigger, + enabled = !isBusy, + onClick = onRetry, + ) + onOpenInLibrary != null && progress.display == RequestDisplayState.InLibrary -> + RequestCapsuleButton(title = "Open", icon = Icons.AutoMirrored.Filled.OpenInNew, onClick = onOpenInLibrary) + else -> Text(text = "›", fontSize = 20.sp, color = SiloSecondaryText.copy(alpha = 0.7f)) + } + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Cancel Request", color = RequestColors.Rose) }, + leadingIcon = { Icon(Icons.Filled.Close, contentDescription = null, tint = RequestColors.Rose) }, + onClick = { + menuOpen = false + onCancel?.invoke() + }, + ) + } + } + } +} + +/** An admin's view of someone's pending request, with inline decisions. */ +@Composable +internal fun RequestApprovalRow( + record: MediaRequest, + onOpen: () -> Unit, + onApprove: () -> Unit, + onDecline: () -> Unit, + modifier: Modifier = Modifier, + isBusy: Boolean = false, + phase: RequestRowActionPhase? = null, + actionError: String? = null, + shakeTrigger: Int = 0, + showDivider: Boolean = false, +) { + Column(modifier = modifier.animateContentSize()) { + if (showDivider) HorizontalDivider(modifier = Modifier.padding(start = 74.dp), color = SiloBorder.copy(alpha = 0.55f)) + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(if (phase != null) 0.45f else 1f) + .rowClickable(enabled = !isBusy, onClick = onOpen), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RequestRowPoster(record.posterPath) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = record.title + (record.year?.takeIf { it > 0 }?.let { " ($it)" } ?: ""), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + RequestRowCopy.day(record.createdAt)?.let { + Text(text = "Requested $it", fontSize = 12.sp, color = SiloSecondaryText) + } + Text(text = RequestRowCopy.kindAndQuality(record), fontSize = 12.sp, color = SiloSecondaryText) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RequestRowActionButton( + action = AdminRequestAction.Approve, + phase = phase, + shakeTrigger = shakeTrigger, + style = RequestRowButtonStyle.Prominent, + enabled = !isBusy, + onClick = onApprove, + ) + RequestRowActionButton( + action = AdminRequestAction.Decline, + phase = phase, + shakeTrigger = shakeTrigger, + style = RequestRowButtonStyle.Wide, + enabled = !isBusy, + onClick = onDecline, + ) + } + if (actionError != null) RequestRowErrorLine(text = actionError) + } + } +} + +private fun Modifier.rowClickable(enabled: Boolean, onClick: () -> Unit): Modifier = + clip(RoundedCornerShape(8.dp)).combinedClickable(enabled = enabled, onClick = onClick) + +@Composable +internal fun RequestRowPoster(path: String?) { + Box( + modifier = Modifier + .size(width = 46.dp, height = 69.dp) + .clip(RoundedCornerShape(6.dp)) + .background(SiloSurfaceElevated), + ) { + requestPosterUrl(path)?.let { url -> + ThumbhashImage( + url = url, + thumbhash = null, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } + } +} + +@Composable +internal fun RequestCapsuleButton(title: String, icon: ImageVector, onClick: () -> Unit) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(RequestChromeSelectedFill) + .rowClickable(enabled = true, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(icon, contentDescription = null, tint = SiloOnSurface, modifier = Modifier.size(14.dp)) + Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + } +} + +internal enum class RequestRowButtonStyle { + /** Compact capsule at the row's trailing edge (Retry). */ + Trailing, + /** Full-width white capsule (Approve). */ + Prominent, + /** Full-width quiet capsule (Decline). */ + Wide, +} + +/** + * An admin action button that shows its own progress: Retry's arrow spins + * while the request is in flight (the others swap their icon for a spinner), + * the icon turns into a check when the server accepts it, and the button + * shakes when it fails. Buttons for the other actions on the row step back. + */ +@Composable +internal fun RowScope.RequestRowActionButton( + action: AdminRequestAction, + phase: RequestRowActionPhase?, + onClick: () -> Unit, + shakeTrigger: Int = 0, + style: RequestRowButtonStyle = RequestRowButtonStyle.Trailing, + enabled: Boolean = true, +) { + val isWorking = phase == RequestRowActionPhase.Working(action) + val isDone = phase == RequestRowActionPhase.Succeeded(action) + val isSidelined = phase != null && !isWorking && !isDone + val shake = remember { Animatable(0f) } + LaunchedEffect(shakeTrigger) { + if (shakeTrigger == 0) return@LaunchedEffect + for (offset in listOf(-7f, 6f, -4f, 0f)) shake.animateTo(offset, tween(70)) + } + val sidelineAlpha by animateFloatAsState(if (isSidelined) 0.35f else 1f, tween(250), label = "sideline") + val foreground = if (style == RequestRowButtonStyle.Prominent) Color.Black else SiloOnSurface + val background = if (style == RequestRowButtonStyle.Prominent) Color.White else RequestChromeSelectedFill + val title = when (action) { + AdminRequestAction.Approve -> if (isWorking) "Approving…" else if (isDone) "Approved" else "Approve" + AdminRequestAction.Decline -> if (isWorking) "Declining…" else if (isDone) "Declined" else "Decline" + AdminRequestAction.Retry -> if (isWorking) "Retrying…" else if (isDone) "Retried" else "Retry" + } + val idleIcon = when (action) { + AdminRequestAction.Approve -> Icons.Filled.Check + AdminRequestAction.Decline -> Icons.Filled.Close + AdminRequestAction.Retry -> Icons.Filled.Refresh + } + val wide = style != RequestRowButtonStyle.Trailing + Row( + modifier = (if (wide) Modifier.weight(1f).heightIn(min = 38.dp) else Modifier) + .graphicsLayer { + translationX = shake.value * density + alpha = sidelineAlpha + } + .clip(CircleShape) + .background(background) + .rowClickable(enabled = enabled && phase == null, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) { + when { + isDone -> Icon( + Icons.Filled.Check, + contentDescription = null, + tint = if (style == RequestRowButtonStyle.Prominent) Color.Black else RequestColors.Emerald, + modifier = Modifier.size(16.dp), + ) + isWorking && action == AdminRequestAction.Retry -> { + val spin = rememberInfiniteTransition(label = "retrySpin") + val angle by spin.animateFloat(0f, 360f, infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Restart), label = "angle") + Icon(idleIcon, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp).rotate(angle)) + } + isWorking -> CircularProgressIndicator(color = foreground, strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) + else -> Icon(idleIcon, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp)) + } + } + Text( + text = title, + fontSize = if (wide) 14.sp else 13.sp, + fontWeight = if (style == RequestRowButtonStyle.Prominent) FontWeight.Bold else FontWeight.SemiBold, + color = foreground, + maxLines = 1, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsLargeTitlePage.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsLargeTitlePage.kt new file mode 100644 index 0000000000..7c4a6e8f66 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsLargeTitlePage.kt @@ -0,0 +1,108 @@ +package org.siloserver.silo.android.ui.screens.requests + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.siloserver.silo.android.ui.theme.siloPageBackdrop +import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling + +/** Height of the pinned bar below the status bar. */ +private val BarHeight = 56.dp + +/** Scroll distance over which the large title hands off to the bar's title. */ +private val TitleHandoff = 44.dp + +/** + * A pushed page with a large title, as the Apple clients draw Requests: the + * title scrolls away under a pinned bar that keeps the back disc and gains the + * compact title. Pull to refresh reloads the page. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RequestsLargeTitlePage( + title: String, + onBackClick: () -> Unit, + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + /** Gap between list items; grouped-row pages pass zero and space their own sections. */ + itemSpacing: Dp = 24.dp, + actions: @Composable () -> Unit = {}, + content: LazyListScope.() -> Unit, +) { + val density = LocalDensity.current + val topInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + BarHeight + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val titleProgress by remember(listState, density) { + derivedStateOf { + if (listState.firstVisibleItemIndex > 0) { + 1f + } else { + val handoff = with(density) { TitleHandoff.toPx() } + (listState.firstVisibleItemScrollOffset / handoff).coerceIn(0f, 1f) + } + } + } + val pullState = rememberPullToRefreshState() + Box(modifier = modifier.fillMaxSize().siloPageBackdrop()) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + state = pullState, + modifier = Modifier.fillMaxSize(), + indicator = { + PullToRefreshDefaults.Indicator( + state = pullState, + isRefreshing = isRefreshing, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = topInset), + ) + }, + ) { + DeferImagePresentationWhileScrolling(listState) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = topInset, bottom = 24.dp + bottomInset), + verticalArrangement = Arrangement.spacedBy(itemSpacing), + ) { + item(key = "large-title", contentType = "large-title") { + RequestsLargeTitle(title = title) + } + content() + } + } + } + RequestsLargeTitleBar( + title = title, + titleProgress = titleProgress, + onBackClick = onBackClick, + actions = actions, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsScreen.kt index a990bf5021..6417a10829 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestsScreen.kt @@ -1,360 +1,223 @@ package org.siloserver.silo.android.ui.screens.requests +import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.Movie -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.HowToReg +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import org.siloserver.silo.android.ui.components.SiloTopBar +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.android.ui.components.EmptyStateView -import org.siloserver.silo.android.ui.components.LoadingIndicator -import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling +import org.siloserver.silo.android.ui.components.ErrorView +import org.siloserver.silo.android.ui.components.MediaGridDefaults +import org.siloserver.silo.android.ui.components.rememberShimmerProgress +import org.siloserver.silo.android.ui.components.skeleton +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.common.requests.RequestColors +import org.siloserver.silo.common.requests.RequestRouter +import org.siloserver.silo.common.requests.rememberRequestRouter import org.siloserver.silo.model.request.RequestMediaResult -import org.siloserver.silo.model.request.RequestMediaType -import org.siloserver.silo.viewmodel.RequestSearchViewModel +import org.siloserver.silo.model.request.RequestStatusTint +import org.siloserver.silo.viewmodel.RequestsUiState import org.siloserver.silo.viewmodel.RequestsViewModel -import org.koin.compose.viewmodel.koinViewModel -@OptIn(ExperimentalMaterial3Api::class) +/** + * The Requests hub, in the same grammar as the library pages: search TMDB to + * request, a status summary and the user's own requests one glance down, then + * the discover carousels. + */ @Composable fun RequestsScreen( onBackClick: () -> Unit, onMyRequestsClick: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, + onApprovalsClick: () -> Unit, + onRequestDetailClick: (mediaType: String, tmdbId: Int) -> Unit, onLibraryItemClick: (String) -> Unit, viewModel: RequestsViewModel = koinViewModel(), - searchViewModel: RequestSearchViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - val searchState by searchViewModel.uiState.collectAsState() - val hasSubmittedQuery = searchState.hasSubmittedQuery - val hasSearchResults = searchState.results.isNotEmpty() - val showDiscoverRows = !hasSubmittedQuery || - (searchState.error != null && state.sections.any { it.results.isNotEmpty() }) + val router = rememberRequestRouter(koinInject(), onLibraryItemClick, onRequestDetailClick) - Scaffold( - topBar = { - SiloTopBar( - title = "Requests", - onBackClick = onBackClick, - actions = { - TextButton(onClick = onMyRequestsClick) { - Text("My Requests") - } - }, + RequestsLargeTitlePage( + title = "Requests", + onBackClick = onBackClick, + isRefreshing = state.isRefreshing, + onRefresh = viewModel::refresh, + ) { + item(key = "search", contentType = "search") { + RequestsSearchField( + query = state.query, + onQueryChange = viewModel::onQueryChanged, + onSearch = viewModel::submitSearch, ) - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> + } when { - state.isLoading && state.sections.isEmpty() -> { - LoadingIndicator( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) - } - state.error != null && state.sections.isEmpty() && !state.isEnabled && !hasSubmittedQuery -> { - RequestErrorState( - message = state.error ?: "Requests are unavailable.", - onRetry = viewModel::load, - modifier = Modifier.padding(padding), - ) + !state.isShowingDiscover -> searchResults(state, router::openResult) + state.error != null -> item(key = "error") { + ErrorView(message = state.error.orEmpty(), onRetry = viewModel::load, modifier = Modifier.padding(top = 60.dp)) } - else -> { - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - val feedState = rememberLazyListState() - DeferImagePresentationWhileScrolling(feedState) { - LazyColumn( - state = feedState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - item(contentType = "request-search") { - RequestSearchPanel( - query = searchState.query, - selectedType = searchState.mediaType, - isLoading = searchState.isLoading, - error = searchState.error, - totalResults = searchState.totalResults, - results = searchState.results, - hasSubmittedQuery = hasSubmittedQuery, - onQueryChange = searchViewModel::onQueryChanged, - onTypeChange = { type -> - searchViewModel.onMediaTypeChanged(type) - if (searchState.query.isNotBlank()) { - searchViewModel.search() - } - }, - onSearch = { searchViewModel.search() }, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) - } - - state.error?.let { error -> - item(contentType = "request-error") { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - if (state.sections.isEmpty() && !hasSubmittedQuery) { - item(contentType = "request-empty") { - EmptyStateView( - title = "No request suggestions yet", - subtitle = "Search for movies or series to request.", - icon = Icons.Outlined.Movie, - ) - } - } else if (showDiscoverRows) { - items( - state.sections, - key = { it.key }, - contentType = { "request-section" }, - ) { section -> - RequestSectionRow( - title = section.title, - items = section.results, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) - } - } - } - } + state.isLoading -> item(key = "loading") { + val progress = rememberShimmerProgress() + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + RequestRailSkeleton(progress, title = "Your requests") + RequestRailSkeleton(progress) } } + state.myRequests.isEmpty() && state.sections.isEmpty() -> item(key = "empty") { + EmptyStateView( + title = "Nothing here yet", + subtitle = "Search for a movie or series to request it", + icon = Icons.Outlined.AutoAwesome, + modifier = Modifier.padding(top = 80.dp), + ) + } + else -> discover(state, router, onMyRequestsClick, onApprovalsClick) } } } -@Composable -private fun RequestSearchPanel( - query: String, - selectedType: String?, - isLoading: Boolean, - error: String?, - totalResults: Int, - results: List, - hasSubmittedQuery: Boolean, - onQueryChange: (String) -> Unit, - onTypeChange: (String?) -> Unit, - onSearch: () -> Unit, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, +private fun LazyListScope.discover( + state: RequestsUiState, + router: RequestRouter, + onMyRequestsClick: () -> Unit, + onApprovalsClick: () -> Unit, ) { - val summary = requestSearchSummary( - query = query, - totalResults = totalResults, - isLoading = isLoading, - hasSubmittedQuery = hasSubmittedQuery, - hasResults = results.isNotEmpty(), - error = error, - ) - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "Search", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Movie or series title") }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - RequestFilterChip( - selected = selectedType == RequestMediaType.All, - label = "All", - onClick = { onTypeChange(RequestMediaType.All) }, - ) - RequestFilterChip( - selected = selectedType == RequestMediaType.Movie, - label = "Movies", - onClick = { onTypeChange(RequestMediaType.Movie) }, - ) - RequestFilterChip( - selected = selectedType == RequestMediaType.Series, - label = "Series", - onClick = { onTypeChange(RequestMediaType.Series) }, - ) - } - Button(onClick = onSearch, enabled = !isLoading) { - Text(if (isLoading) "Searching..." else "Search") - } - error?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + val moving = state.inProgressCount + state.needsAttentionCount + if (moving > 0 || state.pendingApprovals > 0) { + item(key = "summary", contentType = "summary") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (moving > 0) { + RequestSummaryCard(title = summaryTitle(state), parts = summaryParts(state), onClick = onMyRequestsClick) + } + if (state.pendingApprovals > 0) { + RequestSummaryCard( + title = when { + state.morePendingApprovals -> "${state.pendingApprovals}+ requests need your approval" + state.pendingApprovals == 1 -> "1 request needs your approval" + else -> "${state.pendingApprovals} requests need your approval" + }, + leadingIcon = Icons.Outlined.HowToReg, + leadingTint = RequestColors.Amber, + onClick = onApprovalsClick, + ) + } + } } - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + } + if (state.myRequests.isNotEmpty()) { + item(key = "mine", contentType = "rail") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + RequestsSectionHeader(title = "Your requests", trailing = "See all", onTrailingClick = onMyRequestsClick) + RequestCardRail(items = state.myRequests, key = { it.id }) { record -> + RequestRecordCard(record = record, onClick = { router.openRecord(record) }) + } + } } - if (results.isNotEmpty()) { - RequestMediaRow( - items = results, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) + } + state.sections.forEachIndexed { index, section -> + item(key = "discover:${section.key}", contentType = "rail") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + RequestsSectionHeader(title = section.title, label = if (index == 0) "Discover" else null) + RequestCardRail(items = section.results, key = { "${it.mediaType}:${it.tmdbId}" }) { result -> + RequestMediaCard(item = result, onClick = { router.openResult(result) }) + } + } } } } -@Composable -private fun RequestSectionRow( - title: String, - items: List, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, -) { - if (items.isEmpty()) return - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - RequestMediaRow( - items = items, - onMediaClick = onMediaClick, - onLibraryItemClick = onLibraryItemClick, - ) +private fun summaryTitle(state: RequestsUiState): String { + val count = state.inProgressCount + return when (count) { + 0 -> "Your requests need you" + 1 -> "1 request in progress" + else -> "$count requests in progress" } } -@Composable -private fun RequestMediaRow( - items: List, - onMediaClick: (RequestMediaResult) -> Unit, - onLibraryItemClick: (String) -> Unit, -) { - val rowState = rememberLazyListState() - DeferImagePresentationWhileScrolling(rowState) { - LazyRow( - state = rowState, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(end = 16.dp), - ) { - items( - items, - key = { "${it.mediaType}-${it.tmdbId}" }, - contentType = { item -> item.mediaType }, - ) { item -> - RequestMediaCard( - item = item, - onClick = { - val libraryId = item.libraryContentId?.takeIf { it.isNotBlank() } - if (libraryId != null) { - onLibraryItemClick(libraryId) - } else { - onMediaClick(item) - } - }, +private fun summaryParts(state: RequestsUiState): List> = buildList { + if (state.onTheWayCount > 0) add(RequestStatusTint.Sky to "${state.onTheWayCount} on the way") + if (state.pendingCount > 0) add(RequestStatusTint.Amber to "${state.pendingCount} pending") + if (state.needsAttentionCount > 0) add(RequestStatusTint.Rose to "${state.needsAttentionCount} need you") +} + +/** Library search's grid: adaptive columns from the card presentation, placeholders in the same cells while searching. */ +private fun LazyListScope.searchResults(state: RequestsUiState, onOpen: (RequestMediaResult) -> Unit) { + when { + state.isSearching && state.searchResults.isEmpty() -> item(key = "search-loading") { + SearchGrid(count = 9) { width, _ -> + val progress = rememberShimmerProgress() + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Box( + modifier = Modifier.width(width).aspectRatio(RequestPosterAspect).skeleton(progress), + ) + Box( + modifier = Modifier.width(width * 0.7f).height(10.dp).skeleton(progress, RoundedCornerShape(3.dp)), + ) + } + } + } + state.hasSearched && state.searchResults.isEmpty() -> item(key = "search-empty") { + EmptyStateView( + title = "No matches", + subtitle = "Nothing on TMDB matched that search", + icon = Icons.Outlined.Search, + modifier = Modifier.padding(top = 80.dp), ) } - } + else -> { + item(key = "search-count") { + Text( + text = "${state.searchTotal} result${if (state.searchTotal == 1) "" else "s"}", + fontSize = 12.sp, + color = SiloSecondaryText, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + item(key = "search-grid") { + SearchGrid(count = state.searchResults.size) { width, index -> + val result = state.searchResults[index] + RequestMediaCard(item = result, width = width, onClick = { onOpen(result) }) + } + } + } } } @Composable -private fun RequestErrorState( - message: String, - onRetry: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Outlined.ErrorOutline, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = message, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(20.dp)) - Button(onClick = onRetry) { - Text("Retry") +private fun SearchGrid(count: Int, cell: @Composable (width: Dp, index: Int) -> Unit) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val columns = requestGridColumns(maxWidth, MediaGridDefaults.scaledPosterGridMinWidth) + val spacing = MediaGridDefaults.PosterGridHorizontalSpacing + val cellWidth = (maxWidth - 32.dp - spacing * (columns - 1)) / columns + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridVerticalSpacing), + ) { + (0 until count).chunked(columns).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { + row.forEach { index -> cell(cellWidth, index) } + } + } } } } - -private fun requestSearchSummary( - query: String, - totalResults: Int, - isLoading: Boolean, - hasSubmittedQuery: Boolean, - hasResults: Boolean, - error: String?, -): String? = when { - query.isBlank() -> "Search movies and series to request them." - isLoading -> "Searching..." - error != null -> null - hasSubmittedQuery && !hasResults -> "No matches for \"$query\"." - hasResults && totalResults == 1 -> "1 result" - hasResults -> "$totalResults results" - else -> null -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/RequestSearchSection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/RequestSearchSection.kt index 9c90a1d983..9f57ba1f7d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/RequestSearchSection.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/RequestSearchSection.kt @@ -21,8 +21,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.android.ui.screens.requests.RequestMediaCard +import org.siloserver.silo.common.requests.rememberRequestRouter import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling import org.siloserver.silo.model.request.RequestMediaResult import org.siloserver.silo.model.request.RequestMediaType @@ -33,12 +35,13 @@ fun RequestSearchSection( query: String, selectedMediaType: MobileSearchMediaType, requestsEnabled: Boolean, - onRequestMediaClick: (RequestMediaResult) -> Unit, + onRequestMediaClick: (mediaType: String, tmdbId: Int) -> Unit, onRequestLibraryItemClick: (String) -> Unit, modifier: Modifier = Modifier, viewModel: RequestSearchViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + val router = rememberRequestRouter(koinInject(), onRequestLibraryItemClick, onRequestMediaClick) val trimmedQuery = query.trim() val requestMediaType = selectedMediaType.toRequestMediaType() val visibleResults = state.results.filter { result -> @@ -92,14 +95,7 @@ fun RequestSearchSection( ) { item -> RequestMediaCard( item = item, - onClick = { - val libraryId = item.libraryContentId?.takeIf { it.isNotBlank() } - if (libraryId != null) { - onRequestLibraryItemClick(libraryId) - } else { - onRequestMediaClick(item) - } - }, + onClick = { router.openResult(item) }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt index 5bd1c8800b..9a990823cc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt @@ -38,7 +38,6 @@ import org.siloserver.silo.model.navigation.MediaMode import org.siloserver.silo.model.navigation.MediaModeCapabilities import org.siloserver.silo.model.navigation.mobileMediaModeCapabilities import org.siloserver.silo.model.feature.RequestsFeatureStore -import org.siloserver.silo.model.request.RequestMediaResult import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.PersonalDataRepository import org.koin.compose.koinInject @@ -57,7 +56,7 @@ import org.koin.compose.koinInject fun SearchScreen( onItemClick: (String) -> Unit, onPersonClick: (Long) -> Unit, - onRequestMediaClick: (RequestMediaResult) -> Unit, + onRequestMediaClick: (mediaType: String, tmdbId: Int) -> Unit, onRequestLibraryItemClick: (String) -> Unit, onBackClick: (() -> Unit)? = null, viewModel: SearchViewModel, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index c749276dbc..22737609bc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -389,7 +389,7 @@ val androidTvModule = module { viewModel { TvServerListViewModel(get(), get(), get()) } viewModel { params -> - org.siloserver.silo.viewmodel.RequestDetailViewModel(get(), params.get(), params.get()) + org.siloserver.silo.viewmodel.RequestDetailViewModel(get(), params.get(), params.get(), featureStore = get()) } viewModel { params -> val args = params.get>() @@ -404,10 +404,16 @@ val androidTvModule = module { viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } viewModel { org.siloserver.silo.tv.ui.screens.home.TvUpcomingViewModel(get()) } viewModel { RecommendationsViewModel(get()) } - viewModel { RequestsViewModel(get()) } + // The Requests page reads the approval queue itself, so the hub doesn't + // count it; it loads its view models when it composes. + viewModel { params -> + RequestsViewModel(get(), get(), countsPendingApprovals = false, loadOnInit = params.getOrNull() ?: true) + } viewModel { RequestSearchViewModel(get()) } - viewModel { MyRequestsViewModel(get()) } - viewModel { org.siloserver.silo.tv.ui.screens.requests.TvRequestsViewModel(get()) } + viewModel { params -> MyRequestsViewModel(get(), loadOnInit = params.getOrNull() ?: true) } + viewModel { params -> + org.siloserver.silo.viewmodel.RequestApprovalsViewModel(get(), loadOnInit = params.getOrNull() ?: true) + } // Platform supplies "today" and the IANA timezone; the shared ViewModel's // week math stays deterministic in commonTest (no Clock.System default). viewModel { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt index 3e58376999..3f9743e0e0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt @@ -60,6 +60,9 @@ fun TvFocusMarquee( topPadding: androidx.compose.ui.unit.Dp = 0.dp, bottomPadding: androidx.compose.ui.unit.Dp = 0.dp, animateTransition: Boolean = true, + /** An extra line under the block for pages that preview more than catalog + * items (the Requests page's status and stage track). */ + footer: (@Composable (TvMarqueeContent) -> Unit)? = null, ) { Box( modifier = modifier @@ -100,12 +103,13 @@ fun TvFocusMarquee( TvMarqueeBlock( content = value, detailLine = detailLine.takeIf { value.id == content?.id }, + footer = footer, ) } } } } else if (content != null) { - TvMarqueeBlock(content = content, detailLine = detailLine) + TvMarqueeBlock(content = content, detailLine = detailLine, footer = footer) } } } @@ -114,6 +118,7 @@ fun TvFocusMarquee( private fun TvMarqueeBlock( content: TvMarqueeContent, detailLine: String?, + footer: (@Composable (TvMarqueeContent) -> Unit)? = null, ) { // tvOS parity (TVFocusMarquee): when the text-fallback title wraps to two // lines the synopsis drops to one, keeping the bottom-anchored block's @@ -269,6 +274,8 @@ private fun TvMarqueeBlock( badges.forEach { badge -> MarqueeBadge(badge.uppercase()) } } } + + footer?.invoke(content) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt index 2154e08b7d..c9bc9b8be2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt @@ -1029,16 +1029,16 @@ private const val HeroLogoPreloadHeightPx = 200 private const val HeroFocusPrefetchRadius = 2 /** tvOS MediaRow cardSpacing 40pt maps to 20dp. */ -private val TvSkylineItemSpacing = 20.dp +internal val TvSkylineItemSpacing = 20.dp /** A little breathing room between adjacent Home sections. */ -private val TvSkylineRowPreviewSpacing = 14.dp +internal val TvSkylineRowPreviewSpacing = 14.dp /** tvOS rowBandCardVerticalPadding 14pt maps to 7dp. */ -private val TvSkylineRowCardVerticalPadding = 7.dp +internal val TvSkylineRowCardVerticalPadding = 7.dp /** tvOS rowBandBottomInset 20pt maps to 10dp. */ -private val TvSkylineRowBandBottomInset = 10.dp +internal val TvSkylineRowBandBottomInset = 10.dp // The band is sized from what its first row actually needs — section header, // card at the scaled dense-poster height, and whichever caption lines the @@ -1053,9 +1053,9 @@ private val TvSkylineCaptionMetadataHeight = 18.dp // TvMediaCard: 18sp year lin private val TvSkylineRowPreviewPeek = 24.dp // sliver of the next section's header /** The band never pushes the marquee below ~40% of the screen. */ -private const val TvSkylineRowBandMaxFraction = 0.58f +internal const val TvSkylineRowBandMaxFraction = 0.58f -private fun tvSkylineRowBandHeight(presentation: CardPresentation): Dp { +internal fun tvSkylineRowBandHeight(presentation: CardPresentation): Dp { val cardHeight = RowDimens.DensePosterWidth * presentation.posterSize.posterScale * 3f / 2f val captionHeight = when { !presentation.caption.showsTitle -> 0.dp @@ -1069,13 +1069,13 @@ private fun tvSkylineRowBandHeight(presentation: CardPresentation): Dp { } /** Gap between the marquee block and the top of the row band. */ -private val TvSkylineMarqueeBottomGap = 4.dp +internal val TvSkylineMarqueeBottomGap = 4.dp // Row-band relocation requests are close to row-sized; horizontal card rails // have much wider viewports and must still use the smooth scroll distance. private const val TvSkylineVerticalContainerRatio = 3f -private val TvSkylineBringIntoViewSpec: BringIntoViewSpec = object : BringIntoViewSpec { +internal val TvSkylineBringIntoViewSpec: BringIntoViewSpec = object : BringIntoViewSpec { override val scrollAnimationSpec: AnimationSpec = tween( durationMillis = 520, easing = FastOutSlowInEasing, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt index 2ff3f62594..b9823247cc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt @@ -307,7 +307,6 @@ sealed class TvMainRoute(val route: String) { data object Music : TvMainRoute("main/music") data object Audiobooks : TvMainRoute("main/audiobooks") data object Requests : TvMainRoute("main/requests") - data object MyRequests : TvMainRoute("main/requests/mine") data object Settings : TvMainRoute("main/settings") data object Collections : TvMainRoute("main/collections") diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt index 0a361aed0f..61a45486b3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.LocalTextStyle @@ -80,11 +81,14 @@ internal fun TvDetailHero( // Optional description-translation affordance (Apple tvOS parity), // rendered as its own focus stop directly under the synopsis. translation: (@Composable () -> Unit)? = null, + /** Grows the hero, not the artwork, for a taller summary strip (request + * detail's status and stage track) so the actions stay inside its clip. */ + extraHeight: Dp = 0.dp, ) { // tvOS 690pt on a 1080pt canvas. Android TV's 1920×1080 emulator reports // a 960×540dp layout canvas, so the same fraction produces 345dp. val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val heroHeight = screenHeight * HERO_HEIGHT_FRACTION + val heroHeight = screenHeight * HERO_HEIGHT_FRACTION + extraHeight BoxWithConstraints( modifier = modifier diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvMyRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvMyRequestsScreen.kt deleted file mode 100644 index aebef9d8a1..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvMyRequestsScreen.kt +++ /dev/null @@ -1,261 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.requests - -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.Refresh -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.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import org.siloserver.silo.tv.ui.focus.rememberTvFlatReturnRestoration -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.siloserver.silo.model.request.canCancel -import org.siloserver.silo.tv.ui.components.TvErrorScreen -import org.siloserver.silo.tv.ui.components.TvLoadingScreen -import org.siloserver.silo.tv.ui.shell.TvTopMenuLayout -import org.siloserver.silo.tv.ui.theme.SiloBlue -import org.siloserver.silo.tv.ui.theme.Spacing -import org.siloserver.silo.tv.ui.theme.sectionEyebrow -import org.siloserver.silo.viewmodel.MyRequestsViewModel -import org.koin.compose.viewmodel.koinViewModel - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvMyRequestsScreen( - onOpenLibraryItem: (contentId: String) -> Unit = {}, - onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit = { _, _ -> }, - onInitialContentFocus: () -> Unit = {}, - viewModel: MyRequestsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val visibleRequests = state.requests.filterTvMediaRequests() - val restoreItemFocusRequester = remember { FocusRequester() } - var attachedRequesterId by remember { mutableStateOf(null) } - val listState = rememberLazyListState() - - // Rows are identified by the REQUEST, not by what they open. A row with a - // library item opens that item while every other row opens the request - // detail, so two rows can share a navigation target; keying restoration on - // it would send focus to whichever came first. request.id is the row. - // - // Projected from the FILTERED list, because that is what is rendered — the - // view model's unfiltered list would put every index in a different - // coordinate space from the rows these indices address. - // - // Nothing here paginates, so hasMore is false and the page hunt never runs; - // the restoration is purely resolve, scroll, confirm. - val restoration = rememberTvFlatReturnRestoration( - itemIds = visibleRequests.map { it.id }, - hasMore = false, - isLoadingMore = false, - // Refresh here is a button rather than a resume hook, so it cannot - // collide with entry the way the personal lists' does — but it still - // REPLACES the list, and a viewer who presses it and opens a row - // before it lands would otherwise restore against the outgoing one. - isReplacingContent = state.isRefreshing, - errorMessage = state.error, - surfaceKey = "my-requests", - onLoadMore = {}, - scrollToItem = { itemIndex -> listState.scrollToItem(itemIndex + requestsHeaderSlots(state.error)) }, - requestFocus = restoreItemFocusRequester::requestFocus, - onRestored = onInitialContentFocus, - ) - - // No separate first-entry path. On a fresh arrival the restoration already - // targets row zero, so a second requester would race it — and worse, the - // one that ran first was UNATTACHED whenever restoration owned row zero, - // meaning it reported a content handoff it had not actually made. One - // claimant, and it only reports once focus is confirmed. - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - MyRequestsHeader( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - ) - when { - state.isLoading && state.requests.isEmpty() -> TvLoadingScreen() - state.error != null && state.requests.isEmpty() -> TvErrorScreen( - message = state.error ?: "Failed to load your requests.", - onRetry = viewModel::load, - ) - visibleRequests.isEmpty() -> EmptyMyRequests() - else -> LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .focusGroup(), - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - bottom = 56.dp, - ), - ) { - state.error?.let { error -> - item { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - itemsIndexed(visibleRequests, key = { _, request -> request.id }) { index, request -> - if (index == restoration.requesterItemIndex) { - DisposableEffect(request.id) { - attachedRequesterId = request.id - restoration.onRequesterAttached(request.id) - onDispose { - // Only if this row is still the owner. When the - // requester moves, the new row can attach - // before the old one disposes, and an - // unconditional clear would erase the live - // attachment and lose the restore. - if (attachedRequesterId == request.id) { - attachedRequesterId = null - restoration.onRequesterAttached(null) - } - } - } - } - TvRequestListCard( - request = request, - onClick = { - restoration.onItemClicked(itemId = request.id, index = index) - // In-library items open library detail; everything else - // opens the request detail (phone parity — rows are always - // actionable, not only when a library item exists). - val contentId = request.libraryContentId - if (contentId != null) onOpenLibraryItem(contentId) - else onOpenRequestDetail(request.mediaType, request.tmdbId) - }, - focusRequester = restoreItemFocusRequester - .takeIf { index == restoration.requesterItemIndex }, - // hasFocus, not isFocused: the card's own Surface owns - // focus below this modifier, so isFocused never fires - // here and the restoration could never confirm. - modifier = Modifier.onFocusChanged { - if (it.hasFocus) { - restoration.onItemFocused(request.id, index) - } else { - restoration.onItemFocusLost(request.id) - } - }, - trailing = { - if (request.canCancel()) { - TvRequestActionPill( - label = if (state.actionInFlightId == request.id) "Canceling" else "Cancel", - icon = Icons.Filled.Cancel, - onClick = { viewModel.cancel(request.id) }, - enabled = state.actionInFlightId != request.id, - ) - } - }, - ) - } - item { Spacer(modifier = Modifier.height(8.dp)) } - } - } - } -} - -@Composable -private fun MyRequestsHeader( - isRefreshing: Boolean, - onRefresh: () -> Unit, -) { - Column( - modifier = Modifier.padding( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = TvTopMenuLayout.contentTopInset, - bottom = Spacing.lg, - ), - verticalArrangement = Arrangement.spacedBy(Spacing.md), - ) { - Text( - text = "REQUESTS", - style = sectionEyebrow, - color = SiloBlue.copy(alpha = 0.92f), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "My Requests", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - ) - TvRequestActionPill( - label = if (isRefreshing) "Refreshing" else "Refresh", - icon = Icons.Filled.Refresh, - onClick = onRefresh, - enabled = !isRefreshing, - ) - } - } -} - -@Composable -private fun EmptyMyRequests() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - androidx.tv.material3.Icon( - imageVector = Icons.Filled.Inbox, - contentDescription = null, - tint = Color.White.copy(alpha = 0.62f), - modifier = Modifier.height(30.dp), - ) - Text( - text = "No requests yet", - style = MaterialTheme.typography.headlineSmall, - color = Color.White, - ) - Text( - text = "Requested movies and series will appear here.", - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.68f), - ) - } - } -} - -/** The error banner, when shown, is one list slot ahead of the request rows. */ -private fun requestsHeaderSlots(error: String?): Int = if (error != null) 1 else 0 diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestComponents.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestComponents.kt index c6e9872913..ee53a05647 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestComponents.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestComponents.kt @@ -1,307 +1,249 @@ package org.siloserver.silo.tv.ui.screens.requests import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PriorityHigh +import androidx.compose.material.icons.filled.Schedule import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.siloserver.silo.common.cards.LocalCardPresentation +import org.siloserver.silo.common.requests.color import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.request.MediaRequest -import org.siloserver.silo.model.request.RequestAvailability +import org.siloserver.silo.model.request.RequestDisplayState import org.siloserver.silo.model.request.RequestMediaResult -import org.siloserver.silo.model.request.RequestMediaType -import org.siloserver.silo.model.request.RequestOutcome -import org.siloserver.silo.model.request.RequestStatus -import org.siloserver.silo.model.request.badgeStatus -import org.siloserver.silo.model.request.requestDisplayLabel +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.RequestStatusTint import org.siloserver.silo.model.request.requestPosterUrl -import org.siloserver.silo.model.request.targetSummary -import org.siloserver.silo.tv.ui.components.TvCardWidth -import org.siloserver.silo.tv.ui.theme.SiloBlue -import org.siloserver.silo.tv.ui.theme.DarkOnPrimary import org.siloserver.silo.tv.ui.theme.RowDimens -import org.siloserver.silo.tv.ui.theme.cardScaled +import org.siloserver.silo.tv.ui.theme.SiloSecondaryText import org.siloserver.silo.tv.ui.theme.siloCardDefaults -@OptIn(ExperimentalTvMaterial3Api::class) +internal fun RequestStatusTint.tvColor(): Color = color(neutral = SiloSecondaryText) + +/** Dense Skyline poster width, scaled by the card presentation preference. */ +@Composable +internal fun tvRequestCardWidth(): Dp = RowDimens.DensePosterWidth * LocalCardPresentation.current.posterSize.posterScale + +/** + * Poster card for a TMDB search/discover result in the Skyline rows' grammar + * (`TvMediaCard`): a title already requested or in the library gets a small + * corner badge, and the caption keeps its year. + */ @Composable fun TvRequestCard( result: RequestMediaResult, onClick: () -> Unit, modifier: Modifier = Modifier, + /** Applied to the focusable card itself rather than the card-and-caption column. */ cardModifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, focusRequester: FocusRequester? = null, + width: Dp = tvRequestCardWidth(), ) { - val cardShape = RoundedCornerShape(8.dp) - val cardFocus = siloCardDefaults(shape = cardShape) - val caption = LocalCardPresentation.current.caption + TvRequestPosterCard( + title = result.title, + year = result.year, + posterPath = result.posterPath, + progress = RequestProgress.of(result.availability, result.request), + showsStatusInCaption = false, + onClick = onClick, + onLongClick = onLongClick, + focusRequester = focusRequester, + modifier = modifier, + cardModifier = cardModifier, + width = width, + ) +} - Column(modifier = modifier.width(TvCardWidth.cardScaled())) { +/** Poster card for a request record (the user's own, or one an admin decides on): its status is the caption. */ +@Composable +fun TvRequestRecordCard( + record: MediaRequest, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + focusRequester: FocusRequester? = null, + width: Dp = tvRequestCardWidth(), +) { + TvRequestPosterCard( + title = record.title, + year = record.year, + posterPath = record.posterPath, + progress = RequestProgress.of(record), + showsStatusInCaption = true, + onClick = onClick, + onLongClick = onLongClick, + focusRequester = focusRequester, + modifier = modifier, + width = width, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvRequestPosterCard( + title: String, + year: Int?, + posterPath: String?, + progress: RequestProgress?, + showsStatusInCaption: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + focusRequester: FocusRequester?, + modifier: Modifier, + width: Dp, + cardModifier: Modifier = Modifier, +) { + val caption = LocalCardPresentation.current.caption + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(8.dp) + val focus = siloCardDefaults(shape = shape) + val label = listOfNotNull(title, year?.takeIf { it > 0 }?.toString(), progress?.shortLabel).joinToString(", ") + Column(modifier = modifier.width(width)) { Card( onClick = onClick, - shape = CardDefaults.shape(shape = cardShape), - scale = cardFocus.scale, - border = cardFocus.border, - glow = cardFocus.glow, - modifier = Modifier - .then(cardModifier) + onLongClick = onLongClick, + interactionSource = interactionSource, + shape = CardDefaults.shape(shape = shape), + scale = focus.scale, + border = focus.border, + glow = focus.glow, + modifier = cardModifier .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .size(RowDimens.PosterWidth.cardScaled(), RowDimens.PosterHeight.cardScaled()), + .size(width, width * 1.5f) + .semantics { contentDescription = label }, ) { - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.06f))) { ThumbhashImage( - url = requestPosterUrl(result.posterPath), + url = requestPosterUrl(posterPath), thumbhash = null, - contentDescription = result.title, + contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - TvRequestStatusChip( - status = result.badgeStatus(), - outcome = null, - modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp), - ) + if (!showsStatusInCaption && progress != null) { + val badgeSize = (width * RowDimens.WatchedBadgeSizeFraction) + .coerceIn(RowDimens.WatchedBadgeMinSize, RowDimens.WatchedBadgeMaxSize) + val badgePadding = (width * RowDimens.WatchedBadgePaddingFraction) + .coerceIn(RowDimens.WatchedBadgeMinPadding, RowDimens.WatchedBadgeMaxPadding) + TvRequestPosterBadge( + state = progress.display, + size = badgeSize, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(badgePadding), + ) + } } } if (caption.showsTitle) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(11.dp)) Text( - text = result.title, - style = MaterialTheme.typography.titleSmall, - color = Color.White.copy(alpha = 0.82f), - maxLines = 2, + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 15.5.sp, lineHeight = 18.5.sp), + color = if (isFocused) Color.White else Color.White.copy(alpha = 0.78f), + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth(), ) - if (caption.showsMetadata) { - RequestMetaLine(mediaType = result.mediaType, year = result.year) - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvRequestListCard( - request: MediaRequest, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - trailing: @Composable (() -> Unit)? = null, - focusRequester: FocusRequester? = null, -) { - val shape = RoundedCornerShape(10.dp) - Surface( - onClick = onClick ?: {}, - enabled = onClick != null, - shape = ClickableSurfaceDefaults.shape(shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.06f), - contentColor = Color.White, - focusedContainerColor = Color.White.copy(alpha = 0.16f), - focusedContentColor = Color.White, - pressedContainerColor = Color.White.copy(alpha = 0.18f), - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f), - modifier = modifier - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - Box( - modifier = Modifier - .width(72.dp) - .aspectRatio(2f / 3f) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)), - ) { - ThumbhashImage( - url = requestPosterUrl(request.posterPath), - thumbhash = null, - contentDescription = request.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + when { + showsStatusInCaption && progress != null -> TvRequestStatusLabel( + progress = progress, + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.70f), ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(7.dp), - ) { - Text( - text = request.title, - style = MaterialTheme.typography.titleMedium, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + caption.showsMetadata && year != null && year > 0 -> Text( + text = year.toString(), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.70f), ) - RequestMetaLine(mediaType = request.mediaType, year = request.year) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TvRequestStatusChip(status = request.status, outcome = null) - TvRequestStatusChip(status = request.outcome, outcome = request.outcome) - } - request.targetSummary()?.let { summary -> - Text( - text = summary, - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.70f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - request.lastError.takeIf { it.isNotBlank() }?.let { error -> - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } } - trailing?.invoke() } } } +/** A white glyph on a filled status disc, in the watched badge's geometry. */ @Composable -fun TvRequestStatusChip( - status: String, - outcome: String?, - modifier: Modifier = Modifier, -) { - val normalized = status.ifBlank { outcome.orEmpty() } - val (background, foreground) = when (normalized.lowercase()) { - RequestAvailability.Available, RequestStatus.Completed -> SiloBlue to DarkOnPrimary - RequestStatus.Pending, RequestOutcome.Active -> Color(0xFFFFD166).copy(alpha = 0.92f) to Color.Black - RequestStatus.Failed, RequestOutcome.Failed -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError - RequestOutcome.Cancelled, RequestOutcome.Declined -> Color.White.copy(alpha = 0.14f) to Color.White.copy(alpha = 0.80f) - else -> Color.Black.copy(alpha = 0.64f) to Color.White +internal fun TvRequestPosterBadge(state: RequestDisplayState, size: Dp, modifier: Modifier = Modifier) { + val icon = when (state) { + RequestDisplayState.Pending -> Icons.Filled.Schedule + RequestDisplayState.OnTheWay -> Icons.Filled.ArrowDownward + RequestDisplayState.InLibrary -> Icons.Filled.Check + is RequestDisplayState.NeedsAttention -> Icons.Filled.PriorityHigh + is RequestDisplayState.Unavailable -> return } Box( - modifier = modifier - .background(background, RoundedCornerShape(100.dp)) - .padding(horizontal = 9.dp, vertical = 4.dp), + modifier = modifier.size(size).background(state.tint.tvColor(), CircleShape), contentAlignment = Alignment.Center, ) { - Text( - text = normalized.requestDisplayLabel(), - style = MaterialTheme.typography.labelSmall, - color = foreground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Icon(imageVector = icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(size * 0.6f)) } } -@OptIn(ExperimentalTvMaterial3Api::class) +/** Status dot and label. */ @Composable -fun TvRequestActionPill( - label: String, - icon: ImageVector?, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - Surface( - onClick = onClick, - enabled = enabled, - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(100.dp)), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), - contentColor = Color.White, - focusedContainerColor = Color.White.copy(alpha = 0.92f), - focusedContentColor = DarkOnPrimary, - pressedContainerColor = Color.White.copy(alpha = 0.92f), - pressedContentColor = DarkOnPrimary, - disabledContainerColor = Color.White.copy(alpha = 0.05f), - disabledContentColor = Color.White.copy(alpha = 0.42f), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - modifier = modifier, - ) { - Row( - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (icon != null) { - Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(18.dp)) - } - Text(text = label, style = MaterialTheme.typography.labelLarge) - } - } -} - -@Composable -private fun RequestMetaLine( - mediaType: String, - year: Int?, +internal fun TvRequestStatusLabel( + progress: RequestProgress, modifier: Modifier = Modifier, + text: String = progress.shortLabel, + fontSize: TextUnit = 14.sp, + fontWeight: FontWeight = FontWeight.Normal, + color: Color = Color.White.copy(alpha = 0.70f), + dotSize: Dp = 6.dp, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(dotSize), ) { - Icon( - imageVector = mediaType.icon(), - contentDescription = null, - tint = Color.White.copy(alpha = 0.62f), - modifier = Modifier.size(13.dp), - ) + Box(modifier = Modifier.size(dotSize).background(progress.tint.tvColor(), CircleShape)) Text( - text = listOfNotNull(mediaType.requestDisplayLabel(), year?.toString()).joinToString(" • "), - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.62f), + text = text, + style = MaterialTheme.typography.bodySmall.copy(fontSize = fontSize, lineHeight = fontSize * 1.3f), + fontWeight = fontWeight, + color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } - -fun RequestMediaResult.canOpenLibraryDetail(): Boolean = - availability == RequestAvailability.Available && !libraryContentId.isNullOrBlank() - -fun RequestMediaResult.canRequest(): Boolean = - availability != RequestAvailability.Available && request.requestable - -private fun String.icon(): ImageVector = when (this) { - RequestMediaType.Series -> Icons.Default.Tv - else -> Icons.Default.Movie -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt index 7e43666c5c..b04eb61963 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.tv.ui.screens.requests +import org.siloserver.silo.common.requests.rememberRequestRouter +import org.siloserver.silo.model.request.RequestMediaResult +import androidx.compose.ui.graphics.vector.ImageVector import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -8,15 +11,23 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight 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.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,57 +38,71 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Button +import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.common.requests.RequestRowCopy +import org.siloserver.silo.common.requests.RequestStageTrack import org.siloserver.silo.model.catalog.ExternalRatings +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.RequestDisplayState import org.siloserver.silo.model.request.RequestMediaDetail -import org.siloserver.silo.model.request.RequestState -import org.siloserver.silo.model.request.reasonMessage +import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.RequestStep +import org.siloserver.silo.model.request.RequestTargetSummary import org.siloserver.silo.model.request.requestBackdropUrl -import org.siloserver.silo.model.request.requestDisplayLabel import org.siloserver.silo.model.request.requestPosterUrl +import org.siloserver.silo.repository.RequestsRepository +import org.siloserver.silo.tv.ui.components.PillKind +import org.siloserver.silo.tv.ui.components.SquaredPillSurface import org.siloserver.silo.tv.ui.components.TvErrorScreen -import org.siloserver.silo.tv.ui.components.TvFactsRow import org.siloserver.silo.tv.ui.components.TvHeroFactToken import org.siloserver.silo.tv.ui.components.TvLoadingScreen +import org.siloserver.silo.tv.ui.components.rememberAmbientBackdropTintState import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.theme.RowDimens -import org.siloserver.silo.tv.ui.theme.SiloBlue -import org.siloserver.silo.tv.ui.theme.cardScaled -import org.siloserver.silo.tv.ui.theme.sectionEyebrow +import org.siloserver.silo.tv.ui.screens.detail.TvDetailHero +import org.siloserver.silo.tv.ui.screens.detail.TvDetailHorizontalInset +import org.siloserver.silo.tv.ui.screens.detail.TvDetailSectionHeader +import org.siloserver.silo.tv.ui.screens.detail.tvDetailPageSurfaceColor +import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.viewmodel.RequestDetailUiState import org.siloserver.silo.viewmodel.RequestDetailViewModel -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf +import org.siloserver.silo.viewmodel.RequestPrimaryAction /** - * TV request detail — the 10-foot counterpart to the phone RequestDetailScreen. - * Reuses the shared [RequestDetailViewModel] (load + submitRequest) keyed by - * (mediaType, tmdbId). Shows title/metadata/genres/overview and one primary - * action: Request when the title is requestable, otherwise the request status. + * TV request detail on the same page surface and hero as a library title + * (`TvDetailHero`), like tvOS `RequestDetailView`: a STATUS / REQUESTED / + * QUALITY summary with a labeled stage track, one primary action that morphs + * in place, admin decisions, Cancel, and a "More like this" row. + * + * Focus: the primary action is one stable node whose label and look follow + * [RequestPrimaryAction], so focus stays put across request → requesting → + * pending. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvRequestDetailScreen( mediaType: String, tmdbId: Int, onBack: () -> Unit, + onOpenLibraryItem: (contentId: String) -> Unit = {}, + onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit = { _, _ -> }, /** * False while the shell has an overlay (a cascade panel or the profile * menu) that Back should close first. This handler registers after the @@ -87,22 +112,25 @@ fun TvRequestDetailScreen( backEnabled: Boolean = true, onInitialContentFocus: () -> Unit = {}, viewModel: RequestDetailViewModel = koinViewModel { parametersOf(mediaType, tmdbId) }, + repository: RequestsRepository = koinInject(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val router = rememberRequestRouter(repository, onOpenLibraryItem, onOpenRequestDetail) val loadingFocusRequester = remember { FocusRequester() } val primaryActionFocusRequester = remember { FocusRequester() } var pageHasFocus by remember { mutableStateOf(false) } var pageHadFocus by remember { mutableStateOf(false) } - val showsLoading = state.isLoading && state.detail == null - // The title, not the loaded detail: the refresh after a submit replaces the + var primaryHasFocus by remember { mutableStateOf(false) } + var confirmingDecline by remember { mutableStateOf(false) } + val showsLoading = state.detail == null && state.error == null + // The title, not the loaded detail: refreshes after an action replace the // detail and must not re-run the claim below. val detailKey = state.detail?.let { "${it.mediaType}:${it.tmdbId}" } val detailArrived by rememberUpdatedState(detailKey != null) // Nothing here is focusable until the detail loads, and the row that opened - // the page is about to be disposed, so Compose would re-home focus onto the - // top bar's Search button. Hold it on the loading indicator instead: focus - // stays in the page, and a move to the bar while this loads is the viewer's. + // the page is about to be disposed. Hold focus on the loading indicator so + // it stays in the page. LaunchedEffect(showsLoading) { if (!showsLoading) return@LaunchedEffect requestFocusUntilObserved( @@ -113,11 +141,10 @@ fun TvRequestDetailScreen( ) } - // Hand focus to the primary action when the detail arrives, but only if it - // is still in the page (or never got here). A viewer who went up to the bar - // or into the profile menu while this loaded keeps their place. Read in the - // composition that swaps the indicator for the content, so it is the focus - // from before the swap. + // Hand focus to the primary action when the detail arrives, but only if + // focus is still in the page (or never got here). Observed on the action + // itself: the synopsis above it is focusable too, and focus entering the + // page first lands there. val claimOnArrival = remember(detailKey) { pageHasFocus || !pageHadFocus } LaunchedEffect(detailKey) { if (detailKey == null || !claimOnArrival) return@LaunchedEffect @@ -125,44 +152,77 @@ fun TvRequestDetailScreen( maxAttempts = TvContentInitialFocusMaxAttempts, awaitAttempt = { withFrameNanos { } }, requestFocus = primaryActionFocusRequester::requestFocus, - isFocused = { pageHasFocus }, + isFocused = { primaryHasFocus }, ) if (result == TvObservedFocusResult.Focused) onInitialContentFocus() } BackHandler(enabled = backEnabled) { onBack() } - Column( + if (confirmingDecline) { + AlertDialog( + onDismissRequest = { confirmingDecline = false }, + containerColor = DarkSurfaceElevated, + titleContentColor = SiloOnSurface, + textContentColor = SiloOnSurface.copy(alpha = 0.76f), + title = { Text("Decline this request?", color = SiloOnSurface) }, + text = { Text("The person who asked for it will see it as declined.", color = SiloOnSurface.copy(alpha = 0.76f)) }, + confirmButton = { + // Keep first, so a stray press doesn't decide on someone's request. + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { confirmingDecline = false }) { Text("Keep") } + Button(onClick = { + confirmingDecline = false + viewModel.moderate(AdminRequestAction.Decline) + }) { Text("Decline") } + } + }, + ) + } + + val backdropUrl = state.detail?.let { requestBackdropUrl(it.backdropPath) ?: requestPosterUrl(it.posterPath) } + val pageTint = rememberAmbientBackdropTintState() + LaunchedEffect(backdropUrl) { pageTint.set(item = null, url = backdropUrl) } + + Box( modifier = Modifier .fillMaxSize() .onFocusChanged { pageHasFocus = it.hasFocus if (it.hasFocus) pageHadFocus = true } - .background(MaterialTheme.colorScheme.background), + .background(tvDetailPageSurfaceColor(pageTint.accent)), ) { + val detail = state.detail when { - showsLoading -> RequestDetailLoading(focusRequester = loadingFocusRequester) - state.error != null && state.detail == null -> TvErrorScreen( - message = state.error ?: "Failed to load this title.", - onRetry = viewModel::load, - ) - state.detail != null -> RequestDetailContent( - detail = state.detail!!, - isSubmitting = state.isSubmitting, - notice = state.notice, - error = state.error, - onRequest = viewModel::submitRequest, + detail != null -> RequestDetailContent( + detail = detail, + state = state, + backdropUrl = backdropUrl, primaryActionFocusRequester = primaryActionFocusRequester, + onPrimaryFocusChanged = { primaryHasFocus = it }, + onPrimary = { + when (val action = state.primaryAction) { + RequestPrimaryAction.Request -> viewModel.submitRequest() + is RequestPrimaryAction.OpenInLibrary -> onOpenLibraryItem(action.contentId) + else -> Unit + } + }, + onModerate = { action -> + if (action == AdminRequestAction.Decline) confirmingDecline = true else viewModel.moderate(action) + }, + onCancel = viewModel::cancel, + onOpenRecommendation = router::openResult, ) + state.error != null -> TvErrorScreen(message = state.error.orEmpty(), onRetry = viewModel::load) + else -> RequestDetailLoading(focusRequester = loadingFocusRequester) } } } /** * The loading indicator plus a spinner-sized focus target over it, so focus - * can wait inside the page. Kept small and centred: D-pad Up from it has to - * find the top bar by geometry, which a full-screen target would not. + * can wait inside the page. */ @Composable private fun RequestDetailLoading(focusRequester: FocusRequester) { @@ -177,199 +237,240 @@ private fun RequestDetailLoading(focusRequester: FocusRequester) { } } -/** - * Content starts below the shell's top navigation, which is a `TopStart` - * overlay drawn over every screen rather than something that reserves space. - * Without this the title rendered *through* the nav row and the eyebrow was - * clipped off the top edge entirely. - */ -private val ShellNavInset = 96.dp - -/** TV-safe horizontal margin; the outer ~5% of a panel is not reliably visible. */ -private val SafeHorizontal = 48.dp - -/** - * The overview is a full TMDB synopsis — often several hundred words, which on - * a 10-foot display filled the screen and pushed the Request action off the - * bottom. Clamped to a readable teaser; the point of this screen is deciding - * whether to request, not reading the whole plot. - */ -private const val OverviewMaxLines = 4 - -@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun RequestDetailContent( detail: RequestMediaDetail, - isSubmitting: Boolean, - notice: String?, - error: String?, - onRequest: () -> Unit, + state: RequestDetailUiState, + backdropUrl: String?, primaryActionFocusRequester: FocusRequester, + onPrimaryFocusChanged: (Boolean) -> Unit, + onPrimary: () -> Unit, + onModerate: (AdminRequestAction) -> Unit, + onCancel: () -> Unit, + onOpenRecommendation: (RequestMediaResult) -> Unit, ) { - Box(modifier = Modifier.fillMaxSize()) { - // Backdrop, scrimmed hard enough that body copy stays legible over the - // busiest frame. Absent artwork simply leaves the background colour. - requestBackdropUrl(detail.backdropPath)?.let { url -> - ThumbhashImage( - url = url, - thumbhash = null, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + val progress = state.progress?.takeIf { state.showsStatus } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 70.dp), + ) { + item(key = "hero", contentType = "detail-hero") { + TvDetailHero( + title = detail.title, + seriesTitle = null, + logoUrl = null, + backdropUrl = backdropUrl, + backdropThumbhash = null, + sourceTokens = detail.genres.take(3), + ratingChip = detail.contentRating.takeIf { it.isNotBlank() }, + overview = detail.overview.takeIf { it.isNotBlank() }, + tagline = detail.tagline.takeIf { it.isNotBlank() }, + factsLine = detail.factTokens(), + directorText = detail.creditText(), + extraHeight = if (progress != null) StatusStripHeight else 0.dp, + playbackSummary = progress?.let { { RequestStatusStrip(progress = it, state = state) } }, + actions = { + Row( + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RequestPrimaryActionPill( + state = state, + focusRequester = primaryActionFocusRequester, + onClick = onPrimary, + modifier = Modifier.onFocusChanged { onPrimaryFocusChanged(it.hasFocus) }, + ) + state.moderationActions.forEach { action -> + val (icon, title) = when (action) { + AdminRequestAction.Approve -> Icons.Filled.Check to "Approve" + AdminRequestAction.Decline -> Icons.Filled.Close to "Decline" + AdminRequestAction.Retry -> Icons.Filled.Refresh to "Retry" + } + RequestActionPill(icon = icon, title = title, onClick = { onModerate(action) }) + } + if (state.canCancel) { + RequestActionPill(icon = Icons.Filled.Close, title = "Cancel Request", onClick = onCancel) + } + state.actionErrorMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp), + color = SiloOnSurface.copy(alpha = 0.72f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, ) } - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - 0f to MaterialTheme.colorScheme.background, - 0.55f to MaterialTheme.colorScheme.background.copy(alpha = 0.94f), - 1f to Color.Transparent, - ), - ), - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0f to MaterialTheme.colorScheme.background.copy(alpha = 0.86f), - 0.4f to Color.Transparent, - 1f to MaterialTheme.colorScheme.background.copy(alpha = 0.92f), - ), - ), - ) + val recommendations = state.recommendations + if (recommendations.isNotEmpty()) { + item(key = "more-like-this", contentType = "rail") { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + TvDetailSectionHeader(title = "More like this", modifier = Modifier.padding(horizontal = TvDetailHorizontalInset)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(horizontal = TvDetailHorizontalInset, vertical = 8.dp), + ) { + items(recommendations, key = { "${it.mediaType}:${it.tmdbId}" }) { result -> + TvRequestCard(result = result, onClick = { onOpenRecommendation(result) }) + } + } + } + } + } + } +} - Row( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - PaddingValues( - start = SafeHorizontal, - end = SafeHorizontal, - top = ShellNavInset, - bottom = 48.dp, - ), - ), - horizontalArrangement = Arrangement.spacedBy(32.dp), - ) { - requestPosterUrl(detail.posterPath)?.let { url -> - ThumbhashImage( - url = url, - thumbhash = null, - contentDescription = detail.title, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(RowDimens.PosterWidth.cardScaled(), RowDimens.PosterHeight.cardScaled()) - .clip(RoundedCornerShape(10.dp)), - ) +/** + * The page's one primary action: a white Request / Open in Library pill, or + * the request's status with its dot. Never disabled and never swapped for + * another node, so a submit can't drop focus mid-morph; presses on a status + * do nothing. + */ +@Composable +private fun RequestPrimaryActionPill( + state: RequestDetailUiState, + focusRequester: FocusRequester, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val action = state.primaryAction + SquaredPillSurface( + kind = PillKind.Primary, + onClick = { if (action.isInteractive) onClick() }, + modifier = modifier.height(38.dp), + focusRequester = focusRequester, + capsule = true, + stableHero = true, + contentPadding = PaddingValues(horizontal = 20.dp), + ) { foreground -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { + when (action) { + RequestPrimaryAction.Request -> Icon(Icons.Filled.Add, contentDescription = null, tint = foreground, modifier = Modifier.size(18.dp)) + RequestPrimaryAction.Submitting -> CircularProgressIndicator(color = foreground, strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) + is RequestPrimaryAction.OpenInLibrary -> + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp)) + is RequestPrimaryAction.Status -> + Box(modifier = Modifier.size(9.dp).background(action.state.tint.tvColor(), CircleShape)) + RequestPrimaryAction.Loading -> Unit } + Text( + text = state.primaryActionTitle, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 14.sp), + color = foreground, + maxLines = 1, + ) + } + } +} - Column( - // Held to the scrimmed side so the backdrop stays visible on the - // right rather than sitting behind the text. - modifier = Modifier.widthIn(max = 900.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = "REQUEST", - style = sectionEyebrow, - color = SiloBlue.copy(alpha = 0.92f), - ) - Text( - text = detail.title, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) +/** + * A secondary action in the hero row (Approve, Decline, Retry, Cancel), in the + * row's 38dp capsule so every control in it shares one height. + */ +@Composable +private fun RequestActionPill(icon: ImageVector, title: String, onClick: () -> Unit) { + SquaredPillSurface( + kind = PillKind.Secondary, + onClick = onClick, + modifier = Modifier.height(38.dp), + focusRequester = null, + capsule = true, + stableHero = true, + contentPadding = PaddingValues(horizontal = 18.dp), + ) { foreground -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 14.sp), + color = foreground, + maxLines = 1, + ) + } + } +} - RequestMetaLine(detail) +/** Extra hero height for the status strip and labeled track. */ +private val StatusStripHeight = 64.dp - if (detail.tagline.isNotBlank()) { - Text( - text = detail.tagline, - style = MaterialTheme.typography.titleMedium.copy(fontStyle = FontStyle.Italic), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.86f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) +/** + * The labeled summary under the synopsis, in the playback summary's grammar, + * plus the stage track with its step names. + */ +@Composable +private fun RequestStatusStrip(progress: RequestProgress, state: RequestDetailUiState) { + val record = state.displayedRecord + Column(modifier = Modifier.padding(top = 7.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + SummaryField(label = "STATUS", value = progress.longLabel, dot = progress.tint.tvColor()) + if (record != null) { + RequestRowCopy.day(record.createdAt)?.let { SummaryField(label = "REQUESTED", value = it) } + (RequestTargetSummary.text(record.targets) ?: RequestTargetSummary.qualities(record.targets))?.let { + SummaryField(label = "QUALITY", value = it) } - - if (detail.overview.isNotBlank()) { + } + } + Column(modifier = Modifier.width(420.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + RequestStageTrack(progress = progress, height = 3.dp) + Row { + RequestStep.entries.forEach { step -> + val color = when { + step == progress.currentStep -> progress.tint.tvColor() + step.ordinal < progress.completedSteps -> SiloOnSurface + else -> SiloOnSurface.copy(alpha = 0.45f) + } Text( - text = detail.overview, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.92f), - maxLines = OverviewMaxLines, - overflow = TextOverflow.Ellipsis, + text = step.title, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), + color = color, + maxLines = 1, + modifier = Modifier.weight(1f), ) } - - // One pill in every state, never swapped for another node. The - // status used to be plain text, which left the page with nothing - // to focus, and replacing Request with it after a submit dropped - // the focused node the same way. Only Request is enabled; the - // status or reason renders disabled so it reads as not - // actionable. A disabled TV Surface still takes focus (its - // clickable is focusable regardless of enabled), so the page - // keeps a focus target in every state, like tvOS - // RequestDetailView's single primary action. - val request = detail.request - TvRequestActionPill( - label = request.primaryActionLabel(isSubmitting), - icon = Icons.Filled.Add.takeIf { request.requestable }, - onClick = onRequest, - enabled = request.requestable && !isSubmitting, - modifier = Modifier - .padding(top = 12.dp) - .focusRequester(primaryActionFocusRequester), - ) - - notice?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) - } - error?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) - } } } } } -/** `2024 · 128 min · PG-13 · TMDB 7.8 · Drama · Crime`; past the width, whole facts drop from the end. */ @Composable -private fun RequestMetaLine(detail: RequestMediaDetail) { - val tokens = buildList { - detail.year?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(it.toString())) } - detail.runtime?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken("$it min")) } - detail.contentRating.takeIf { it.isNotBlank() }?.let { add(TvHeroFactToken.TextToken(it)) } - ExternalRatings.tmdb(detail.voteAverage)?.let { add(TvHeroFactToken.ExternalRating(it)) } - detail.genres.take(3).forEach { add(TvHeroFactToken.TextToken(it)) } +private fun SummaryField(label: String, value: String, dot: Color? = null) { + Column(modifier = Modifier.width(150.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.55.sp), + color = Color.White.copy(alpha = 0.48f), + maxLines = 1, + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + if (dot != null) Box(modifier = Modifier.size(6.dp).background(dot, CircleShape)) + Text( + text = value, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - if (tokens.isEmpty()) return - TvFactsRow( - tokens = tokens, - style = MaterialTheme.typography.titleMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - spacing = 10.dp, - ) } -/** - * Label for the detail's primary action: Request while the title is - * requestable, otherwise the existing request's status, otherwise the reason - * it cannot be requested. Status tokens use [requestDisplayLabel]; reasons use - * the shared [reasonMessage] policy for readable sentences and unknown codes. - */ -private fun RequestState.primaryActionLabel(isSubmitting: Boolean): String { - if (requestable) return if (isSubmitting) "Requesting…" else "Request" - val status = status?.takeIf { it.isNotBlank() } - return when { - status != null -> "Request status: ${status.requestDisplayLabel()}" - else -> reasonMessage() ?: "Unavailable" +/** `2026 · 2h 25m · TMDB 7.9`, with genres after it as source tokens. */ +private fun RequestMediaDetail.factTokens(): List = buildList { + year?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(it.toString())) } + if (mediaType == RequestMediaType.Series) { + numberOfSeasons?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(if (it == 1) "1 season" else "$it seasons")) } + } else { + runtime?.takeIf { it > 0 }?.let { add(TvHeroFactToken.TextToken(if (it >= 60) "${it / 60}h ${it % 60}m" else "${it}m")) } } + ExternalRatings.tmdb(voteAverage)?.let { add(TvHeroFactToken.ExternalRating(it)) } +} + +private fun RequestMediaDetail.creditText(): String? = when { + director.isNotBlank() -> "Directed by $director" + creators.isNotEmpty() -> "Created by ${creators.take(2).joinToString(", ")}" + else -> null } + diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt new file mode 100644 index 0000000000..9d60fa8eef --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt @@ -0,0 +1,832 @@ +package org.siloserver.silo.tv.ui.screens.requests + +import androidx.compose.runtime.rememberCoroutineScope +import org.siloserver.silo.common.requests.rememberRequestRouter +import org.siloserver.silo.viewmodel.RequestApprovalsUiState +import org.siloserver.silo.model.request.RequestDiscoverySection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material3.AlertDialog +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.common.cards.LocalCardPresentation +import org.siloserver.silo.common.requests.RequestRowCopy +import org.siloserver.silo.common.requests.RequestStageTrack +import org.siloserver.silo.model.catalog.ExternalRatings +import org.siloserver.silo.model.feature.RequestsFeatureStore +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestAttention +import org.siloserver.silo.model.request.RequestDisplayState +import org.siloserver.silo.model.request.RequestMediaResult +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.requestBackdropUrl +import org.siloserver.silo.model.request.requestPosterUrl +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.repository.RequestsRepository +import org.siloserver.silo.tv.ui.components.TvErrorScreen +import org.siloserver.silo.tv.ui.components.TvFocusMarquee +import org.siloserver.silo.tv.ui.components.TvHeroFactToken +import org.siloserver.silo.tv.ui.components.TvMarqueeContent +import org.siloserver.silo.tv.ui.components.TvRootHeroBackdrop +import org.siloserver.silo.tv.ui.components.TvSectionHeader +import org.siloserver.silo.tv.ui.components.LocalAmbientBackdropTint +import org.siloserver.silo.tv.ui.components.TvSkylineBringIntoViewSpec +import org.siloserver.silo.tv.ui.components.TvSkylineItemSpacing +import org.siloserver.silo.tv.ui.components.TvSkylineMarqueeBottomGap +import org.siloserver.silo.tv.ui.components.TvSkylineRowBandBottomInset +import org.siloserver.silo.tv.ui.components.TvSkylineRowBandMaxFraction +import org.siloserver.silo.tv.ui.components.TvSkylineRowCardVerticalPadding +import org.siloserver.silo.tv.ui.components.TvSkylineRowPreviewSpacing +import org.siloserver.silo.tv.ui.components.rememberAmbientBackdropTintState +import org.siloserver.silo.tv.ui.components.tvSkylineRowBandHeight +import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.theme.Spacing +import org.siloserver.silo.tv.ui.theme.TvSkyline +import org.siloserver.silo.viewmodel.MyRequestsViewModel +import org.siloserver.silo.viewmodel.RequestApprovalsViewModel +import org.siloserver.silo.viewmodel.RequestsViewModel + +/** + * The top-bar Requests tab in the Skyline layout Home and the library tabs + * use: an ambient backdrop and a passive preview panel that follow the focused + * card, including its request status and stage track, over horizontally + * scrolling rows in the bottom band (the tvOS `TVRequestsPage`). Rows: requests + * waiting for this admin's approval, the user's own requests, failed requests + * (admins), then the discover carousels. + * + * Focus: the page claims the first card when the tab is selected, and the last + * focused card when it returns from a detail page. Up from the first row + * reaches the top bar through the shell. Loading, empty, and error states own + * focus themselves, so entry always lands somewhere. + */ +@Composable +fun TvRequestsPage( + onOpenLibraryItem: (contentId: String) -> Unit, + onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit, + onInitialContentFocus: () -> Unit, + onFocusHandoffFailed: () -> Unit = {}, + /** Shell token bumped when the tab is selected from the bar. */ + focusRequest: Int = 0, + hub: RequestsViewModel = koinViewModel(key = "tv-requests-hub") { parametersOf(false) }, + /** For Cancel on the user's own cards; the hub's list shows the rows. */ + mine: MyRequestsViewModel = koinViewModel(key = "tv-requests-mine") { parametersOf(false) }, + approvals: RequestApprovalsViewModel = koinViewModel(key = "tv-requests-approvals") { parametersOf(false) }, + featureStore: RequestsFeatureStore = koinInject(), + repository: RequestsRepository = koinInject(), +) { + val hubState by hub.uiState.collectAsState() + val mineState by mine.uiState.collectAsState() + val approvalsState by approvals.uiState.collectAsState() + val canModerate by featureStore.canModerate.collectAsState() + var hasLoaded by rememberSaveable { mutableStateOf(false) } + var actionTarget by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + // Rows appear together once every read for the page has answered, so the + // first row never changes under a focused card. Re-read on every return. + suspend fun load() { + val discover = scope.launch { hub.fetch() } + if (featureStore.canModerate.value) approvals.fetch() + discover.join() + hasLoaded = true + } + LaunchedEffect(Unit) { load() } + // Moderation can be confirmed after the page's first load. + LaunchedEffect(canModerate) { + if (canModerate && hasLoaded) approvals.fetch() + } + + val rows = if (hasLoaded) tvRequestRows(hubState.myRequests, hubState.sections, approvalsState, canModerate) else emptyList() + // A failed action's reason shows for a few seconds; an unconfirmed decision + // stays until a read settles it. + // The view models outlive this page's composition (a detail push and + // Back re-enter it), so remember what was already shown and replay nothing. + var transientMessage by remember { mutableStateOf(null) } + var seenFailedActions by rememberSaveable { mutableStateOf(approvalsState.failedActions) } + var seenCancelMessage by rememberSaveable { mutableStateOf(mineState.actionErrorMessage) } + LaunchedEffect(approvalsState.failedActions) { + if (approvalsState.failedActions <= seenFailedActions) return@LaunchedEffect + seenFailedActions = approvalsState.failedActions + transientMessage = approvalsState.lastActionError + delay(ActionMessageMs) + transientMessage = null + } + LaunchedEffect(mineState.actionErrorMessage) { + val message = mineState.actionErrorMessage + if (message == null || message == seenCancelMessage) return@LaunchedEffect + seenCancelMessage = message + transientMessage = message + delay(ActionMessageMs) + if (transientMessage == message) transientMessage = null + } + // An admin whose approval queue couldn't load sees why, instead of a page + // that silently lacks its approval rows. + val approvalsError = approvalsState.error?.takeIf { canModerate && hasLoaded } + ?.let { "Couldn't load requests waiting for approval" } + val actionMessage = approvalsState.actionErrorMessage ?: transientMessage ?: approvalsError + + val router = rememberRequestRouter(repository, onOpenLibraryItem, onOpenRequestDetail) + fun open(item: TvRequestItem) { + when (item) { + is TvRequestItem.Record -> router.openRecord(item.record) + is TvRequestItem.Approval -> router.openModerationRecord(item.record) + is TvRequestItem.Result -> router.openResult(item.result) + } + } + + fun actionsFor(item: TvRequestItem): List = + item.actions(canCancel = mineState::canCancel, canAct = approvalsState::canAct) + + val target = actionTarget + val targetActions = target?.let(::actionsFor).orEmpty() + // A card's actions can lapse while its dialog is up (an action on it + // started elsewhere); the dialog closes rather than offer nothing. + LaunchedEffect(target, targetActions.isEmpty()) { + if (target != null && targetActions.isEmpty()) actionTarget = null + } + if (target != null && targetActions.isNotEmpty()) { + TvRequestActionDialog( + item = target, + actions = targetActions, + onDismiss = { actionTarget = null }, + onAction = { action, record -> + actionTarget = null + when (action) { + // The hub's list shows the row; it settles an uncertain cancel. + TvRequestAction.Cancel -> mine.cancel(record, refresh = { hub.fetch() }) + TvRequestAction.Approve -> approvals.perform(AdminRequestAction.Approve, record) + TvRequestAction.Decline -> approvals.perform(AdminRequestAction.Decline, record) + TvRequestAction.Retry -> approvals.perform(AdminRequestAction.Retry, record) + } + }, + ) + } + + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + if (rows.isEmpty()) { + TvRequestsPlaceholder( + hasLoaded = hasLoaded, + error = hubState.error, + focusRequest = focusRequest, + onRetry = { + hasLoaded = false + scope.launch { load() } + }, + onInitialContentFocus = onInitialContentFocus, + onFocusHandoffFailed = onFocusHandoffFailed, + ) + } else { + TvRequestsFeed( + rows = rows, + focusRequest = focusRequest, + onOpen = ::open, + // A card with nothing to offer ignores the long-press. + onLongPress = { item -> if (actionsFor(item).isNotEmpty()) actionTarget = item }, + onInitialContentFocus = onInitialContentFocus, + onFocusHandoffFailed = onFocusHandoffFailed, + ) + } + if (actionMessage != null) { + Text( + text = actionMessage, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp), + color = SiloOnSurface, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = TvSkyline.safeAreaX, bottom = 20.dp) + .background(DarkSurfaceElevated.copy(alpha = 0.92f), RoundedCornerShape(50)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + } +} + +// ── Rows ───────────────────────────────────────────────────── + +internal data class TvRequestRow( + val id: String, + val title: String, + val items: List, + val label: String? = null, +) { + /** Page-unique focus key: the same title can sit in two rows. */ + fun key(item: TvRequestItem): String = "$id|${item.id}" +} + +internal sealed interface TvRequestItem { + val id: String + + /** One of the signed-in user's requests. */ + data class Record(val record: MediaRequest) : TvRequestItem { + override val id: String get() = "request:${record.id}" + } + + /** Someone's request, shown to an admin who can decide on it. */ + data class Approval(val record: MediaRequest) : TvRequestItem { + override val id: String get() = "approval:${record.id}" + } + + data class Result(val result: RequestMediaResult) : TvRequestItem { + override val id: String get() = "tmdb:${result.mediaType}:${result.tmdbId}" + } +} + +private fun tvRequestRows( + myRequests: List, + sections: List, + approvals: RequestApprovalsUiState, + showsApprovals: Boolean, +): List = buildList { + if (showsApprovals && approvals.awaitingApproval.isNotEmpty()) { + add(TvRequestRow("approvals", "Waiting for your approval", approvals.awaitingApproval.map { TvRequestItem.Approval(it) })) + } + val mine = myRequests.filterTvMediaRequests() + if (mine.isNotEmpty()) add(TvRequestRow("mine", "Your requests", mine.map { TvRequestItem.Record(it) })) + if (showsApprovals && approvals.failed.isNotEmpty()) { + add(TvRequestRow("failed", "Failed requests", approvals.failed.map { TvRequestItem.Approval(it) })) + } + sections.filterTvRequestSections().forEachIndexed { index, section -> + add( + TvRequestRow( + id = "discover:${section.key}", + title = section.title, + items = section.results.map { TvRequestItem.Result(it) }, + label = if (index == 0) "Discover" else null, + ), + ) + } +} + +// ── Feed ───────────────────────────────────────────────────── + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TvRequestsFeed( + rows: List, + focusRequest: Int, + onOpen: (TvRequestItem) -> Unit, + onLongPress: (TvRequestItem) -> Unit, + onInitialContentFocus: () -> Unit, + onFocusHandoffFailed: () -> Unit, +) { + val tintState = rememberAmbientBackdropTintState() + val bandState = rememberLazyListState() + val rowStates = remember { mutableMapOf() } + val requesters = remember { mutableMapOf() } + fun requesterFor(key: String) = requesters.getOrPut(key) { FocusRequester() } + + var focusedKey by remember { mutableStateOf(null) } + /** The card holding focus right now, for confirming an entry claim landed on its target. */ + var focusedCardKey by remember { mutableStateOf(null) } + /** Last focused card, restored when the page returns from a detail page. */ + var lastFocusedKey by rememberSaveable { mutableStateOf(null) } + var appliedFocusRequest by rememberSaveable { mutableStateOf(0) } + // Set when a card opens a page. Focus moves while this page is torn down + // (an outer route takes the whole shell out of composition), and those + // moves must not replace the card the viewer actually opened. + var leaving by remember { mutableStateOf(false) } + fun openFrom(key: String, item: TvRequestItem) { + lastFocusedKey = key + leaving = true + onOpen(item) + } + var previewKey by remember { mutableStateOf(null) } + val latestRows by rememberUpdatedState(rows) + + fun locate(key: String?): Pair? { + if (key == null) return null + latestRows.forEachIndexed { rowIndex, row -> + val itemIndex = row.items.indexOfFirst { row.key(it) == key } + if (itemIndex >= 0) return rowIndex to itemIndex + } + return null + } + + // Entry: the first card when the tab is selected from the bar, the last + // focused card on a return from detail. + LaunchedEffect(focusRequest) { + val fromBar = focusRequest != appliedFocusRequest + appliedFocusRequest = focusRequest + val first = latestRows.first().let { it.key(it.items.first()) } + val target = if (fromBar) first else lastFocusedKey?.takeIf { locate(it) != null } ?: first + val (rowIndex, itemIndex) = locate(target) ?: (0 to 0) + bandState.scrollToItem(rowIndex) + rowStates[latestRows[rowIndex].id]?.scrollToItem(itemIndex) + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = { requesterFor(target).requestFocus() }, + // The target card itself: focus entering the page lands on some + // other card first, and that must not count as the claim. + isFocused = { focusedCardKey == target }, + ) + if (result == TvObservedFocusResult.Focused) onInitialContentFocus() else onFocusHandoffFailed() + } + + // The band's vertical bring-into-view is off (TvSkylineBringIntoViewSpec), + // so the focused row is scrolled to the top of the band explicitly, as + // Home's feed does. + val focusedRowIndex = locate(focusedKey)?.first + LaunchedEffect(focusedRowIndex, rows.size) { + if (focusedRowIndex != null && focusedRowIndex in rows.indices) bandState.animateScrollToItem(focusedRowIndex) + } + + // The preview follows focus after a short rest, as Home's marquee does. + LaunchedEffect(focusedKey) { + val key = focusedKey ?: return@LaunchedEffect + if (previewKey != null) delay(MarqueeRestMs) + previewKey = key + } + val preview = remember(previewKey, rows) { + val key = previewKey ?: rows.first().let { it.key(it.items.first()) } + locate(key)?.let { (rowIndex, itemIndex) -> rows[rowIndex].let { row -> TvRequestPreview.of(row.items[itemIndex], row) } } + } + LaunchedEffect(preview?.content?.heroBackdropUrl) { + preview?.let { tintState.set(it.content.source, it.content.heroBackdropUrl) } + } + + CompositionLocalProvider( + LocalAmbientBackdropTint provides tintState, + LocalBringIntoViewSpec provides TvSkylineBringIntoViewSpec, + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val presentation = LocalCardPresentation.current + val bandHeight = tvSkylineRowBandHeight(presentation).coerceAtMost(maxHeight * TvSkylineRowBandMaxFraction) + val trailingPadding = (bandHeight - TvSkylineRowBandBottomInset).coerceAtLeast(0.dp) + val previews = remember(rows) { mutableMapOf() } + + TvRootHeroBackdrop(content = preview?.content, modifier = Modifier.fillMaxSize()) + TvFocusMarquee( + content = preview?.content, + startPadding = TvSkyline.safeAreaX, + topPadding = TvSkyline.barTopInset + TvSkyline.barHeight, + bottomPadding = bandHeight + TvSkylineMarqueeBottomGap, + modifier = Modifier.fillMaxSize(), + footer = { content -> + val shown = if (content.id == preview?.content?.id) preview else previews[content.id] + shown?.let { TvRequestPreviewStatus(it) } + }, + ) + preview?.let { previews[it.content.id] = it } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(bandHeight) + .align(Alignment.BottomStart) + .clipToBounds(), + ) { + LazyColumn( + state = bandState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(TvSkylineRowPreviewSpacing), + contentPadding = PaddingValues(bottom = trailingPadding), + ) { + itemsIndexed(rows, key = { _, row -> row.id }) { _, row -> + val rowState = rowStates.getOrPut(row.id) { LazyListState() } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + TvSectionHeader( + title = row.title, + eyebrow = row.label, + modifier = Modifier.padding(start = TvSkyline.safeAreaX, end = Spacing.safeArea), + ) + LazyRow( + state = rowState, + modifier = Modifier.focusRestorer(), + horizontalArrangement = Arrangement.spacedBy(TvSkylineItemSpacing), + contentPadding = PaddingValues( + start = TvSkyline.safeAreaX, + end = Spacing.safeArea, + top = TvSkylineRowCardVerticalPadding, + bottom = TvSkylineRowCardVerticalPadding, + ), + ) { + itemsIndexed(row.items, key = { _, item -> item.id }) { _, item -> + val key = row.key(item) + val cardModifier = Modifier.onFocusChanged { + if (it.hasFocus) { + focusedCardKey = key + } else if (focusedCardKey == key) { + focusedCardKey = null + } + if (it.hasFocus && !leaving) { + focusedKey = key + lastFocusedKey = key + } + } + when (item) { + is TvRequestItem.Record -> TvRequestRecordCard( + record = item.record, + onClick = { openFrom(key, item) }, + onLongClick = { onLongPress(item) }, + focusRequester = requesterFor(key), + modifier = cardModifier, + ) + is TvRequestItem.Approval -> TvRequestRecordCard( + record = item.record, + onClick = { openFrom(key, item) }, + onLongClick = { onLongPress(item) }, + focusRequester = requesterFor(key), + modifier = cardModifier, + ) + is TvRequestItem.Result -> TvRequestCard( + result = item.result, + onClick = { openFrom(key, item) }, + focusRequester = requesterFor(key), + modifier = cardModifier, + ) + } + } + } + } + } + } + } + } + } +} + +private const val MarqueeRestMs = 150L +private const val ActionMessageMs = 4_000L + +/** What the preview panel shows for the focused card. */ +internal data class TvRequestPreview( + val content: TvMarqueeContent, + val progress: RequestProgress?, + val statusText: String?, +) { + companion object { + fun of(item: TvRequestItem, row: TvRequestRow): TvRequestPreview { + val meta = mutableListOf() + val title: String + val overview: String? + val posterPath: String? + val backdropPath: String? + val mediaType: String + val progress: RequestProgress? + val statusText: String? + val year: Int? + when (item) { + is TvRequestItem.Record, is TvRequestItem.Approval -> { + val record = (item as? TvRequestItem.Record)?.record ?: (item as TvRequestItem.Approval).record + title = record.title + overview = record.overview + posterPath = record.posterPath + backdropPath = record.backdropPath + mediaType = record.mediaType + year = record.year + progress = RequestProgress.of(record) + statusText = RequestRowCopy.status(record, progress) + } + is TvRequestItem.Result -> { + val result = item.result + title = result.title + overview = result.overview + posterPath = result.posterPath + backdropPath = result.backdropPath + mediaType = result.mediaType + year = result.year + // Like the detail page: a title that can't be requested has no track. + progress = RequestProgress.of(result.availability, result.request) + ?.takeUnless { it.display is RequestDisplayState.Unavailable } + statusText = progress?.longLabel + } + } + year?.takeIf { it > 0 }?.let { meta += TvHeroFactToken.TextToken(it.toString()) } + meta += TvHeroFactToken.TextToken(RequestRowCopy.mediaTypeLabel(mediaType)) + when (item) { + is TvRequestItem.Record -> RequestRowCopy.day(item.record.createdAt)?.let { meta += TvHeroFactToken.TextToken("Requested $it") } + is TvRequestItem.Approval -> RequestRowCopy.day(item.record.createdAt)?.let { meta += TvHeroFactToken.TextToken("Requested $it") } + is TvRequestItem.Result -> ExternalRatings.tmdb(item.result.voteAverage)?.let { meta += TvHeroFactToken.ExternalRating(it) } + } + val posterUrl = requestPosterUrl(posterPath) + val backdropUrl = requestBackdropUrl(backdropPath) + val content = TvMarqueeContent( + id = row.key(item), + title = title, + logoUrl = null, + badges = emptyList(), + metaParts = meta, + synopsis = overview?.takeIf { it.isNotBlank() }, + detailLine = null, + specLine = null, + backdropUrl = backdropUrl, + backdropThumbhash = null, + posterUrl = posterUrl, + posterThumbhash = null, + isEpisode = false, + // The tint samples the same artwork the backdrop shows. + source = SectionItem( + contentId = row.key(item), + type = mediaType, + title = title, + posterUrl = posterUrl, + backdropUrl = backdropUrl, + ), + ) + return TvRequestPreview(content, progress, statusText) + } + } +} + +/** The status sentence and stage track under the preview's synopsis. */ +@Composable +private fun TvRequestPreviewStatus(preview: TvRequestPreview) { + val progress = preview.progress ?: return + Row( + modifier = Modifier.padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + TvRequestStatusLabel( + progress = progress, + text = preview.statusText ?: progress.longLabel, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + modifier = Modifier.weight(1f, fill = false), + ) + RequestStageTrack(progress = progress, height = 3.dp, modifier = Modifier.width(120.dp)) + } +} + +// ── Placeholders ───────────────────────────────────────────── + +/** + * The page before it has rows: a loading skeleton in the feed's geometry, the + * empty state, or the error. Each owns focus, so entry from the bar always + * lands in the page. + */ +@Composable +private fun TvRequestsPlaceholder( + hasLoaded: Boolean, + error: String?, + focusRequest: Int, + onRetry: () -> Unit, + onInitialContentFocus: () -> Unit, + onFocusHandoffFailed: () -> Unit, +) { + val owner = remember { FocusRequester() } + var ownerHasFocus by remember { mutableStateOf(false) } + val state = when { + !hasLoaded -> "loading" + error != null -> "error" + else -> "empty" + } + LaunchedEffect(focusRequest, state) { + // The error view owns its Retry button; hand the shell back its bar + // rather than holding the handoff open. + if (state == "error") { + onFocusHandoffFailed() + return@LaunchedEffect + } + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = owner::requestFocus, + isFocused = { ownerHasFocus }, + ) + if (result == TvObservedFocusResult.Focused) onInitialContentFocus() else onFocusHandoffFailed() + } + when (state) { + "error" -> TvErrorScreen(message = error.orEmpty(), onRetry = onRetry) + else -> { + val label = if (state == "loading") "Loading requests" else "Nothing here yet" + Box( + modifier = Modifier + .fillMaxSize() + .focusRequester(owner) + .onFocusChanged { ownerHasFocus = it.isFocused } + .semantics { contentDescription = label } + .focusable(), + ) { + if (state == "loading") TvRequestsLoadingView() else TvRequestsEmptyState() + } + } + } +} + +@Composable +private fun TvRequestsEmptyState() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(imageVector = Icons.Outlined.Inbox, contentDescription = null, tint = Color.White.copy(alpha = 0.62f), modifier = Modifier.size(36.dp)) + Text(text = "Nothing here yet", style = MaterialTheme.typography.headlineSmall, color = Color.White) + Text( + text = "Use Search to find a movie or series to request", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.68f), + ) + } +} + +/** + * First frame while rows load: the same preview-and-row geometry as the feed, + * so the real page replaces it in place instead of building itself from a + * black canvas. + */ +@Composable +private fun TvRequestsLoadingView() { + val presentation = LocalCardPresentation.current + val cardWidth = tvRequestCardWidth() + val bar = Color.White.copy(alpha = 0.10f) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val bandHeight = tvSkylineRowBandHeight(presentation).coerceAtMost(maxHeight * TvSkylineRowBandMaxFraction) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = TvSkyline.safeAreaX, bottom = bandHeight + TvSkylineMarqueeBottomGap), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(modifier = Modifier.size(260.dp, 36.dp).background(Color.White.copy(alpha = 0.14f), RoundedCornerShape(6.dp))) + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + listOf(59.dp, 41.dp, 75.dp).forEach { Box(modifier = Modifier.size(it, 11.dp).background(bar, RoundedCornerShape(3.dp))) } + } + Box(modifier = Modifier.size(360.dp, 9.dp).background(bar, RoundedCornerShape(3.dp))) + Box(modifier = Modifier.size(305.dp, 9.dp).background(bar, RoundedCornerShape(3.dp))) + Text( + text = "Loading Requests", + style = MaterialTheme.typography.bodyMedium, + color = SiloOnSurface.copy(alpha = 0.72f), + modifier = Modifier.padding(top = 2.dp), + ) + } + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(bandHeight) + .padding(start = TvSkyline.safeAreaX), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(modifier = Modifier.size(140.dp, 18.dp).background(bar, RoundedCornerShape(3.dp))) + Row( + modifier = Modifier.padding(top = TvSkylineRowCardVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(TvSkylineItemSpacing), + ) { + repeat(8) { + Box(modifier = Modifier.size(cardWidth, cardWidth * 1.5f).background(bar, RoundedCornerShape(8.dp))) + } + } + } + } +} + +// ── Long-press actions ─────────────────────────────────────── + +internal enum class TvRequestAction(val label: String) { + Cancel("Cancel Request"), + Approve("Approve"), + Decline("Decline"), + Retry("Retry"), +} + +/** The long-press actions a card offers right now; empty when it has none. */ +internal fun TvRequestItem.actions( + canCancel: (MediaRequest) -> Boolean, + canAct: (MediaRequest) -> Boolean, +): List = when (this) { + is TvRequestItem.Record -> if (canCancel(record)) listOf(TvRequestAction.Cancel) else emptyList() + is TvRequestItem.Approval -> if (!canAct(record)) { + emptyList() + } else { + when (val display = RequestDisplayState.of(record)) { + RequestDisplayState.Pending -> listOf(TvRequestAction.Approve, TvRequestAction.Decline) + is RequestDisplayState.NeedsAttention -> + if (display.attention == RequestAttention.Failed) listOf(TvRequestAction.Retry) else emptyList() + else -> emptyList() + } + } + is TvRequestItem.Result -> emptyList() +} + +/** [actions] is non-empty and [item] is a request card; the caller decides both. */ +@Composable +private fun TvRequestActionDialog( + item: TvRequestItem, + actions: List, + onDismiss: () -> Unit, + onAction: (TvRequestAction, MediaRequest) -> Unit, +) { + val record = when (item) { + is TvRequestItem.Record -> item.record + is TvRequestItem.Approval -> item.record + is TvRequestItem.Result -> return + } + var confirmingDecline by remember(item) { mutableStateOf(false) } + if (confirmingDecline) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = DarkSurfaceElevated, + titleContentColor = SiloOnSurface, + textContentColor = SiloOnSurface.copy(alpha = 0.76f), + title = { Text("Decline this request?", color = SiloOnSurface) }, + text = { + Text( + "${record.title} will show as declined to the person who asked for it.", + color = SiloOnSurface.copy(alpha = 0.76f), + ) + }, + confirmButton = { + // Keep first, so a stray press doesn't decide on someone's request. + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onDismiss) { Text("Keep") } + Button(onClick = { onAction(TvRequestAction.Decline, record) }) { Text("Decline") } + } + }, + ) + return + } + AlertDialog( + onDismissRequest = onDismiss, + containerColor = DarkSurfaceElevated, + titleContentColor = SiloOnSurface, + textContentColor = SiloOnSurface.copy(alpha = 0.76f), + title = { Text(record.title, color = SiloOnSurface) }, + text = { + Text( + if (item is TvRequestItem.Approval) "Requested by someone on this server." else RequestProgress.of(record).longLabel, + color = SiloOnSurface.copy(alpha = 0.76f), + ) + }, + confirmButton = { + // Close first, so a stray press doesn't send a non-retryable action. + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onDismiss) { Text("Close") } + actions.forEach { action -> + Button( + onClick = { + // Decline asks first. + if (action == TvRequestAction.Decline) confirmingDecline = true else onAction(action, record) + }, + ) { Text(action.label) } + } + } + }, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt deleted file mode 100644 index c1e63aa6fc..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt +++ /dev/null @@ -1,653 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.requests - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon as M3Icon -import androidx.compose.material3.OutlinedTextField -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.saveable.rememberSaveable -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 -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Button -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.koin.compose.viewmodel.koinViewModel -import org.siloserver.silo.model.request.CreateMediaRequest -import org.siloserver.silo.model.request.RequestAvailability -import org.siloserver.silo.model.request.RequestDiscoverySection -import org.siloserver.silo.model.request.RequestMediaResult -import org.siloserver.silo.model.request.RequestMediaType -import org.siloserver.silo.model.request.reasonMessage -import org.siloserver.silo.tv.ui.components.TvErrorScreen -import org.siloserver.silo.tv.ui.components.TvFilterChip -import org.siloserver.silo.tv.ui.components.TvLoadingScreen -import org.siloserver.silo.tv.ui.components.tvOutlinedTextFieldColors -import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts -import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult -import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.screens.search.TV_SEARCH_QUERY_MAX_LENGTH -import org.siloserver.silo.tv.ui.shell.TvTopMenuLayout -import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated -import org.siloserver.silo.tv.ui.theme.SiloBlue -import org.siloserver.silo.tv.ui.theme.SiloOnSurface -import org.siloserver.silo.tv.ui.theme.Spacing -import org.siloserver.silo.tv.ui.theme.sectionEyebrow -import org.siloserver.silo.viewmodel.RequestSearchViewModel -import org.siloserver.silo.viewmodel.RequestsViewModel - -private val requestMediaFilters = listOf( - RequestMediaType.All to "All", - RequestMediaType.Movie to "Movies", - RequestMediaType.Series to "Series", -) - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvRequestsScreen( - onOpenLibraryItem: (contentId: String) -> Unit, - onOpenMyRequests: () -> Unit, - onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit = { _, _ -> }, - onInitialContentFocus: () -> Unit = {}, - viewModel: RequestsViewModel = koinViewModel(), - searchViewModel: RequestSearchViewModel = koinViewModel(), - actionViewModel: TvRequestsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val searchState by searchViewModel.uiState.collectAsState() - val actionState by actionViewModel.actionState.collectAsState() - val visibleSearchResults = searchState.results.filterTvRequestResults() - val visibleDiscoverSections = state.sections.filterTvRequestSections() - val searchFieldFocusRequester = remember { FocusRequester() } - // Observed per REGION. requestFocusUntilObserved tests isFocused() before - // it requests anything, so a screen-wide flag meant the post-search claim - // was skipped outright: you are in the search field when the results land, - // the flag is already true, and focus never moves to them. - var focusedRegion by remember { mutableStateOf(null) } - val setFocusedRegion: (TvRequestsFocusRegion, Boolean) -> Unit = { region, focused -> - if (focused) { - focusedRegion = region - } else if (focusedRegion == region) { - focusedRegion = null - } - } - val firstFilterChipFocusRequester = remember { FocusRequester() } - val firstResultFocusRequester = remember { FocusRequester() } - val hasSubmittedQuery = searchState.hasSubmittedQuery - val hasSearchResults = visibleSearchResults.isNotEmpty() - val firstDiscoverSectionKey = visibleDiscoverSections.firstOrNull { it.results.isNotEmpty() }?.key - val hasDiscoverResults = firstDiscoverSectionKey != null - val showDiscoverRows = !hasSubmittedQuery || (searchState.error != null && hasDiscoverResults) - val hasFocusableResult = hasSearchResults || (showDiscoverRows && hasDiscoverResults) - var initialFocusRequested by remember { mutableStateOf(false) } - var focusResultsAfterSearch by remember { mutableStateOf(false) } - var pendingRequest by remember { mutableStateOf(null) } - // The highest successGeneration this screen has already refreshed for. - // rememberSaveable so a restored composition (config change, tab - // save/restore) doesn't re-run the success refresh for a create that was - // handled before the screen went away. - var consumedSuccessGeneration by rememberSaveable { mutableStateOf(0) } - - fun refreshRequests() { - actionViewModel.clearNotices() - viewModel.refresh() - } - - fun handleRequestItemClick(item: RequestMediaResult) { - if (actionState.submittingKey != null) return - when { - item.canOpenLibraryDetail() -> onOpenLibraryItem(item.libraryContentId.orEmpty()) - item.canRequest() -> pendingRequest = item - // Non-actionable (already requested / pending / unavailable): open the - // request detail so the user can see metadata + current status, instead - // of just a one-shot message (phone parity). - else -> onOpenRequestDetail(item.mediaType, item.tmdbId) - } - } - - LaunchedEffect(searchFieldFocusRequester) { - if (initialFocusRequested) return@LaunchedEffect - // Seventh false handover: onInitialContentFocus() told the shell content - // had focus regardless of whether the claim landed. - val landed = requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = searchFieldFocusRequester::requestFocus, - isFocused = { focusedRegion == TvRequestsFocusRegion.Field }, - ) - if (landed == TvObservedFocusResult.Focused) onInitialContentFocus() - initialFocusRequested = true - } - - LaunchedEffect(focusResultsAfterSearch, searchState.isLoading, hasSearchResults) { - if (focusResultsAfterSearch && !searchState.isLoading) { - val target = if (hasSearchResults) { - firstResultFocusRequester - } else { - firstFilterChipFocusRequester - } - val targetRegion = if (hasSearchResults) { - TvRequestsFocusRegion.Results - } else { - TvRequestsFocusRegion.Chips - } - requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = target::requestFocus, - isFocused = { focusedRegion == targetRegion }, - ) - focusResultsAfterSearch = false - } - } - - // Once a create succeeds, refresh discover/search so the just-requested card - // reflects its new status. The create itself runs in the ViewModel's scope - // (see TvRequestsViewModel) so it survives navigating away; this only tidies - // the rows when the user is still on this screen. Only UNCONSUMED - // generations fire the refresh — re-entering the screen with the ViewModel - // still holding a past success must not re-fire it; instead the stale - // "Request submitted." notice is dropped so it doesn't resurface. - LaunchedEffect(actionState.successGeneration) { - if (actionState.successGeneration > consumedSuccessGeneration) { - consumedSuccessGeneration = actionState.successGeneration - viewModel.refresh() - if (searchState.query.isNotBlank()) { - searchViewModel.search(searchState.page) - } - } else if (actionState.message != null) { - // Already-consumed success on re-entry: clear the success banner - // only (errors stay visible until acted on). - actionViewModel.consumeSuccessNotice() - } - } - - pendingRequest?.let { item -> - AlertDialog( - onDismissRequest = { pendingRequest = null }, - containerColor = DarkSurfaceElevated, - titleContentColor = SiloOnSurface, - textContentColor = SiloOnSurface.copy(alpha = 0.76f), - title = { Text("Request ${item.title}?") }, - text = { Text("This will submit a ${item.mediaType} request for your profile.") }, - confirmButton = { - Button( - onClick = { - pendingRequest = null - actionViewModel.submit(item.requestKey(), item.toCreateMediaRequest()) - }, - ) { - Text("Request") - } - }, - dismissButton = { - Button(onClick = { pendingRequest = null }) { - Text("Cancel") - } - }, - ) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - Column(modifier = Modifier.fillMaxSize()) { - RequestsHeader( - query = searchState.query, - mediaType = searchState.mediaType, - searchStatus = requestSearchStatusText( - query = searchState.submittedQuery, - total = visibleSearchResults.size, - isSearching = searchState.isLoading, - error = searchState.error, - ), - isRefreshing = state.isRefreshing || actionState.submittingKey != null, - searchFieldFocusRequester = searchFieldFocusRequester, - firstFilterChipFocusRequester = firstFilterChipFocusRequester, - firstResultFocusRequester = firstResultFocusRequester, - hasFocusableResult = hasFocusableResult, - onFieldFocusChanged = { setFocusedRegion(TvRequestsFocusRegion.Field, it) }, - onChipsFocusChanged = { setFocusedRegion(TvRequestsFocusRegion.Chips, it) }, - onQueryChanged = { query -> searchViewModel.onQueryChanged(query) }, - onSearch = { - focusResultsAfterSearch = searchState.query.isNotBlank() - searchViewModel.search() - }, - onMediaTypeChanged = { type -> - searchViewModel.onMediaTypeChanged(type) - if (searchState.query.isNotBlank()) { - searchViewModel.search() - } - }, - onRefresh = ::refreshRequests, - onOpenMyRequests = onOpenMyRequests, - ) - when { - state.isLoading && state.sections.isEmpty() -> TvLoadingScreen() - state.error != null && state.sections.isEmpty() && !hasSubmittedQuery -> TvErrorScreen( - message = state.error ?: "Requests are unavailable.", - onRetry = viewModel::load, - ) - visibleDiscoverSections.none { it.results.isNotEmpty() } && !hasSubmittedQuery -> RequestsEmptyState( - message = state.error ?: "Search movies and series to request them.", - ) - else -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .onFocusChanged { - setFocusedRegion(TvRequestsFocusRegion.Results, it.hasFocus) - }, - verticalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(bottom = 56.dp), - ) { - if (actionState.error != null || searchState.error != null || state.error != null || actionState.message != null) { - item(contentType = "request-notice") { - RequestNotice( - text = actionState.error ?: searchState.error ?: state.error ?: actionState.message.orEmpty(), - isError = actionState.error != null || searchState.error != null || state.error != null, - ) - } - } - - searchEmptyMessage( - query = searchState.query, - isSearching = searchState.isLoading, - hasSubmittedQuery = hasSubmittedQuery, - hasSearchResults = hasSearchResults, - hasSearchError = searchState.error != null, - )?.let { message -> - item(key = "search-empty", contentType = "request-search-empty") { - RequestSearchEmptyItem(message = message) - } - } - - if (hasSearchResults) { - item(key = "search-results", contentType = "request-search-results") { - RequestResultRow( - title = "Search Results", - results = visibleSearchResults, - firstItemFocusRequester = firstResultFocusRequester, - firstItemCardModifier = Modifier.focusProperties { - up = firstFilterChipFocusRequester - }, - submittingKey = actionState.submittingKey, - onItemClick = ::handleRequestItemClick, - ) - } - } - - if (showDiscoverRows) { - items( - visibleDiscoverSections, - key = { it.key }, - contentType = { "request-section" }, - ) { section -> - RequestSectionRow( - section = section, - firstItemFocusRequester = firstResultFocusRequester - .takeIf { !hasSearchResults && section.key == firstDiscoverSectionKey }, - firstItemCardModifier = Modifier.focusProperties { - up = firstFilterChipFocusRequester - }, - submittingKey = actionState.submittingKey, - onItemClick = ::handleRequestItemClick, - ) - } - } - item { Spacer(modifier = Modifier.height(8.dp)) } - } - } - } - - } -} - -@Composable -private fun RequestSectionRow( - section: RequestDiscoverySection, - firstItemFocusRequester: FocusRequester?, - firstItemCardModifier: Modifier = Modifier, - submittingKey: String?, - onItemClick: (RequestMediaResult) -> Unit, -) { - RequestResultRow( - title = section.title, - results = section.results, - firstItemFocusRequester = firstItemFocusRequester, - firstItemCardModifier = firstItemCardModifier, - submittingKey = submittingKey, - onItemClick = onItemClick, - ) -} - -@Composable -private fun RequestResultRow( - title: String, - results: List, - firstItemFocusRequester: FocusRequester?, - firstItemCardModifier: Modifier = Modifier, - submittingKey: String?, - onItemClick: (RequestMediaResult) -> Unit, -) { - if (results.isEmpty()) return - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - color = Color.White, - modifier = Modifier.padding(horizontal = Spacing.safeArea), - ) - LazyRow( - modifier = Modifier.focusGroup(), - horizontalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = 10.dp, - bottom = 22.dp, - ), - ) { - itemsIndexed( - results, - key = { _, item -> item.requestKey() }, - contentType = { _, _ -> "request-result" }, - ) { index, item -> - TvRequestCard( - result = if (submittingKey == item.requestKey()) { - item.copy(request = item.request.copy(status = "submitting", requestable = false)) - } else { - item - }, - onClick = { onItemClick(item) }, - focusRequester = firstItemFocusRequester.takeIf { index == 0 }, - cardModifier = if (index == 0) firstItemCardModifier else Modifier, - ) - } - } - } -} - -@Composable -private fun RequestSearchEmptyItem(message: String) { - Text( - text = message, - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.72f), - modifier = Modifier.padding(horizontal = Spacing.safeArea), - ) -} - -/** Focus regions a claim on this screen can aim at. */ -private enum class TvRequestsFocusRegion { Field, Chips, Results } - -@Composable -private fun RequestsHeader( - query: String, - mediaType: String?, - searchStatus: String?, - isRefreshing: Boolean, - searchFieldFocusRequester: FocusRequester, - firstFilterChipFocusRequester: FocusRequester, - firstResultFocusRequester: FocusRequester, - hasFocusableResult: Boolean, - onFieldFocusChanged: (Boolean) -> Unit, - onChipsFocusChanged: (Boolean) -> Unit, - onQueryChanged: (String) -> Unit, - onSearch: () -> Unit, - onMediaTypeChanged: (String?) -> Unit, - onRefresh: () -> Unit, - onOpenMyRequests: () -> Unit, -) { - Column( - modifier = Modifier.padding( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = TvTopMenuLayout.contentTopInset, - bottom = Spacing.lg, - ), - verticalArrangement = Arrangement.spacedBy(Spacing.md), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "REQUESTS", - style = sectionEyebrow, - color = SiloBlue.copy(alpha = 0.92f), - ) - if (searchStatus != null) { - RequestSearchStatusPill(text = searchStatus) - } - } - Column( - modifier = Modifier.widthIn(max = 410.dp), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - OutlinedTextField( - value = query, - onValueChange = { onQueryChanged(it.take(TV_SEARCH_QUERY_MAX_LENGTH)) }, - singleLine = true, - placeholder = { - Text( - text = "Search movies and series to request", - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.56f), - ) - }, - leadingIcon = { - M3Icon( - imageVector = Icons.Filled.Movie, - contentDescription = null, - tint = Color.White.copy(alpha = 0.72f), - ) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Search, - ), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), - textStyle = MaterialTheme.typography.bodyMedium.copy(color = Color.White), - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .onFocusChanged { onFieldFocusChanged(it.isFocused) } - .focusRequester(searchFieldFocusRequester) - .focusProperties { down = firstFilterChipFocusRequester }, - colors = tvOutlinedTextFieldColors(), - ) - LazyRow( - modifier = Modifier.onFocusChanged { onChipsFocusChanged(it.hasFocus) }, - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(end = Spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - itemsIndexed(requestMediaFilters) { index, (type, label) -> - val chipModifier = Modifier - .then( - if (index == 0) { - Modifier.focusRequester(firstFilterChipFocusRequester) - } else { - Modifier - }, - ) - .then( - if (index == 0 && hasFocusableResult) { - Modifier.focusProperties { down = firstResultFocusRequester } - } else { - Modifier - }, - ) - TvFilterChip( - text = label, - selected = mediaType == type, - onClick = { onMediaTypeChanged(type) }, - modifier = chipModifier, - ) - } - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Find Something New", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - TvRequestActionPill( - label = "My Requests", - icon = Icons.Filled.Inbox, - onClick = onOpenMyRequests, - ) - TvRequestActionPill( - label = if (isRefreshing) "Refreshing" else "Refresh", - icon = Icons.Filled.Refresh, - onClick = onRefresh, - enabled = !isRefreshing, - ) - } - } - } -} - -@Composable -private fun RequestsEmptyState(message: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - androidx.tv.material3.Icon( - imageVector = Icons.Filled.Movie, - contentDescription = null, - tint = Color.White.copy(alpha = 0.62f), - modifier = Modifier.height(30.dp), - ) - Text( - text = message, - style = MaterialTheme.typography.headlineSmall, - color = Color.White, - ) - } - } -} - -@Composable -private fun RequestSearchStatusPill(text: String) { - val shape = RoundedCornerShape(100.dp) - Box( - modifier = Modifier - .border(1.dp, Color.White.copy(alpha = 0.08f), shape) - .background(Color.White.copy(alpha = 0.045f), shape) - .padding(horizontal = 12.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.68f), - ) - } -} - -@Composable -private fun RequestNotice(text: String, isError: Boolean) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = if (isError) MaterialTheme.colorScheme.error else SiloBlue, - modifier = Modifier.padding(horizontal = Spacing.safeArea), - ) -} - -private fun RequestMediaResult.toCreateMediaRequest(): CreateMediaRequest = CreateMediaRequest( - mediaType = mediaType, - tmdbId = tmdbId, - title = title, - year = year, - overview = overview, - posterPath = posterPath, - backdropPath = backdropPath, -) - -private fun RequestMediaResult.requestKey(): String = "$mediaType-$tmdbId" - -private fun RequestMediaResult.nonActionableMessage(): String = when { - availability == RequestAvailability.Available -> "This title is already in your library." - request.status?.isNotBlank() == true -> "$title is already ${request.status}." - else -> request.reasonMessage() ?: "This title cannot be requested right now." -} - -private fun searchEmptyMessage( - query: String, - isSearching: Boolean, - hasSubmittedQuery: Boolean, - hasSearchResults: Boolean, - hasSearchError: Boolean, -): String? = when { - !hasSubmittedQuery -> "Search movies and series to request them." - isSearching -> "Searching..." - hasSearchError -> null - hasSearchResults -> null - else -> "No matches for \"${query.trim()}\"." -} - -private fun requestSearchStatusText( - query: String, - total: Int, - isSearching: Boolean, - error: String?, -): String? = when { - query.isBlank() -> null - isSearching -> "Searching..." - error != null && total == 0 -> "Search unavailable" - total == 0 -> "No results" - total == 1 -> "1 result" - else -> "$total results" -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsViewModel.kt deleted file mode 100644 index cf9154ad34..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsViewModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.requests - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.siloserver.silo.model.request.CreateMediaRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.errorMessage -import org.siloserver.silo.repository.RequestsRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * Owns request-creation for the TV Requests discover/search screen. - * - * The confirm dialog used to fire `repository.create` from the composable's - * [androidx.compose.runtime.rememberCoroutineScope], which is cancelled the - * moment the screen leaves composition — so tapping "My Requests" (or Back) - * right after confirming silently cancelled the in-flight POST and dropped the - * result state with the composition. Running it in [viewModelScope] keeps the - * create alive across the natural forward navigation to My Requests, and the - * outcome is published in [actionState] as durable state the screen observes - * instead of composition-local `remember`s. Mirrors the shared - * [org.siloserver.silo.viewmodel.RequestDetailViewModel.submitRequest] pattern - * the phone routes creation through. - */ -data class TvRequestsActionState( - val submittingKey: String? = null, - val message: String? = null, - val error: String? = null, - val successGeneration: Int = 0, -) - -class TvRequestsViewModel( - private val repository: RequestsRepository, -) : ViewModel() { - - private val _actionState = MutableStateFlow(TvRequestsActionState()) - val actionState: StateFlow = _actionState.asStateFlow() - - /** - * Submits [request], guarding against double-submit while any create is in - * flight. [requestKey] identifies the card so the row can render its - * submitting overlay; on success [TvRequestsActionState.successGeneration] - * ticks so the screen can refresh discover/search to pick up the new status. - */ - fun submit(requestKey: String, request: CreateMediaRequest) { - if (_actionState.value.submittingKey != null) return - _actionState.update { - it.copy(submittingKey = requestKey, message = null, error = null) - } - viewModelScope.launch { - when (val result = repository.create(request)) { - is ApiResult.Success -> { - _actionState.update { - it.copy( - submittingKey = null, - message = "Request submitted.", - error = null, - successGeneration = it.successGeneration + 1, - ) - } - } - is ApiResult.Error -> { - _actionState.update { - it.copy( - submittingKey = null, - error = result.errorMessage("Failed to submit request"), - ) - } - } - is ApiResult.NetworkError -> { - _actionState.update { - it.copy( - submittingKey = null, - error = "Network error. Check your connection.", - ) - } - } - } - } - } - - /** Clears any surfaced success/error notice (e.g. on manual refresh). */ - fun clearNotices() { - _actionState.update { it.copy(message = null, error = null) } - } - - /** - * Clears only the success notice, keeping any error visible. Called when - * the screen detects an already-consumed success on re-entry so a stale - * "Request submitted." doesn't resurface. - */ - fun consumeSuccessNotice() { - _actionState.update { it.copy(message = null) } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt index 1b31a9f2e5..f52d52b379 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.snapshotFlow +import org.siloserver.silo.common.requests.rememberRequestRouter import org.siloserver.silo.tv.ui.focus.TvReturnTarget import org.siloserver.silo.tv.ui.focus.TvReturnTargetSaver import org.siloserver.silo.tv.ui.focus.TvReturnRelocation @@ -86,7 +87,6 @@ import org.siloserver.silo.tv.ui.components.TvFilterChip import org.siloserver.silo.tv.ui.components.TvSectionHeader import org.siloserver.silo.tv.ui.components.tvOutlinedTextFieldColors import org.siloserver.silo.tv.ui.screens.requests.TvRequestCard -import org.siloserver.silo.tv.ui.screens.requests.canOpenLibraryDetail import org.siloserver.silo.tv.ui.screens.requests.filterTvRequestResults import org.siloserver.silo.tv.ui.shell.TvTopMenuLayout import org.siloserver.silo.tv.ui.theme.ElevatedSurface @@ -802,6 +802,7 @@ private fun TvRequestSearchSection( onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit, onOpenLibraryItem: (contentId: String) -> Unit, ) { + val router = rememberRequestRouter(org.koin.compose.koinInject(), onOpenLibraryItem, onOpenRequestDetail) if (!requestsEnabled || query.trim().length < 2 || !shouldShow) return // This section is a full-span footer item inside TvCatalogGrid, so the @@ -846,11 +847,7 @@ private fun TvRequestSearchSection( result = item, onClick = { onItemClicked(item, index) - if (item.canOpenLibraryDetail()) { - onOpenLibraryItem(item.libraryContentId.orEmpty()) - } else { - onOpenRequestDetail(item.mediaType, item.tmdbId) - } + router.openResult(item) }, // The restore target wins the slot when it is this // card: index zero can be both, and two requesters diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt index f9f3d70f9a..50b43e3f49 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.shape.CircleShape 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.Bookmark import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Favorite @@ -148,9 +147,8 @@ import org.siloserver.silo.tv.ui.screens.recommendations.TvRecommendationsScreen import org.siloserver.silo.tv.ui.screens.recommendations.SavedListSelection import org.siloserver.silo.tv.ui.screens.recommendations.TvForYouEntryRequest import org.siloserver.silo.tv.ui.screens.recommendations.TvForYouEntryRequestSaver -import org.siloserver.silo.tv.ui.screens.requests.TvMyRequestsScreen import org.siloserver.silo.tv.ui.screens.requests.TvRequestDetailScreen -import org.siloserver.silo.tv.ui.screens.requests.TvRequestsScreen +import org.siloserver.silo.tv.ui.screens.requests.TvRequestsPage import org.siloserver.silo.tv.ui.screens.search.TvSearchScreen import org.siloserver.silo.tv.ui.screens.settings.TvSettingsScreen import org.siloserver.silo.tv.ui.screens.watchtogether.TvJoinCodeDialog @@ -225,6 +223,7 @@ fun TvMainShell( val serverRegistry: ServerRegistry = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() + val requestsResolved by requestsFeatureStore.isResolved.collectAsState() val activeServerEntry by serverRegistry.activeEntry.collectAsState() val tvLibraryScopeStore: TvLibraryScopeStore = koinInject() // One shell-scoped Home owner feeds both the Home screen and General's @@ -285,8 +284,8 @@ fun TvMainShell( showAudiobooksTab = runCatching { tvLibraryScopeStore.getShowAudiobooksTab() }.getOrDefault(false) showAudiobooksTabResolved = true } - val visibleRoots = remember(libraries, showAudiobooksTab) { - visibleTvRoots(libraries, showAudiobooks = showAudiobooksTab) + val visibleRoots = remember(libraries, showAudiobooksTab, requestsEnabled) { + visibleTvRoots(libraries, showAudiobooks = showAudiobooksTab, requestsEnabled = requestsEnabled) } // In-session scope/pill selections per library type. Scope selections are @@ -344,6 +343,12 @@ fun TvMainShell( calendarFocusHandoffPending = false } } + // Requests owns its entry focus the way Calendar does: the page claims its + // first card (or the card it opened a detail from) and the bar stays out of + // focus search until it has. + var requestsFocusHandoffPending by remember(currentRoute) { + mutableStateOf(currentRoute == TvMainRoute.Requests.route) + } LaunchedEffect(activeServerEntry?.id, activeServerEntry?.profileId) { requestsFeatureStore.reset() @@ -547,6 +552,10 @@ fun TvMainShell( // top), while ordinary content re-entry keeps the focusRestorer()'s // last-focused card. var contentFocusRequest by remember { mutableIntStateOf(0) } + // Bumped only when the Requests tab is selected from the bar. Saveable, so + // a return to the shell from an outer route (a library title opened from + // Requests) restores the last card instead of reading as a fresh entry. + var requestsEntryRequest by rememberSaveable { mutableIntStateOf(0) } // Saveable, because the For You screen guards against replaying an entry // request with a SAVED "last applied sequence". A shell recreated with a // plain remember restarted the counter at 0 while the screen still held @@ -665,7 +674,7 @@ fun TvMainShell( } // Secondary routes (reached FROM another screen — Settings -> Favorites/ - // Watchlist/History/Collections/Requests, Requests -> MyRequests) push onto + // Watchlist/History/Collections, Requests -> request detail) push onto // the current route instead of flattening to the tab root, // so Back returns to the parent screen (e.g. Settings) rather than Home. val navigateToSecondary: (String) -> Unit = { route -> @@ -677,6 +686,14 @@ fun TvMainShell( } } + // Request detail can open another request detail ("More like this"). A + // single-top push would reuse the current entry, its view model and its + // back stack slot, so the new title would show the old one's state and + // Back would skip it. Each title gets its own entry. + val openRequestDetail: (String, Int) -> Unit = { mediaType, tmdbId -> + nestedNav.navigate(TvMainRoute.RequestDetail(mediaType, tmdbId).route) + } + // True when a requester actually took focus. `requestFocus()` throws rather // than returning false when its node has not composed yet, so each call has // to be guarded — and that guard is what used to swallow the failure whole. @@ -785,6 +802,10 @@ fun TvMainShell( if (dest == TvRootDestination.Calendar) { calendarFocusHandoffPending = true } + if (dest == TvRootDestination.Requests) { + requestsFocusHandoffPending = true + requestsEntryRequest++ + } // A dwell preview can still be open when Center commits For You (or a // library root). Close it without returning focus to the bar before the // content handoff, otherwise the overlay lingers and races page focus. @@ -816,6 +837,9 @@ fun TvMainShell( // transition. Leave focus on the Calendar tab until its screen is // composed, then let TvCalendarScreen target the filter directly. focusState.closeProfileMenuForContent() + } else if (dest == TvRootDestination.Requests) { + // The Requests page claims its first card from the shell token. + focusState.closeProfileMenuForContent() } else if (dest == TvRootDestination.Home) { // Home's contentFocusRequest targets row 0 / card 0 directly. // Re-entering the parent content focusRestorer here would restore @@ -900,7 +924,7 @@ fun TvMainShell( } } - LaunchedEffect(currentRoute, visibleRoots, librariesLoaded, showAudiobooksTabResolved) { + LaunchedEffect(currentRoute, visibleRoots, librariesLoaded, showAudiobooksTabResolved, requestsResolved) { // Wait until libraries have actually loaded — before that `visibleRoots` // is just Home + Calendar, and a restored/deep-linked `main/movies` route // would be wrongly ejected even though that type exists. Likewise wait for @@ -908,6 +932,10 @@ fun TvMainShell( // omits Audiobooks, so a restored `main/audiobooks` route would be ejected // to Home before the DataStore read completes. if (!librariesLoaded || !showAudiobooksTabResolved) return@LaunchedEffect + // Likewise the Requests tab appears only once the server's requests + // status has been read; a restored Requests route (or a return from + // a title opened there) must not be ejected while that read runs. + if (currentRoute == TvMainRoute.Requests.route && !requestsResolved) return@LaunchedEffect // Only media-root tabs are eligible for the "tab no longer visible" // redirect. Non-tab routes (Settings, Favorites, Search, …) map // to null and must be left alone — otherwise navigating to Settings @@ -1204,9 +1232,7 @@ fun TvMainShell( onOpenPersonDetail = onOpenPersonDetail, ) }, - onOpenRequestDetail = { mediaType, tmdbId -> - navigateToSecondary(TvMainRoute.RequestDetail(mediaType, tmdbId).route) - }, + onOpenRequestDetail = openRequestDetail, onOpenLibraryItem = onOpenItemDetail, onOpenPersonDetail = onOpenPersonDetail, searchFieldFocusRequester = searchInputFocusRequester, @@ -1318,22 +1344,21 @@ fun TvMainShell( ) } shellComposable(TvMainRoute.Requests.route) { - TvRequestsScreen( + TvRequestsPage( onOpenLibraryItem = onOpenItemDetail, - onOpenMyRequests = { navigateToSecondary(TvMainRoute.MyRequests.route) }, - onOpenRequestDetail = { mt, id -> - navigateToSecondary(TvMainRoute.RequestDetail(mt, id).route) + onOpenRequestDetail = openRequestDetail, + onInitialContentFocus = { + focusState.closeProfileMenuForContent() + requestsFocusHandoffPending = false }, - onInitialContentFocus = { focusState.closeProfileMenuForContent() }, - ) - } - shellComposable(TvMainRoute.MyRequests.route) { - TvMyRequestsScreen( - onOpenLibraryItem = onOpenItemDetail, - onOpenRequestDetail = { mt, id -> - navigateToSecondary(TvMainRoute.RequestDetail(mt, id).route) + onFocusHandoffFailed = { + requestsFocusHandoffPending = false + focusState.requestMenuFocus( + target = TvTopMenuPanel.Root(TvRootDestination.Requests), + suppressDwellPreview = true, + ) }, - onInitialContentFocus = { focusState.closeProfileMenuForContent() }, + focusRequest = requestsEntryRequest, ) } shellComposable( @@ -1347,6 +1372,8 @@ fun TvMainShell( mediaType = entry.arguments?.getString(TvMainRoute.RequestDetail.ARG_MEDIA_TYPE).orEmpty(), tmdbId = entry.arguments?.getInt(TvMainRoute.RequestDetail.ARG_TMDB_ID) ?: 0, onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, + onOpenLibraryItem = onOpenItemDetail, + onOpenRequestDetail = openRequestDetail, // Back closes an open panel or profile menu first, as // on every other screen; the shell's handler does that. backEnabled = focusState.openPanel == null && !focusState.profileMenuOpen, @@ -1414,6 +1441,16 @@ fun TvMainShell( onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } + // My Requests became the Requests tab's "Your requests" row; a + // saved back stack naming it lands on the tab. + composable(route = "main/requests/mine") { + LaunchedEffect(Unit) { + nestedNav.navigate(TvMainRoute.Requests.route) { + popUpTo("main/requests/mine") { inclusive = true } + launchSingleTop = true + } + } + } // ---- Removed route aliases (defensive) ---- see // [TvRemovedMainRoutes]. Registered, never rendered: each one // redirects into Settings so a back stack saved by a build that @@ -1483,7 +1520,9 @@ fun TvMainShell( // still pops the route via DelegateToNav. isFocusSuppressed = focusState.isMenuFocusSuppressed || calendarFocusHandoffPending || - currentRoute == TvMainRoute.Settings.route, + requestsFocusHandoffPending || + currentRoute == TvMainRoute.Settings.route || + currentRoute == TvMainRoute.RequestDetail.ROUTE, focusRequest = focusState.menuFocusRequest, focusRequestTarget = focusState.menuFocusTarget, focusRequestSuppressesDwell = focusState.menuFocusSuppressesDwell, @@ -1492,7 +1531,13 @@ fun TvMainShell( onInstallAnchorFocus = { hook -> focusState.focusBarAnchorNow = hook }, profileFocusRequest = focusState.profileFocusRequest, isSearchActive = currentRoute == TvMainRoute.Search.route, - visibility = if (currentRoute == TvMainRoute.Settings.route) 0f else menuVisibility.value, + // Request detail is a full detail page like a library title's, + // which has no bar either (tvOS pushes it over the tab). + visibility = if (currentRoute == TvMainRoute.Settings.route || currentRoute == TvMainRoute.RequestDetail.ROUTE) { + 0f + } else { + menuVisibility.value + }, openPanel = focusState.openPanel, onDwell = focusState::previewPanel, onEnterPanel = focusState::enterPanel, @@ -1627,11 +1672,6 @@ fun TvMainShell( navigateToSecondary(TvMainRoute.History.route) moveFocusToContent(TvMainRoute.History.route) }, - showRequests = requestsEnabled, - onRequests = closeMenuAnd { - navigateToSecondary(TvMainRoute.Requests.route) - moveFocusToContent(TvMainRoute.Requests.route) - }, showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED, onWatchTogether = { focusState.closeProfileMenuForContent() @@ -1799,8 +1839,9 @@ private fun mapRouteToRoot(route: String): TvRootDestination? = when (route) { TvMainRoute.Audiobooks.route -> TvRootDestination.LibraryType(TvLibraryTabType.Audiobooks) TvMainRoute.Calendar.route -> TvRootDestination.Calendar TvMainRoute.ForYou.route -> TvRootDestination.ForYou + TvMainRoute.Requests.route -> TvRootDestination.Requests // Search maps to null so no top tab is highlighted (trailing icon). - // Requests/MyRequests/Settings/Audio/Libraries are likewise non-tab. + // Request detail/Settings/Audio/Libraries are likewise non-tab. else -> null } @@ -1815,6 +1856,7 @@ private fun TvRootDestination.toRoute(): String = when (this) { TvRootDestination.Home -> TvMainRoute.Home.route TvRootDestination.ForYou -> TvMainRoute.ForYou.route TvRootDestination.Calendar -> TvMainRoute.Calendar.route + TvRootDestination.Requests -> TvMainRoute.Requests.route is TvRootDestination.LibraryType -> when (type) { TvLibraryTabType.Movies -> TvMainRoute.Movies.route TvLibraryTabType.Series -> TvMainRoute.Series.route @@ -1874,8 +1916,8 @@ private fun cascadePanelOffset( * returns focus to the avatar via [onDismiss]. * * Row set + order mirrors tvOS: Switch Profile · Watchlist · Favorites · - * History · Requests (server-gated) · Watch Together (client-policy-gated) · - * Settings · Switch Server · Sign Out. Calendar is a top-level tab. + * History · Watch Together (client-policy-gated) · Settings · Switch Server · + * Sign Out. Calendar and Requests are top-level tabs. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -1887,8 +1929,6 @@ private fun TvProfileDropdown( onWatchlist: () -> Unit, onFavorites: () -> Unit, onHistory: () -> Unit, - showRequests: Boolean, - onRequests: () -> Unit, showWatchTogether: Boolean, onWatchTogether: () -> Unit, onSettings: () -> Unit, @@ -1936,13 +1976,6 @@ private fun TvProfileDropdown( ProfileDropdownRow(label = "Watchlist", icon = Icons.Filled.Bookmark, onClick = onWatchlist) ProfileDropdownRow(label = "Favorites", icon = Icons.Filled.Favorite, onClick = onFavorites) ProfileDropdownRow(label = "History", icon = Icons.Filled.History, onClick = onHistory) - if (showRequests) { - ProfileDropdownRow( - label = "Requests", - icon = Icons.Filled.AutoAwesome, - onClick = onRequests, - ) - } if (showWatchTogether) { ProfileDropdownRow( label = "Watch Together", diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinations.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinations.kt index c4c1f7bffe..02fd91f020 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinations.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinations.kt @@ -6,8 +6,8 @@ import org.siloserver.silo.tv.ui.navigation.TvMainRoute /** * Skyline content-type-first shell (§3.1): a fixed root order of `Home`, then * one tab per [TvLibraryTabType] the profile can actually see (a library of - * that type exists), then `Calendar`. Search and For You are no longer tabs — - * Search is a trailing icon button and For You is reached as a Home row. + * that type exists), then For You and `Calendar`, then Requests while the + * server has it enabled. Search is a trailing icon button. * * Mirrors tvOS `TVMainTabView.visibleRoots`. */ @@ -16,6 +16,9 @@ fun visibleTvRoots( /** tvOS navPrefs.showAudiobooks parity: the Audiobooks tab is opt-in * (hidden by default) even when an audiobook library exists. */ showAudiobooks: Boolean = false, + /** Requests is a server capability, not a customizable menu item: it + * trails the content tabs whenever the server has it enabled. */ + requestsEnabled: Boolean = false, ): List = buildList { add(TvRootDestination.Home) TvLibraryTabType.entries @@ -25,6 +28,7 @@ fun visibleTvRoots( // tvOS root order: libraries, then For You, then Calendar. add(TvRootDestination.ForYou) add(TvRootDestination.Calendar) + if (requestsEnabled) add(TvRootDestination.Requests) } fun firstTvRoute(): String = TvMainRoute.Home.route diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvRootDestination.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvRootDestination.kt index d9bd9eb65b..3a1971c0b0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvRootDestination.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvRootDestination.kt @@ -18,6 +18,12 @@ sealed class TvRootDestination { /** Personal recommendations ("For You") — tvOS `.recommendations`. */ data object ForYou : TvRootDestination() - /** Upcoming releases by week. Always the last tab. */ + /** Upcoming releases by week. */ data object Calendar : TvRootDestination() + + /** + * Media requests, tvOS `.requests`. Present only while the server has + * requests enabled for this profile; it trails the content tabs. + */ + data object Requests : TvRootDestination() } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvTopMenuBar.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvTopMenuBar.kt index efaaaac6a4..a8e5de4fa9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvTopMenuBar.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvTopMenuBar.kt @@ -127,6 +127,7 @@ private sealed class TvTopMenuFocus { data class Tab(val type: TvLibraryTabType) : TvTopMenuFocus() data object ForYou : TvTopMenuFocus() data object Calendar : TvTopMenuFocus() + data object Requests : TvTopMenuFocus() data object Search : TvTopMenuFocus() data object Profile : TvTopMenuFocus() } @@ -202,6 +203,7 @@ fun TvTopMenuBar( val homeFocusRequester = remember { FocusRequester() } val calendarFocusRequester = remember { FocusRequester() } val forYouFocusRequester = remember { FocusRequester() } + val requestsFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() } val profileFocusRequester = remember { FocusRequester() } // One requester per library-type tab; stable across recompositions so a @@ -244,6 +246,7 @@ fun TvTopMenuBar( TvRootDestination.Home -> TvTopMenuFocus.Home TvRootDestination.ForYou -> TvTopMenuFocus.ForYou TvRootDestination.Calendar -> TvTopMenuFocus.Calendar + TvRootDestination.Requests -> TvTopMenuFocus.Requests is TvRootDestination.LibraryType -> TvTopMenuFocus.Tab(root.type) } @@ -257,6 +260,7 @@ fun TvTopMenuBar( is TvTopMenuFocus.Tab -> tabFocusRequesters[focus.type] ?: homeFocusRequester TvTopMenuFocus.ForYou -> forYouFocusRequester TvTopMenuFocus.Calendar -> calendarFocusRequester + TvTopMenuFocus.Requests -> requestsFocusRequester TvTopMenuFocus.Search -> searchFocusRequester TvTopMenuFocus.Profile -> profileFocusRequester } @@ -271,6 +275,7 @@ fun TvTopMenuBar( fun selectedEntryRequester(): FocusRequester = when (val root = selectedRoot) { TvRootDestination.Home -> homeFocusRequester TvRootDestination.Calendar -> calendarFocusRequester + TvRootDestination.Requests -> requestsFocusRequester is TvRootDestination.LibraryType -> tabFocusRequesters[root.type] ?: homeFocusRequester TvRootDestination.ForYou -> forYouFocusRequester null -> if (isSearchActive) searchFocusRequester else homeFocusRequester @@ -631,6 +636,22 @@ fun TvTopMenuBar( }, onClick = { onSelectRoot(TvRootDestination.Calendar) }, ) + + TvRootDestination.Requests -> TvTopMenuTab( + label = "Requests", + isSelected = selectedRoot == TvRootDestination.Requests, + isFocused = focusedButton == TvTopMenuFocus.Requests, + canFocus = canFocusButton(TvTopMenuFocus.Requests), + focusRequester = requestsFocusRequester, + onFocusChanged = { hasFocus -> + focusedButton = if (hasFocus) { + TvTopMenuFocus.Requests + } else { + focusedButton.takeUnless { it == TvTopMenuFocus.Requests } + } + }, + onClick = { onSelectRoot(TvRootDestination.Requests) }, + ) } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/TvUsabilityGuardTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/TvUsabilityGuardTest.kt index 9f70ce911a..f7b3700141 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/TvUsabilityGuardTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/TvUsabilityGuardTest.kt @@ -52,11 +52,12 @@ class TvUsabilityGuardTest { assertTrue(destinations.contains("TvRootDestination.LibraryType")) assertTrue(destinations.contains("TvRootDestination.Calendar")) // For You IS a root tab again (QA 2026-07-08, tvOS .recommendations - // parity, with its Watchlist/Favorites dropdown). Search stays a + // parity, with its Watchlist/Favorites dropdown), and so is Requests + // while the server enables it (tvOS `.requests`). Search stays a // trailing icon, and secondary utility surfaces never become tabs. assertTrue(destinations.contains("TvRootDestination.ForYou")) + assertTrue(destinations.contains("if (requestsEnabled) add(TvRootDestination.Requests)")) assertFalse(destinations.contains("TvRootDestination.Search")) - assertFalse(destinations.contains("TvRootDestination.Requests")) assertFalse(destinations.contains("TvRootDestination.Settings")) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinationsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinationsTest.kt index c1ce5d6f30..ad8992ca6a 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinationsTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvMediaDestinationsTest.kt @@ -64,6 +64,22 @@ class TvMediaDestinationsTest { ) } + @Test + fun requestsTrailsTheContentTabsOnlyWhileTheServerEnablesIt() { + val libraries = listOf(library(1, "movies")) + assertEquals( + listOf( + TvRootDestination.Home, + TvRootDestination.LibraryType(TvLibraryTabType.Movies), + TvRootDestination.ForYou, + TvRootDestination.Calendar, + TvRootDestination.Requests, + ), + visibleTvRoots(libraries, requestsEnabled = true), + ) + assertEquals(false, TvRootDestination.Requests in visibleTvRoots(libraries, requestsEnabled = false)) + } + @Test fun firstRouteIsAlwaysHome() { assertEquals(TvMainRoute.Home.route, firstTvRoute()) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index 0c942099d9..9d0a77b07e 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -19,6 +19,7 @@ import org.siloserver.silo.repository.PlaybackRepository import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.PushRegistrationRepository import org.siloserver.silo.repository.RecommendationRepository +import org.siloserver.silo.repository.RequestDetailCache import org.siloserver.silo.repository.RequestsRepository import org.siloserver.silo.repository.SectionRepository import org.siloserver.silo.repository.SettingsRepository @@ -94,7 +95,16 @@ val repositoryModule = module { ) } single { RecommendationRepository(get()) } - single { RequestsRepository(get()) } + single { + RequestsRepository( + api = get(), + cache = RequestDetailCache( + prefetchScope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate, + ), + ), + ) + } single { RequestsFeatureStore(get()) } single { ActiveProfileStore(get()) } single { org.siloserver.silo.repository.MetadataAiRepository(get()) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStore.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStore.kt index 6390325b04..eeb332103c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStore.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStore.kt @@ -12,7 +12,15 @@ import org.siloserver.silo.repository.RequestsRepository * Matches the Apple clients: entry points start hidden, a successful * `/api/v2/requests/status` probe controls visibility, transient failures keep * the previous value, and reset hides the surface before a server/profile switch - * can reuse stale capability state. + * can reuse stale capability state. Reset also clears [RequestsRepository]'s + * session state, since both shells call it on sign-in, sign-out, and every + * server or profile switch. The feature counts only when the server + * says this profile is `allowed` and the state is `available`. + * + * [canModerate] says whether the signed-in user can approve, decline, and retry + * everyone's requests: an admin acting as the primary profile on a server with + * the request integration configured. It is probed only once requests are + * enabled, and is false on any answer other than `available: true`. */ class RequestsFeatureStore( private val repository: RequestsRepository, @@ -20,6 +28,13 @@ class RequestsFeatureStore( private val _isEnabled = MutableStateFlow(false) val isEnabled: StateFlow = _isEnabled.asStateFlow() + private val _canModerate = MutableStateFlow(false) + val canModerate: StateFlow = _canModerate.asStateFlow() + + private val _isResolved = MutableStateFlow(false) + /** A probe has answered (or failed) since the last [reset]; until then [isEnabled] is only a default. */ + val isResolved: StateFlow = _isResolved.asStateFlow() + // Written on the UI dispatcher (reset) and read after IO resumption // (refresh); Volatile gives the stale-response guard a happens-before. @kotlin.concurrent.Volatile @@ -27,20 +42,39 @@ class RequestsFeatureStore( suspend fun refresh() { val refreshGeneration = generation - when (val result = repository.status()) { - is ApiResult.Success -> { - if (refreshGeneration == generation) { - _isEnabled.value = result.data.requestsEnabled - } - } + val enabled = when (val result = repository.status()) { + is ApiResult.Success -> result.data.isAvailable + // A transient failure shouldn't yank an already-visible entry point. is ApiResult.Error, is ApiResult.NetworkError, - -> Unit + -> _isEnabled.value + } + if (refreshGeneration != generation) return + val moderates = if (!enabled) { + false + } else { + when (val result = repository.adminCapabilities()) { + is ApiResult.Success -> result.data.available + // The server answered: not an admin, not the primary profile, + // or no integration configured. + is ApiResult.Error -> false + // Transport trouble: keep the previous value. + is ApiResult.NetworkError -> _canModerate.value + } } + if (refreshGeneration != generation) return + // Moderation first: a screen that appears when Requests does already + // knows whether to load the approval queue. + _canModerate.value = moderates + _isEnabled.value = enabled + _isResolved.value = true } fun reset() { generation += 1 + repository.reset() _isEnabled.value = false + _canModerate.value = false + _isResolved.value = false } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestDisplayState.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestDisplayState.kt new file mode 100644 index 0000000000..aeb296a00b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestDisplayState.kt @@ -0,0 +1,328 @@ +package org.siloserver.silo.model.request + +/** + * The five user-facing request states, shared by every request surface on + * phone and TV (and matching the Apple clients' `RequestDisplayState`), so a + * card, a list row, the detail page, and My Requests bucketing can never + * disagree about what a given server state looks like. + */ +sealed interface RequestDisplayState { + /** Submitted, awaiting approval. Cancelable by the requester. */ + data object Pending : RequestDisplayState + + /** + * Approved, queued, or downloading, including a finished download the + * library hasn't picked up yet. + */ + data object OnTheWay : RequestDisplayState + + /** The title is in the library (or, from a server without `state`, the request completed). */ + data object InLibrary : RequestDisplayState + + /** Declined or failed; the detail page says which, and why. */ + data class NeedsAttention(val attention: RequestAttention, val reason: String?) : RequestDisplayState + + /** + * Not requestable and no request exists (limit reached, requests off, + * blocked), or the request was cancelled. The request affordance simply + * doesn't render. + */ + data class Unavailable(val reason: String?) : RequestDisplayState + + val label: String + get() = when (this) { + Pending -> "Pending" + OnTheWay -> "On the way" + InLibrary -> "In library" + is NeedsAttention -> "Needs attention" + is Unavailable -> "Unavailable" + } + + val tint: RequestStatusTint + get() = when (this) { + Pending -> RequestStatusTint.Amber + OnTheWay -> RequestStatusTint.Sky + InLibrary -> RequestStatusTint.Emerald + is NeedsAttention -> RequestStatusTint.Rose + is Unavailable -> RequestStatusTint.Neutral + } + + /** The owner may cancel only while the request waits for approval. */ + val isCancelable: Boolean + get() = this == Pending + + /** The detail page's status line, which has room to say more than a caption. */ + val detailTitle: String + get() = when (this) { + Pending -> "Requested · Pending" + OnTheWay -> "On the way" + InLibrary -> "In your library" + is NeedsAttention -> requestReasonCopy(reason)?.let { "${attention.title} · $it" } ?: attention.title + is Unavailable -> requestReasonCopy(reason) ?: "Unavailable" + } + + /** + * The catalog item to open instead of the request, when there is nothing + * about the request left to show: only a state that reads "In library", + * with a known item. A title in the library that still has an active + * request (missing seasons, a failure) opens the request, as its status says. + */ + fun libraryItemToOpen(contentId: String?): String? = + contentId?.takeIf { this == InLibrary && it.isNotBlank() } + + companion object { + /** + * From a full request record: My Requests, the hub strip, admin rows. + * A decline or withdrawal explains itself in `outcome_reason`; a failure + * in `last_error` (sent to admins only). + */ + fun of(record: MediaRequest): RequestDisplayState { + val closedByDecision = record.state == RequestUserState.Declined || record.state == RequestUserState.Cancelled || + (record.state == null && (record.outcome == RequestOutcome.Declined || record.outcome == RequestOutcome.Cancelled)) + val reason = if (closedByDecision) record.outcomeReason else record.lastError.ifBlank { record.outcomeReason } + return of(state = record.state, status = record.status, outcome = record.outcome, reason = reason) + } + + /** The server's `state` decides when present and recognized; `status` and `outcome` otherwise. */ + fun of(state: String?, status: String, outcome: String, reason: String? = null): RequestDisplayState = + fromUserState(state, reason) ?: fromStatus(status, outcome, reason) + + /** + * From a search/discover/detail card's compact annotation. Null when the + * card has no state to show (requestable, never requested): that's the + * Request affordance, not a status. + * + * An active request's `state` comes before availability: a series in the + * library can have a request for its missing seasons, and the card must + * agree with My Requests about it. `request.reason` says why the title + * can't be requested, not why a request failed, so only a title with no + * request shows it. + */ + fun of(availability: String, request: RequestState): RequestDisplayState? { + fromUserState(request.state, reason = null)?.let { return it } + if (availability == RequestAvailability.Available) return InLibrary + request.status?.takeIf { it.isNotBlank() }?.let { + return fromStatus(it, RequestOutcome.Active, reason = null) + } + if (request.requestable) return null + return Unavailable(request.reason.takeIf { it.isNotBlank() }) + } + + /** Null for an absent or unknown state, so a newer server's state falls back to `status`. */ + private fun fromUserState(state: String?, reason: String?): RequestDisplayState? = when (state) { + RequestUserState.Pending -> Pending + // Some requested seasons are in, the rest are still coming: + // "In library" would file it as landed before it has. + RequestUserState.Approved, RequestUserState.Processing, RequestUserState.PartiallyAvailable -> OnTheWay + RequestUserState.Available -> InLibrary + RequestUserState.Declined -> NeedsAttention(RequestAttention.Declined, reason.nonBlank()) + RequestUserState.Failed -> NeedsAttention(RequestAttention.Failed, reason.nonBlank()) + RequestUserState.Cancelled -> Unavailable(reason.nonBlank()) + else -> null + } + + /** + * The mapping for servers without `state`, which can't tell a finished + * download from a title in the library: `completed` reads as in library. + * `outcome` wins over `status` for terminal states. + */ + private fun fromStatus(status: String, outcome: String, reason: String?): RequestDisplayState { + when (outcome) { + RequestOutcome.Declined -> return NeedsAttention(RequestAttention.Declined, reason.nonBlank()) + RequestOutcome.Failed -> return NeedsAttention(RequestAttention.Failed, reason.nonBlank()) + RequestOutcome.Cancelled -> return Unavailable(reason.nonBlank()) + } + return when (status) { + RequestStatus.Pending -> Pending + RequestStatus.Completed -> InLibrary + RequestStatus.Failed -> NeedsAttention(RequestAttention.Failed, reason.nonBlank()) + // An unknown status reads as in flight rather than broken: a newer + // server's added pipeline stage shouldn't read as an error. + else -> OnTheWay + } + } + + private fun String?.nonBlank(): String? = this?.takeIf { it.isNotBlank() } + } +} + +/** Why a request needs attention. A declined request and a failed one need different things from the user. */ +enum class RequestAttention(val title: String) { + Declined("Declined"), + Failed("Request failed"), +} + +/** The status color; the UI layer maps each to its theme color. */ +enum class RequestStatusTint { Amber, Sky, Emerald, Rose, Neutral } + +/** The four steps every request walks through, drawn as the stage track. */ +enum class RequestStep(val title: String) { + Requested("Requested"), + Approval("Approved"), + Download("Downloading"), + Library("In your library"), +} + +/** + * Where a request sits on the stage track, plus the finer labels the five + * display states can't express ("Queued" vs "Downloading" vs "Adding to + * library"). Derived from [RequestDisplayState], so the track, the dot color, + * and My Requests bucketing always agree. + */ +data class RequestProgress( + val display: RequestDisplayState, + /** Steps fully behind the request (0..4). Drawn solid. */ + val completedSteps: Int, + /** The step the request is on, drawn in [tint]. Null once every step is done, or off the track. */ + val currentStep: RequestStep?, + /** One or two words for card captions. */ + val shortLabel: String, + /** A plain-language phrase for list rows and the TV preview panel. */ + val longLabel: String, +) { + val tint: RequestStatusTint get() = display.tint + + companion object { + /** From a full request record. */ + fun of(record: MediaRequest): RequestProgress = + of(RequestDisplayState.of(record), state = record.state, status = record.status) + + /** From a card's compact annotation; null when there is no state to show. */ + fun of(availability: String, request: RequestState): RequestProgress? = + RequestDisplayState.of(availability, request)?.let { of(it, state = request.state, status = request.status) } + + /** The display state decides the bucket and color; `state` and `status` only place an in-flight request. */ + fun of(display: RequestDisplayState, state: String?, status: String?): RequestProgress = when (display) { + RequestDisplayState.Pending -> + RequestProgress(display, 1, RequestStep.Approval, "Pending", "Waiting for approval") + RequestDisplayState.OnTheWay -> { + val (step, short, long) = inFlightPhase(state, status) + RequestProgress(display, step.ordinal, step, short, long) + } + RequestDisplayState.InLibrary -> + RequestProgress(display, RequestStep.entries.size, null, "In library", "In your library") + is RequestDisplayState.NeedsAttention -> when (display.attention) { + RequestAttention.Declined -> RequestProgress(display, 1, RequestStep.Approval, "Declined", "Declined") + RequestAttention.Failed -> RequestProgress(display, 2, RequestStep.Download, "Failed", "Request failed") + } + is RequestDisplayState.Unavailable -> + RequestProgress(display, 0, null, display.label, display.detailTitle) + } + + /** + * Where an approved-but-unfinished request is. `partially_available` and + * a finished download the library hasn't picked up yet are both on the + * last step; everything else is queued or downloading. + */ + private fun inFlightPhase(state: String?, status: String?): Triple { + if (state == RequestUserState.PartiallyAvailable) { + return Triple(RequestStep.Library, "Partly in library", "Partly in your library") + } + return when (status) { + RequestStatus.Downloading -> Triple(RequestStep.Download, "Downloading", "Downloading") + RequestStatus.Completed -> Triple(RequestStep.Library, "Adding to library", "Adding to your library") + RequestStatus.Approved, RequestStatus.Queued -> Triple(RequestStep.Download, "Queued", "Queued for download") + else -> Triple(RequestStep.Download, "On the way", "On the way") + } + } + } +} + +/** + * The My Requests sections (and filters), derived from [RequestDisplayState]. + * Requests that need the user sit above finished ones. Cancelled requests drop + * off the list. + */ +enum class MyRequestsBucket(val title: String) { + InMotion("In progress"), + NeedsAttention("Needs you"), + Landed("Available"), + ; + + companion object { + fun of(state: RequestDisplayState): MyRequestsBucket? = when (state) { + RequestDisplayState.Pending, RequestDisplayState.OnTheWay -> InMotion + RequestDisplayState.InLibrary -> Landed + is RequestDisplayState.NeedsAttention -> NeedsAttention + is RequestDisplayState.Unavailable -> null + } + + /** Ordered, non-empty buckets, newest first within each. */ + fun bucket(requests: List): List>> { + val grouped = requests.groupBy { of(RequestDisplayState.of(it)) } + return entries.mapNotNull { bucket -> + grouped[bucket]?.takeIf { it.isNotEmpty() }?.let { bucket to it.sortedByDescending { r -> r.createdAt.requestInstantKey() } } + } + } + } +} + +/** Per-quality target copy. */ +object RequestTargetSummary { + /** + * "1080p downloading · 4K queued" for a request fanned out to more than + * one quality. Null for single-target requests, whose one state is the + * request's own. + */ + fun text(targets: List): String? { + if (targets.size <= 1) return null + val parts = targets.mapNotNull { target -> + val quality = target.quality.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val word = when (target.status) { + RequestStatus.Completed -> "done" + RequestStatus.Downloading -> "downloading" + RequestStatus.Queued, RequestStatus.Approved -> "queued" + RequestStatus.Pending -> "pending" + RequestStatus.Failed -> "failed" + else -> return@mapNotNull null + } + "$quality $word" + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") + } + + /** The requested qualities, for rows that aren't in flight: "1080p · 4K". */ + fun qualities(targets: List): String? = + targets.map { it.quality }.filter { it.isNotBlank() }.takeIf { it.isNotEmpty() }?.joinToString(" · ") +} + +/** + * Copy for a request reason: a server token (`quota_exceeded`, …) becomes a + * phrase; readable text from the server (a `last_error` sentence) shows as + * written; an unknown code shows nothing rather than a raw code. + */ +fun requestReasonCopy(token: String?): String? { + val value = token?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return when (value) { + RequestReason.AlreadyRequested -> "Already requested" + RequestReason.AlreadyAvailable -> "Already in your library" + RequestReason.QuotaExceeded, "limit_reached" -> "Request limit reached" + RequestReason.RequestsDisabled -> "Requests are turned off" + RequestReason.Blocked, "requesting_blocked" -> "You can't request media right now" + "validation_failed" -> "That request couldn't be submitted" + "invalid_state" -> "This request can no longer be changed" + "not_found" -> "This title is no longer available" + RequestUnconfirmedToken -> "Not confirmed yet" + // Server sentences can arrive lowercase ("one or more fulfillment + // targets failed"); they read as their own line. + else -> value.takeUnless { RequestReasonCodePattern.matches(it) }?.replaceFirstChar { it.uppercase() } + } +} + +/** Local reason for a create whose outcome is not known yet; shown on the held primary action. */ +const val RequestUnconfirmedToken = "request_unconfirmed" + +private val RequestReasonCodePattern = Regex("[a-z0-9_]+") + +/** + * A sort key for a server timestamp: RFC 3339 instants compare as text once the + * fraction is padded, so sorting needs no date library. Unparseable values sort + * as written. + */ +internal fun String.requestInstantKey(): String { + val match = RequestInstantPattern.matchEntire(this) ?: return this + val (head, fraction, zone) = match.destructured + return if (zone == "Z") head + "." + fraction.padEnd(9, '0') + "Z" else this +} + +private val RequestInstantPattern = Regex("""(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})""") diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt index 36613abbc2..bee96fd42a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt @@ -29,7 +29,7 @@ object RequestOutcome { /** * Values of [RequestState.reason]: why a title cannot be requested. They are - * codes for deciding what to offer, not text; show [reasonMessage] instead. + * codes for deciding what to offer, not text; show [requestReasonCopy] instead. */ object RequestReason { const val AlreadyRequested = "already_requested" @@ -44,9 +44,30 @@ object RequestAvailability { const val Available = "available" } +/** + * Values of the server's `state`: the one state to show a user for a request, + * derived from its status, outcome, servers and library presence. It says what + * `status` and `outcome` cannot: a finished download the library has not + * picked up yet is `processing`, not `available`. Older servers omit it, and a + * value this client does not know falls back to `status` and `outcome`. + */ +object RequestUserState { + const val Pending = "pending" + const val Approved = "approved" + const val Processing = "processing" + /** Some of a season request's seasons are in the library, not all. */ + const val PartiallyAvailable = "partially_available" + const val Available = "available" + const val Declined = "declined" + const val Cancelled = "cancelled" + const val Failed = "failed" +} + @Serializable data class RequestState( val status: String? = null, + /** The active request's user-facing [RequestUserState]; absent on older servers. */ + val state: String? = null, val requestable: Boolean = false, val reason: String = "", @SerialName("request_id") val requestId: String? = null, @@ -181,6 +202,8 @@ data class MediaRequest( @SerialName("backdrop_path") val backdropPath: String? = null, val status: String, val outcome: String, + /** What to show the user ([RequestUserState]); absent on older servers. */ + val state: String? = null, @SerialName("requested_by_user_id") val requestedByUserId: String? = null, @SerialName("requested_by_profile_id") val requestedByProfileId: String = "", @SerialName("integration_kind") val integrationKind: String = "", @@ -189,7 +212,10 @@ data class MediaRequest( @SerialName("external_id") val externalId: String = "", @SerialName("external_status") val externalStatus: String = "", @SerialName("library_content_id") val libraryContentId: String? = null, + /** Admins only: why the last submission to a download server failed. */ @SerialName("last_error") val lastError: String = "", + /** Why the request was declined or withdrawn, when a reason was given. */ + @SerialName("outcome_reason") val outcomeReason: String = "", @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, @SerialName("approved_at") val approvedAt: String? = null, @@ -204,7 +230,35 @@ data class RequestsListResponse( @kotlinx.serialization.Required val page: org.siloserver.silo.network.apiv2.PageInfo = org.siloserver.silo.network.apiv2.PageInfo(hasMore = false), ) +/** + * `GET /api/v2/requests/status`. The contract requires `allowed` and `state`; + * they decode as optional so a response without them fails closed. + */ @Serializable data class RequestsFeatureStatus( @SerialName("requests_enabled") val requestsEnabled: Boolean, + val allowed: Boolean? = null, + val state: String? = null, +) { + /** The feature is on and this profile may use it; a blocked account sees `allowed: false`. */ + val isAvailable: Boolean + get() = requestsEnabled && allowed == true && state == "available" +} + +/** `GET /api/v2/admin/requests/capabilities`: whether the request integration is configured. */ +@Serializable +data class AdminRequestCapabilities( + val available: Boolean = false, +) + +/** Moderation actions an admin can take on someone's request (`POST /api/v2/admin/requests/{id}/…`). */ +enum class AdminRequestAction(val path: String) { + Approve("approve"), + Decline("decline"), + Retry("retry"), +} + +@Serializable +data class AdminRequestActionBody( + val reason: String? = null, ) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestMutations.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestMutations.kt new file mode 100644 index 0000000000..aa8071c76d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestMutations.kt @@ -0,0 +1,131 @@ +package org.siloserver.silo.model.request + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.errorMessage + +/** + * Sorts a failed create, cancel, or moderation into the two outcomes the UI + * treats differently. All of them are `non_retryable`: the server keeps no + * request identity, so a resend after an uncertain outcome can act twice. + * + * - **Definite:** the server answered with an error. Show it; the user may try again. + * - **Uncertain:** the request may have reached the server but no usable answer + * came back. Never resend; hold the action until a fresh read shows what happened. + */ +object RequestMutationFailure { + fun isUncertain(result: ApiResult<*>): Boolean = when (result) { + is ApiResult.Success -> false + // A received answer this client couldn't read: the server acted. A + // gateway error without the server's own problem body came from a + // proxy, which may have forwarded the call before giving up on it. + is ApiResult.Error -> (result.code == 0 && result.error == "invalid_response") || + (result.code in GatewayErrors && result.error.isEmpty()) + // Refused before a byte left the device, or never connected: nothing happened. + is ApiResult.NetworkError -> generateSequence(result.exception) { it.cause?.takeIf { cause -> cause !== it } } + .none { it::class.simpleName in NeverConnected } + } + + private val GatewayErrors = setOf(502, 503, 504) + + private val NeverConnected = setOf( + "UnknownHostException", + "ConnectException", + "ConnectTimeoutException", + "NoRouteToHostException", + "UnresolvedAddressException", + ) +} + +/** Copy for request mutations. */ +object RequestActionCopy { + const val UnconfirmedSubmit = "We couldn't confirm the request. Check My Requests before trying again." + const val UnconfirmedCancel = "We couldn't confirm the cancellation. Refresh your requests to check it." + const val UnconfirmedModeration = "We couldn't confirm that decision. Refresh to see where the request stands." + + /** + * Copy for a definite failure. v2 folds "already requested", "already + * available", and "invalid state" into one `conflict` problem, and the quota + * message carries the counts, so the problem's detail is the most specific + * copy there is. + */ + fun failure(result: ApiResult<*>, fallback: String): String = when { + result is ApiResult.Error && result.error == "validation_failed" -> "That request couldn't be submitted" + result is ApiResult.Error && result.error in CapabilityOff -> "Requests are turned off" + else -> result.errorMessage(fallback) + } + + private val CapabilityOff = setOf("capability_disabled", "capability_not_configured", "capability_unsupported") +} + +/** + * A cancel, approve, decline, or retry sent without a usable answer. A timed-out + * call can still be running on the server when the next read comes back, so an + * unchanged request proves nothing: the hold lasts until the request changes or + * leaves the list. It lapses after [Lifetime], read or not, so a call that never + * arrived doesn't lock the row forever. Releasing it can't double an action: the + * server applies each one only from the state it expects. + */ +class RequestActionHold( + val requestId: String, + val updatedAt: String, + private val since: TimeMark, + private val lifetime: Duration = Lifetime, +) { + constructor(request: MediaRequest, timeSource: TimeSource = TimeSource.Monotonic, lifetime: Duration = Lifetime) : + this(request.id, request.updatedAt, timeSource.markNow(), lifetime) + + /** Whether a complete read ([current] is the request's entry in it, or null when gone) shows what the held call did. */ + fun isSettled(current: MediaRequest?): Boolean { + if (current == null || current.id != requestId) return true + return current.updatedAt != updatedAt || since.elapsedNow() >= lifetime + } + + companion object { + val Lifetime: Duration = 60.seconds + } +} + +/** Where one row's admin action is, so the row can animate it. */ +sealed interface RequestRowActionPhase { + val action: AdminRequestAction + + /** The button spins. */ + data class Working(override val action: AdminRequestAction) : RequestRowActionPhase + + /** The button shows its result, then the row leaves the list. */ + data class Succeeded(override val action: AdminRequestAction) : RequestRowActionPhase +} + +/** + * The compact card state implied by a full request record, so every visible + * card flips its status after a create or cancel without a refetch. Active + * requests block re-requesting; cancelled ones re-open the affordance. + */ +fun MediaRequest.toRequestState(): RequestState = when (outcome) { + RequestOutcome.Cancelled -> RequestState(requestable = true) + // Re-requesting is allowed after a decline or failure, but the state stays + // visible so the card explains itself. + RequestOutcome.Declined, RequestOutcome.Failed -> RequestState( + status = status, + state = state, + requestable = true, + requestId = id, + ) + else -> RequestState( + status = status, + state = state, + requestable = false, + requestId = id, + ) +} + +/** This result with the request state implied by [record], when the record is for the same title. */ +fun RequestMediaResult.applying(record: MediaRequest): RequestMediaResult = + if (record.mediaType == mediaType && record.tmdbId == tmdbId) copy(request = record.toRequestState()) else this + +fun List.applyingRequestUpdate(record: MediaRequest): List = + map { it.applying(record) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt index d708a346cf..c7fffba43d 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt @@ -1,9 +1,9 @@ package org.siloserver.silo.model.request /** - * Presentation policy shared by the mobile and TV request surfaces: - * TMDB image URL building, badge/chip status precedence, status - * labelling, cancellability, and the per-target summary line. + * Presentation policy shared by the mobile and TV request surfaces: TMDB + * image URL building and card routing. Status copy lives with + * [RequestDisplayState] and [RequestProgress]. */ fun requestPosterUrl(path: String?): String? = requestImageUrl(path, "w500") @@ -20,61 +20,14 @@ private fun requestImageUrl(path: String?, size: String): String? { } /** - * Raw status token for a discover/search card badge, in precedence - * order: in-library beats request status beats requestability beats - * the server-provided reason. Render with [requestDisplayLabel]. + * The one routing rule for request cards: a card whose status reads "In + * library" opens the real item; everything else opens the request detail, so + * the user can always see state and reason. That includes a title in the + * library with an active request, such as a series with missing seasons coming. */ -fun RequestMediaResult.badgeStatus(): String = when { - availability == RequestAvailability.Available -> RequestAvailability.Available - request.status?.isNotBlank() == true -> request.status.orEmpty() - request.requestable -> "request" - request.reason.isNotBlank() -> request.reason - else -> RequestAvailability.Missing -} - -/** Human label for a request status/outcome/availability/media-type token. */ -fun String.requestDisplayLabel(): String = when (lowercase()) { - RequestMediaType.Movie -> "Movie" - RequestMediaType.Series -> "Series" - RequestMediaType.Audiobook -> "Audiobook" - RequestMediaType.Ebook -> "Ebook" - RequestMediaType.All -> "All" - RequestAvailability.Available -> "In Library" - RequestAvailability.Missing -> "Missing" - "request" -> "Request" - else -> split('_', '-', ' ') - .filter { it.isNotBlank() } - .joinToString(" ") { token -> token.replaceFirstChar { it.uppercase() } } - .ifBlank { this } -} +fun RequestMediaResult.libraryItemToOpen(): String? = + RequestDisplayState.of(availability, request)?.libraryItemToOpen(libraryContentId) -/** - * A sentence saying why this title cannot be requested, or null when there is - * nothing worth showing. A title that already has a request gets null: its - * request status already says where it stands. A code this client does not - * know also gets null, so a raw code never reaches the screen, while reason - * text that is not a code is shown as the server wrote it. - */ -fun RequestState.reasonMessage(): String? = when (reason) { - RequestReason.AlreadyRequested -> null - RequestReason.AlreadyAvailable -> "This title is already in your library." - RequestReason.RequestsDisabled -> "Requests are disabled on this server." - RequestReason.Blocked -> "Your account is blocked from making requests." - RequestReason.QuotaExceeded -> "You've reached your request limit." - else -> reason.takeUnless { it.isBlank() || ReasonCodePattern.matches(it) } -} - -/** What a server reason code looks like, as opposed to readable text. */ -private val ReasonCodePattern = Regex("[a-z0-9_]+") - -fun MediaRequest.canCancel(): Boolean = - outcome == RequestOutcome.Active && status == RequestStatus.Pending - -fun MediaRequest.targetSummary(): String? { - if (targets.isEmpty()) return null - return targets.joinToString(limit = 2, truncated = "…") { target -> - listOf(target.instanceName, target.quality, target.status, target.externalStatus, target.lastError) - .filter { it.isNotBlank() } - .joinToString(" • ") - } -} +/** Same rule for the user's own request records. */ +fun MediaRequest.libraryItemToOpen(): String? = + RequestDisplayState.of(this).libraryItemToOpen(libraryContentId) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/RequestsApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/RequestsApi.kt index f62b9574ca..9b60809644 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/RequestsApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/RequestsApi.kt @@ -1,9 +1,13 @@ package org.siloserver.silo.network.api +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.AdminRequestActionBody +import org.siloserver.silo.model.request.AdminRequestCapabilities import org.siloserver.silo.model.request.CreateMediaRequest import org.siloserver.silo.model.request.MediaRequest import org.siloserver.silo.model.request.RequestMediaDetail import org.siloserver.silo.model.request.RequestMediaPage +import org.siloserver.silo.model.request.RequestMediaType import org.siloserver.silo.model.request.RequestsDiscoverResponse import org.siloserver.silo.model.request.RequestsFeatureStatus import org.siloserver.silo.model.request.RequestsListResponse @@ -54,6 +58,35 @@ interface RequestsApi { suspend fun get(id: String): ApiResult suspend fun cancel(id: String): ApiResult + + /** + * `GET /api/v2/admin/requests/capabilities`. Answers only for an admin + * acting as the account's primary profile; anyone else gets a problem, + * which callers read as "can't moderate". + */ + suspend fun adminCapabilities(): ApiResult + + /** + * Every request matching the filter, across all users, loaded completely + * or not at all. [tmdbId] narrows the list to one title through `q`, which + * also matches titles containing the number, so callers still match the id. + */ + suspend fun adminRequests( + status: String? = null, + outcome: String? = null, + mediaType: String? = null, + tmdbId: Int? = null, + ): ApiResult + + /** + * The first page of [adminRequests] and whether more follow: enough for a + * count badge without reading the whole queue. + */ + suspend fun adminRequestsFirstPage(status: String? = null, outcome: String? = null): ApiResult = + adminRequests(status, outcome) + + /** Approve, decline, or retry someone's request. `non_retryable`: never resent after an uncertain outcome. */ + suspend fun adminAction(id: String, action: AdminRequestAction, reason: String? = null): ApiResult } class DefaultRequestsApi( @@ -106,17 +139,59 @@ class DefaultRequestsApi( offset: Int?, ): ApiResult { require(offset == null || offset == 0) { "Request paging uses cursors, not offsets." } + return pagedRequests("/api/v2/requests/mine", OwnerPolicy.IDENTITY, maxPages = 100, limit = limit) { + parameter("status", status) + parameter("outcome", outcome) + } + } + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult = + pagedRequests("/api/v2/admin/requests", OwnerPolicy.PROFILE, maxPages = 40, limit = null) { + parameter("status", status) + parameter("outcome", outcome) + parameter("media_type", mediaType?.takeIf { it == RequestMediaType.Movie || it == RequestMediaType.Series }) + parameter("q", tmdbId?.toString()) + } + + override suspend fun adminRequestsFirstPage(status: String?, outcome: String?): ApiResult { + val pinned = tokenManager?.snapshotCurrentScope() + return ownedV2Call(gate, tokenManager, pinned, OwnerPolicy.PROFILE, null, { owner -> + client.get("/api/v2/admin/requests") { + owner?.let { authScope(it) } + parameter("status", status) + parameter("outcome", outcome) + parameter("limit", 50) + } + }) { it } + } + + /** + * Follows `page.next_cursor` under one captured owner. A failed page, a + * missing or repeated cursor, or the page bound fails the whole load + * instead of returning a partial list. + */ + private suspend fun pagedRequests( + path: String, + policy: OwnerPolicy, + maxPages: Int, + limit: Int?, + filters: io.ktor.client.request.HttpRequestBuilder.() -> Unit, + ): ApiResult { val size = (limit ?: 50).coerceIn(1, 50) val pinned = tokenManager?.snapshotCurrentScope() val records = mutableListOf() val seen = mutableSetOf() var cursor: String? = null - repeat(100) { - val result = ownedV2Call(gate, tokenManager, pinned, OwnerPolicy.IDENTITY, null, { owner -> - client.get("/api/v2/requests/mine") { + repeat(maxPages) { + val result = ownedV2Call(gate, tokenManager, pinned, policy, null, { owner -> + client.get(path) { owner?.let { authScope(it) } - parameter("status", status) - parameter("outcome", outcome) + filters() parameter("limit", size) cursor?.let { parameter("cursor", it) } } @@ -147,4 +222,19 @@ class DefaultRequestsApi( setBody(kotlinx.serialization.json.JsonObject(emptyMap())) } } + + override suspend fun adminCapabilities(): ApiResult = safeApiV2Call(gate) { + client.get("/api/v2/admin/requests/capabilities") + } + + override suspend fun adminAction( + id: String, + action: AdminRequestAction, + reason: String?, + ): ApiResult = safeApiV2Call(gate) { + client.post("/api/v2/admin/requests/$id/${action.path}") { + contentType(ContentType.Application.Json) + setBody(AdminRequestActionBody(reason = reason?.takeIf { it.isNotBlank() })) + } + } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestDetailCache.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestDetailCache.kt new file mode 100644 index 0000000000..6df0e18a00 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestDetailCache.kt @@ -0,0 +1,208 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestAvailability +import org.siloserver.silo.model.request.RequestMediaDetail +import org.siloserver.silo.model.request.RequestMediaResult +import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.RequestOutcome +import org.siloserver.silo.model.request.RequestReason +import org.siloserver.silo.model.request.RequestState +import org.siloserver.silo.model.request.RequestUserState +import org.siloserver.silo.model.request.requestInstantKey +import org.siloserver.silo.network.ApiResult + +/** + * Session memory for request detail pages, so opening a title the app has + * already seen paints its final layout on the first frame instead of building + * itself as reads land (the Apple clients' `RequestDetailCache`). + * + * It holds, keyed by media type + TMDB id: the title detail from the last read + * or prefetch; the signed-in user's own request records from every + * `/requests/mine` read; and admin records from the approval queue. A tap + * seeds it too: a card carries enough of the title for a provisional page. + * Every page still refreshes from the server; this only decides the first frame. + * + * Main-thread confined, like the view models that read it. Profile-scoped: + * [RequestsRepository.reset] clears it on sign-in, sign-out, and profile or + * server switches, and drops reads that answer after it. + */ +class RequestDetailCache( + /** Runs the shared prefetch queue; null disables prefetch (tests). */ + private val prefetchScope: CoroutineScope? = null, +) { + data class Key(val mediaType: String, val tmdbId: Int) + + private val details = LinkedHashMap() + private var ownRecords: Map = emptyMap() + private var moderationRecords: Map = emptyMap() + /** The exact request an admin opened from the approval queue; wins over any other for the title. */ + private val pinnedModeration = mutableMapOf() + private val seeds = mutableMapOf() + private val prefetchQueue = ArrayDeque ApiResult>>() + private var prefetchJob: Job? = null + /** Bumped by [clear]: a drain from the previous session stops at its next step. */ + private var generation = 0 + + fun detail(key: Key): RequestMediaDetail? = details[key] + fun ownRecord(key: Key): MediaRequest? = ownRecords[key] + fun moderationRecord(key: Key): MediaRequest? = moderationRecords[key] + fun pinnedModerationRecord(key: Key): MediaRequest? = pinnedModeration[key] + + /** Any other way into the title opens an ordinary page. */ + fun unpinModeration(key: Key) { + pinnedModeration.remove(key) + } + + /** Drops the pin once the request has been decided on. */ + fun unpinModeration(record: MediaRequest) { + val key = record.cacheKey() + if (pinnedModeration[key]?.id == record.id) pinnedModeration.remove(key) + } + + fun pinModeration(record: MediaRequest) { + pinnedModeration[record.cacheKey()] = record + } + + /** A page to show before the detail read answers: the cached detail, or one built from a card or record. */ + fun firstFrameDetail(key: Key): RequestMediaDetail? = + details[key] + ?: seeds[key]?.let(::provisionalDetail) + ?: (ownRecords[key] ?: moderationRecords[key])?.let(::provisionalDetail) + + fun store(detail: RequestMediaDetail) { + val key = Key(detail.mediaType, detail.tmdbId) + details.remove(key) + details[key] = detail + if (details.size > DetailLimit) details.remove(details.keys.first()) + } + + /** A complete `/requests/mine` read. Replaces what was known, so a cancelled request no longer seeds a page. */ + fun storeOwnRecords(records: List) { + ownRecords = records.groupBy { it.cacheKey() }.mapNotNull { (key, list) -> currentRecord(list)?.let { key to it } }.toMap() + } + + fun storeOwnRecord(record: MediaRequest) { + val key = record.cacheKey() + ownRecords = if (record.outcome == RequestOutcome.Cancelled) ownRecords - key else ownRecords + (key to record) + } + + /** + * Records in priority order: the first per title wins, so a pending request + * listed before a failed one is the one the page decides on. + */ + fun storeModerationRecords(records: List) { + val next = LinkedHashMap() + records.forEach { next.getOrPut(it.cacheKey()) { it } } + moderationRecords = next + // A complete read no longer listing a pinned request means someone + // already decided on it; its pin must not bring the buttons back. + val queued = records.mapTo(HashSet()) { it.id } + pinnedModeration.entries.removeAll { it.value.id !in queued } + } + + fun seed(result: RequestMediaResult) { + seeds[Key(result.mediaType, result.tmdbId)] = result + } + + fun clear() { + generation++ + prefetchJob?.cancel() + prefetchJob = null + prefetchQueue.clear() + details.clear() + ownRecords = emptyMap() + moderationRecords = emptyMap() + pinnedModeration.clear() + seeds.clear() + } + + /** + * Warms the detail for the first rows of a list, one read at a time and only + * for titles not already cached, so opening any of them lands on the + * finished page. Lists that load together share one queue. + */ + fun prefetch(records: List, load: suspend (Key) -> ApiResult) { + val scope = prefetchScope ?: return + val batch = records.asSequence() + .map { it.cacheKey() } + .filter { details[it] == null && (it.mediaType == RequestMediaType.Movie || it.mediaType == RequestMediaType.Series) } + .distinct() + .take(PrefetchCount) + .filter { key -> prefetchQueue.none { it.first == key } } + .toList() + if (batch.isEmpty()) return + batch.forEach { prefetchQueue.addLast(it to load) } + if (prefetchJob != null) return + val session = generation + prefetchJob = scope.launch { + while (session == generation && prefetchQueue.isNotEmpty()) { + val (key, loader) = prefetchQueue.removeFirst() + if (details[key] != null) continue + val result = loader(key) + if (session != generation) return@launch + if (result is ApiResult.Success) store(result.data) + } + if (session == generation) prefetchJob = null + } + } + + companion object { + /** Bounded so a long browsing session can't grow without limit. */ + private const val DetailLimit = 80 + /** Rows warmed per list read: roughly the first screenful and a bit. */ + const val PrefetchCount = 10 + + /** + * The one of a title's requests that speaks for it: the newest active + * one, otherwise the newest overall. Null when that newest one was + * cancelled, so an older decline or failure doesn't come back. + */ + fun currentRecord(records: List): MediaRequest? { + records.filter { it.outcome == RequestOutcome.Active } + .maxByOrNull { it.createdAt.requestInstantKey() } + ?.let { return it } + return records.maxByOrNull { it.createdAt.requestInstantKey() } + ?.takeIf { it.outcome != RequestOutcome.Cancelled } + } + + fun provisionalDetail(result: RequestMediaResult): RequestMediaDetail = RequestMediaDetail( + mediaType = result.mediaType, + tmdbId = result.tmdbId, + title = result.title, + overview = result.overview, + posterPath = result.posterPath, + backdropPath = result.backdropPath, + releaseDate = result.releaseDate, + year = result.year, + voteAverage = result.voteAverage, + availability = result.availability, + libraryContentId = result.libraryContentId, + request = result.request, + ) + + fun provisionalDetail(record: MediaRequest): RequestMediaDetail = RequestMediaDetail( + mediaType = record.mediaType, + tmdbId = record.tmdbId, + title = record.title, + overview = record.overview, + posterPath = record.posterPath, + backdropPath = record.backdropPath, + year = record.year, + availability = if (record.state == RequestUserState.Available) RequestAvailability.Available else RequestAvailability.Missing, + libraryContentId = record.libraryContentId, + request = RequestState( + status = record.status, + state = record.state, + requestable = false, + reason = RequestReason.AlreadyRequested, + requestId = record.id, + ), + ) + } +} + +fun MediaRequest.cacheKey(): RequestDetailCache.Key = RequestDetailCache.Key(mediaType, tmdbId) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestsRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestsRepository.kt index 46a176dbc9..024420b42a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestsRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/RequestsRepository.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.repository +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.AdminRequestCapabilities import org.siloserver.silo.model.request.CreateMediaRequest import org.siloserver.silo.model.request.MediaRequest import org.siloserver.silo.model.request.RequestMediaDetail @@ -8,16 +10,62 @@ import org.siloserver.silo.model.request.RequestsDiscoverResponse import org.siloserver.silo.model.request.RequestsFeatureStatus import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.RequestsApi +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -class RequestsRepository(private val api: RequestsApi) { +/** + * One request mutation, broadcast so every mounted request surface can patch + * its own items in place. [sequence] makes a repeat of the same record a new event. + */ +data class RequestEvent(val record: MediaRequest, val sequence: Long) + +/** + * Profile-scoped request state: the user's own list, the mutation broadcasts, + * and [cache]. [reset] starts a new session; a read or mutation that began + * before it answers without writing anything back, so the previous profile's + * records never seed the next one's pages. + * + * Main-thread confined, like [RequestDetailCache]. + */ +class RequestsRepository( + private val api: RequestsApi, + val cache: RequestDetailCache = RequestDetailCache(), + private val timeSource: TimeSource = TimeSource.Monotonic, +) { private val _mine = MutableStateFlow>(emptyList()) val mine: StateFlow> = _mine.asStateFlow() + private var eventSequence = 0L + + /** Bumped by each of this client's own creates and cancels as they land. */ + private var ownMutations = 0L + + /** Bumped by [reset]: anything still running from the previous session drops its result. */ + @kotlin.concurrent.Volatile + private var generation = 0 + + /** When [mine] last held a complete, unfiltered read; null until one lands in this session. */ + private var mineReadAt: TimeMark? = null + + private val _lastUpdate = MutableStateFlow(null) + /** The most recent create or cancel of the signed-in user's own request. */ + val lastUpdate: StateFlow = _lastUpdate.asStateFlow() + + private val _lastModeration = MutableStateFlow(null) + /** + * The most recent admin decision (approve, decline, retry) on anyone's + * request. Kept apart from [lastUpdate], whose consumers treat every record + * as the signed-in user's own. + */ + val lastModeration: StateFlow = _lastModeration.asStateFlow() + suspend fun status(): ApiResult = api.status() suspend fun discover(): ApiResult = api.discover() @@ -32,7 +80,7 @@ class RequestsRepository(private val api: RequestsApi) { ): ApiResult = api.search(query, mediaType, page) suspend fun detail(mediaType: String, tmdbId: Int): ApiResult = - api.detail(mediaType, tmdbId) + read({ api.detail(mediaType, tmdbId) }) { cache.store(it) } suspend fun get(id: String): ApiResult = api.get(id) @@ -41,37 +89,123 @@ class RequestsRepository(private val api: RequestsApi) { outcome: String? = null, limit: Int? = null, offset: Int? = null, - ): ApiResult = when (val result = api.mine(status, outcome, limit, offset)) { - is ApiResult.Success -> { - _mine.value = result.data.requests - ApiResult.Success(Unit) + ): ApiResult { + val mutationsBefore = ownMutations + val result = read({ api.mine(status, outcome, limit, offset) }) { response -> + // A create or cancel that landed while this read was out isn't in + // it: keep the patched list, and let the next caller read again. + if (ownMutations != mutationsBefore) { + mineReadAt = null + return@read + } + _mine.value = response.requests + if (status == null && outcome == null) { + cache.storeOwnRecords(response.requests) + mineReadAt = timeSource.markNow() + } else { + mineReadAt = null + } + } + return when (result) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result } - is ApiResult.Error -> ApiResult.Error(result.code, result.error, result.message) - is ApiResult.NetworkError -> ApiResult.NetworkError(result.exception) + } + + /** + * [mine] as of no more than [maxAge] ago, reading only when it's older. + * This client's own creates and cancels patch [mine] as they land, so a + * recent read stays current for them; anything that must see another + * device's change reads with a zero [maxAge]. + */ + suspend fun ensureMine(maxAge: Duration = MineFreshness): ApiResult { + val readAt = mineReadAt + if (readAt != null && readAt.elapsedNow() < maxAge) return ApiResult.Success(Unit) + return refreshMine() } suspend fun create(request: CreateMediaRequest): ApiResult = - when (val result = api.create(request)) { - is ApiResult.Success -> { - upsertMine(result.data) - ApiResult.Success(result.data) - } - is ApiResult.Error -> ApiResult.Error(result.code, result.error, result.message) - is ApiResult.NetworkError -> ApiResult.NetworkError(result.exception) - } + mutate({ api.create(request) }, ::publish) suspend fun cancel(id: String): ApiResult = - when (val result = api.cancel(id)) { - is ApiResult.Success -> { - upsertMine(result.data) - ApiResult.Success(result.data) - } - is ApiResult.Error -> ApiResult.Error(result.code, result.error, result.message) - is ApiResult.NetworkError -> ApiResult.NetworkError(result.exception) + mutate({ api.cancel(id) }, ::publish) + + suspend fun adminCapabilities(): ApiResult = api.adminCapabilities() + + suspend fun adminRequests( + status: String? = null, + outcome: String? = null, + mediaType: String? = null, + tmdbId: Int? = null, + ): ApiResult> = when (val result = read({ api.adminRequests(status, outcome, mediaType, tmdbId) })) { + is ApiResult.Success -> ApiResult.Success(result.data.requests) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + + /** How many requests match, up to one page, and whether more follow. */ + suspend fun adminRequestCount(status: String? = null, outcome: String? = null): ApiResult = + when (val result = read({ api.adminRequestsFirstPage(status, outcome) })) { + is ApiResult.Success -> ApiResult.Success(AdminRequestCount(result.data.requests.size, result.data.page.hasMore)) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result } + suspend fun adminAction(id: String, action: AdminRequestAction, reason: String? = null): ApiResult = + mutate({ api.adminAction(id, action, reason) }, ::publishModeration) + + /** Warms the first rows' details so opening them lands on a finished page. */ + fun prefetch(records: List) { + cache.prefetch(records) { key -> api.detail(key.mediaType, key.tmdbId) } + } + fun reset() { + generation++ + mineReadAt = null _mine.value = emptyList() + _lastUpdate.value = null + _lastModeration.value = null + cache.clear() + } + + /** + * A read for the current session. When a [reset] lands while it runs, the + * answer belongs to the previous profile or server: nothing is stored and + * the caller gets a failure instead of the stale data. + */ + private suspend fun read(call: suspend () -> ApiResult, store: (T) -> Unit = {}): ApiResult { + val session = generation + val result = call() + if (session != generation) return SessionChanged + if (result is ApiResult.Success) store(result.data) + return result + } + + /** + * A mutation for the current session. The server acted either way, so the + * caller gets the real result, but one that finishes after a [reset] isn't + * broadcast into the next session. + */ + private suspend fun mutate(call: suspend () -> ApiResult, publish: (T) -> Unit): ApiResult { + val session = generation + val result = call() + if (session == generation && result is ApiResult.Success) publish(result.data) + return result + } + + private fun publish(request: MediaRequest) { + ownMutations++ + upsertMine(request) + cache.storeOwnRecord(request) + _lastUpdate.value = RequestEvent(request, ++eventSequence) + } + + private fun publishModeration(request: MediaRequest) { + cache.unpinModeration(request) + // An admin deciding on their own request: their own lists update too. + if (cache.ownRecord(request.cacheKey())?.id == request.id) publish(request) + _lastModeration.value = RequestEvent(request, ++eventSequence) } private fun upsertMine(request: MediaRequest) { @@ -80,4 +214,14 @@ class RequestsRepository(private val api: RequestsApi) { if (replaced.any { it.id == request.id }) replaced else replaced + request } } + + companion object { + /** How long a complete read of [mine] answers for a page that only needs one title's record. */ + val MineFreshness: Duration = 30.seconds + + private val SessionChanged = ApiResult.Error(0, "identity_changed", "The acting account or profile changed.") + } } + +/** A count of admin requests, read one page deep: [hasMore] means there are more than [count]. */ +data class AdminRequestCount(val count: Int, val hasMore: Boolean) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestApprovalsViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestApprovalsViewModel.kt new file mode 100644 index 0000000000..a247415c15 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestApprovalsViewModel.kt @@ -0,0 +1,246 @@ +package org.siloserver.silo.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestActionHold +import org.siloserver.silo.model.request.RequestActionCopy +import org.siloserver.silo.model.request.RequestDisplayState +import org.siloserver.silo.model.request.RequestMutationFailure +import org.siloserver.silo.model.request.RequestOutcome +import org.siloserver.silo.model.request.RequestRowActionPhase +import org.siloserver.silo.model.request.RequestStatus +import org.siloserver.silo.model.request.requestInstantKey +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.errorMessage +import org.siloserver.silo.repository.RequestsRepository + +data class RequestApprovalsUiState( + /** Everyone's requests waiting on a decision, oldest first. */ + val awaitingApproval: List = emptyList(), + /** Failed requests that can be retried, most recently updated first. */ + val failed: List = emptyList(), + val hasLoaded: Boolean = false, + val isRefreshing: Boolean = false, + val error: String? = null, + /** Inline message for an action whose result is unknown; cleared when it settles. */ + val actionErrorMessage: String? = null, + /** Per-row animation state for actions in flight or just finished. */ + val phases: Map = emptyMap(), + /** Rows whose decision was sent without a usable answer; they offer nothing until it settles. */ + val heldIds: Set = emptySet(), + /** A failed action's reason, shown on its own row. */ + val rowErrors: Map = emptyMap(), + /** Per-row count of outright failures; the row's button shakes when it changes. */ + val failureCounts: Map = emptyMap(), + /** Bumped on every accepted action, for the success haptic. */ + val completedActions: Int = 0, + /** Bumped on every outright failure, for the error haptic. */ + val failedActions: Int = 0, + /** The newest outright failure's message, for surfaces without per-row error lines (TV). */ + val lastActionError: String? = null, +) { + val isEmpty: Boolean get() = hasLoaded && awaitingApproval.isEmpty() && failed.isEmpty() + + /** Whether a row may offer its decision: not while its own action runs or is held. */ + fun canAct(request: MediaRequest): Boolean = request.id !in phases && request.id !in heldIds +} + +/** + * The admin approval queue: everyone's requests waiting on a decision, and + * failed ones that can be retried. Only reachable when + * [org.siloserver.silo.model.feature.RequestsFeatureStore.canModerate] is true. + */ +class RequestApprovalsViewModel( + private val repository: RequestsRepository, + private val holdLifetime: Duration = RequestActionHold.Lifetime, + private val timeSource: TimeSource = TimeSource.Monotonic, + /** Loads on creation; the TV hub loads only once moderation is confirmed. */ + loadOnInit: Boolean = true, +) : ViewModel() { + + private val _uiState = MutableStateFlow(RequestApprovalsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val holds = mutableMapOf() + private var loadGeneration = 0 + /** Counts decisions; a read keeps out a request decided after it started. */ + private var decisionCount = 0L + private val decidedAt = mutableMapOf() + + init { + if (loadOnInit) load() + viewModelScope.launch { + // A decision made elsewhere (the detail page) takes the request out + // of whichever queue still lists it. + repository.lastModeration.drop(1).filterNotNull().collect { applyModeration(it.record) } + } + } + + fun load() { + viewModelScope.launch { fetch() } + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshing = true) } + fetch() + _uiState.update { it.copy(isRefreshing = false) } + } + } + + /** Reads both queues; returns whether they read. A newer read, started meanwhile, wins. */ + suspend fun fetch(): Boolean { + val generation = ++loadGeneration + val startedAfter = decisionCount + _uiState.update { it.copy(error = null) } + val pending = viewModelScope.async { + repository.adminRequests(status = RequestStatus.Pending, outcome = RequestOutcome.Active) + } + val broken = repository.adminRequests(outcome = RequestOutcome.Failed) + val waiting = pending.await() + if (generation != loadGeneration) return false + if (waiting is ApiResult.Success && broken is ApiResult.Success) { + // The server may have answered before a decision made while it ran. + fun undecided(request: MediaRequest): Boolean = (decidedAt[request.id] ?: Long.MIN_VALUE) <= startedAfter + decidedAt.values.removeAll { it <= startedAfter } + val awaiting = waiting.data.filter(::undecided).sortedBy { it.createdAt.requestInstantKey() } + val failed = broken.data.filter(::undecided).sortedByDescending { it.updatedAt.requestInstantKey() } + val listed = awaiting + failed + repository.cache.storeModerationRecords(listed) + repository.prefetch(listed) + // Release the holds this read settles; an unchanged request keeps its hold. + holds.entries.removeAll { (id, hold) -> hold.isSettled(listed.firstOrNull { it.id == id }) } + _uiState.update { + it.copy( + awaitingApproval = awaiting, + failed = failed, + hasLoaded = true, + heldIds = holds.keys.toSet(), + // A fresh read is the new word on every row. + rowErrors = it.rowErrors.filterKeys { id -> id in phasesOrHeld(it) }, + ).clearingUnconfirmedMessageIfSettled() + } + return true + } + val failure = if (waiting is ApiResult.Success) broken else waiting + _uiState.update { + if (it.hasLoaded) it else it.copy(hasLoaded = true, error = failure.errorMessage("Failed to load requests")) + } + return false + } + + fun perform(action: AdminRequestAction, request: MediaRequest) { + if (!_uiState.value.canAct(request)) return + _uiState.update { + it.copy( + phases = it.phases + (request.id to RequestRowActionPhase.Working(action)), + rowErrors = it.rowErrors - request.id, + actionErrorMessage = null, + ) + } + viewModelScope.launch { + val result = repository.adminAction(request.id, action) + when { + result is ApiResult.Success -> { + recordDecision(request.id) + // Show the result on the row, then let the row leave. + _uiState.update { + it.copy( + phases = it.phases + (request.id to RequestRowActionPhase.Succeeded(action)), + completedActions = it.completedActions + 1, + ) + } + delay(ResultHold) + _uiState.update { + it.copy( + awaitingApproval = it.awaitingApproval.filterNot { r -> r.id == request.id }, + failed = it.failed.filterNot { r -> r.id == request.id }, + phases = it.phases - request.id, + ) + } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold the row until a fresh read shows the result. + _uiState.update { it.copy(phases = it.phases - request.id) } + hold(request) + fetch() + if (holds.isNotEmpty()) { + _uiState.update { it.copy(actionErrorMessage = RequestActionCopy.UnconfirmedModeration) } + } + } + else -> { + val message = RequestActionCopy.failure(result, "Something went wrong") + _uiState.update { + it.copy( + phases = it.phases - request.id, + failureCounts = it.failureCounts + (request.id to ((it.failureCounts[request.id] ?: 0) + 1)), + failedActions = it.failedActions + 1, + rowErrors = it.rowErrors + (request.id to message), + lastActionError = message, + ) + } + } + } + } + } + + /** + * Holds the row, and ends the hold when its lifetime runs out even if no + * read settles it. Only this hold: a newer one for the same request keeps + * its own clock. + */ + private fun hold(request: MediaRequest) { + val hold = RequestActionHold(request, timeSource, holdLifetime) + holds[request.id] = hold + _uiState.update { it.copy(heldIds = holds.keys.toSet()) } + viewModelScope.launch { + delay(holdLifetime) + if (holds[request.id] !== hold) return@launch + holds.remove(request.id) + _uiState.update { it.copy(heldIds = holds.keys.toSet()).clearingUnconfirmedMessageIfSettled() } + fetch() + } + } + + /** Rows whose state the last action still owns; their error lines survive a read. */ + private fun phasesOrHeld(state: RequestApprovalsUiState): Set = state.phases.keys + state.heldIds + + private fun RequestApprovalsUiState.clearingUnconfirmedMessageIfSettled(): RequestApprovalsUiState = + if (heldIds.isEmpty() && actionErrorMessage == RequestActionCopy.UnconfirmedModeration) copy(actionErrorMessage = null) else this + + private fun recordDecision(id: String) { + decidedAt[id] = ++decisionCount + } + + private fun applyModeration(record: MediaRequest) { + // This list's own action removes its row after the result shows. + if (record.id in _uiState.value.phases) return + val pendingNow = RequestDisplayState.of(record) == RequestDisplayState.Pending + if (!pendingNow) recordDecision(record.id) + _uiState.update { + it.copy( + awaitingApproval = it.awaitingApproval.filterNot { r -> r.id == record.id && !pendingNow }, + failed = it.failed.filterNot { r -> r.id == record.id }, + ) + } + } + + companion object { + /** How long a finished row shows its result before leaving the list. */ + val ResultHold: Duration = 900.milliseconds + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestDetailViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestDetailViewModel.kt new file mode 100644 index 0000000000..bc098ad664 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestDetailViewModel.kt @@ -0,0 +1,550 @@ +package org.siloserver.silo.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlin.time.Duration +import kotlin.time.TimeSource +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.siloserver.silo.model.feature.RequestsFeatureStore +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.CreateMediaRequest +import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.RequestActionHold +import org.siloserver.silo.model.request.RequestActionCopy +import org.siloserver.silo.model.request.RequestAttention +import org.siloserver.silo.model.request.RequestDisplayState +import org.siloserver.silo.model.request.RequestMediaDetail +import org.siloserver.silo.model.request.RequestMediaResult +import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.RequestMutationFailure +import org.siloserver.silo.model.request.RequestOutcome +import org.siloserver.silo.model.request.RequestProgress +import org.siloserver.silo.model.request.RequestStatus +import org.siloserver.silo.model.request.RequestUnconfirmedToken +import org.siloserver.silo.model.request.toRequestState +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.errorMessage +import org.siloserver.silo.repository.RequestDetailCache +import org.siloserver.silo.repository.RequestsRepository + +/** + * The single primary action on a request detail page, computed from server + * state on every read so the button never disagrees with the server. The UI + * renders exactly one button whose label and action vary by case, so TV focus + * stays put as it morphs. + */ +sealed interface RequestPrimaryAction { + data object Loading : RequestPrimaryAction + /** The one-tap request. */ + data object Request : RequestPrimaryAction + /** Submit in flight: same button, relabeled. */ + data object Submitting : RequestPrimaryAction + /** A request exists (or the title is blocked); not interactive. */ + data class Status(val state: RequestDisplayState) : RequestPrimaryAction + /** Already in the library: the button opens the item. */ + data class OpenInLibrary(val contentId: String) : RequestPrimaryAction + + val isInteractive: Boolean get() = this == Request || this is OpenInLibrary +} + +data class RequestDetailUiState( + val detail: RequestMediaDetail? = null, + /** False while the page shows a cached or provisional first frame. */ + val hasFreshDetail: Boolean = false, + /** The signed-in user's own record for this title: stage timestamps, targets, and the id to cancel. */ + val record: MediaRequest? = null, + /** A pending or failed request for this title the signed-in admin can decide on. */ + val moderationRecord: MediaRequest? = null, + /** Opened from an approval queue: the page describes that exact request. */ + val openedForModeration: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, + val isSubmitting: Boolean = false, + /** A create was sent but its outcome is unknown; the action stays held until a read shows a request, or the hold lapses. */ + val isSubmissionUnconfirmed: Boolean = false, + val isCancelling: Boolean = false, + /** A cancel was sent without a usable answer; Cancel stays hidden until the request changes, or the hold lapses. */ + val isCancelUnconfirmed: Boolean = false, + val isModerating: Boolean = false, + /** A decision was sent without a usable answer; the buttons stay hidden until it settles. */ + val isModerationUnconfirmed: Boolean = false, + /** Inline message near the action for a failed create, cancel, or decision. */ + val actionErrorMessage: String? = null, + /** Bumped when the server accepts a new request, for the success haptic. */ + val submittedCount: Int = 0, + /** Bumped on every accepted admin decision, for the success haptic. */ + val moderatedCount: Int = 0, +) { + val primaryAction: RequestPrimaryAction + get() { + val detail = detail ?: return RequestPrimaryAction.Loading + // The same state as the title's card: a title in the library opens + // only when no active request says otherwise. + val state = RequestDisplayState.of(detail.availability, detail.request) + state?.libraryItemToOpen(detail.libraryContentId)?.let { return RequestPrimaryAction.OpenInLibrary(it) } + if (isSubmitting) return RequestPrimaryAction.Submitting + if (isSubmissionUnconfirmed) { + return RequestPrimaryAction.Status(RequestDisplayState.Unavailable(RequestUnconfirmedToken)) + } + val moderation = moderationRecord + if (openedForModeration && moderation != null) { + return RequestPrimaryAction.Status(RequestDisplayState.of(moderation)) + } + // A failed or declined request no longer blocks a new one: when the + // server calls the title requestable, the status shows on the card + // and progress, and the action stays Request (Again). + val endedButRequestable = state is RequestDisplayState.NeedsAttention && detail.request.requestable + if (state != null && !endedButRequestable) return RequestPrimaryAction.Status(state) + // The title annotation drops a request whose download finished but + // hasn't reached the library, and would offer a duplicate request; + // the user's own record still knows it's on the way. + activeRecordState?.let { return RequestPrimaryAction.Status(it) } + // A failed request no longer blocks a new one, so the server calls + // the title requestable. An admin who can retry it does that instead. + val ended = endedRequest + if (ended != null && isRetryableByAdmin) return RequestPrimaryAction.Status(RequestDisplayState.of(ended)) + return RequestPrimaryAction.Request + } + + /** + * The request for this title that ended without landing (failed or + * declined), when that's the latest word on it: the user's own, or the one + * an admin is deciding on. + */ + val endedRequest: MediaRequest? + get() { + val order = if (openedForModeration) listOf(moderationRecord, currentOwnRecord) else listOf(currentOwnRecord, moderationRecord) + return order.filterNotNull().firstOrNull { RequestDisplayState.of(it) is RequestDisplayState.NeedsAttention } + } + + /** + * Stage-track state for a request that exists: the user's record when they + * have one, an admin's view of someone else's, otherwise the title + * annotation. Null for a title nobody has requested. + */ + val progress: RequestProgress? + get() { + val own = currentOwnRecord + val moderation = moderationRecord + if (moderation != null && (openedForModeration || own == null)) return RequestProgress.of(moderation) + if (own != null && (activeRecordState != null || endedRequest?.id == own.id || detail?.request?.requestId == own.id)) { + return RequestProgress.of(own) + } + val detail = detail ?: return null + return RequestProgress.of(detail.availability, detail.request) + ?.takeUnless { it.display is RequestDisplayState.Unavailable } + } + + /** The request whose steps and timestamps the page shows. */ + val displayedRecord: MediaRequest? + get() = if (openedForModeration && moderationRecord != null) moderationRecord else record ?: moderationRecord + + /** Cancel is the requester's action, offered while the request is still pending. */ + val canCancel: Boolean + get() { + val record = record ?: return false + if (openedForModeration || isCancelling || isCancelUnconfirmed) return false + return RequestDisplayState.of(record).isCancelable + } + + /** The decisions the admin can take on [moderationRecord]. */ + val moderationActions: List + get() { + val moderation = moderationRecord ?: return emptyList() + if (isModerating || isModerationUnconfirmed) return emptyList() + return when (val state = RequestDisplayState.of(moderation)) { + RequestDisplayState.Pending -> listOf(AdminRequestAction.Approve, AdminRequestAction.Decline) + is RequestDisplayState.NeedsAttention -> + if (state.attention == RequestAttention.Failed) listOf(AdminRequestAction.Retry) else emptyList() + else -> emptyList() + } + } + + /** Recommendations this client can route (movies and series). */ + val recommendations: List + get() = detail?.recommendations.orEmpty().filter { + it.mediaType == RequestMediaType.Movie || it.mediaType == RequestMediaType.Series + } + + /** Where the title stands, in the slot a library title uses for its eyebrow. */ + val eyebrow: String? + get() { + if (moderationRecord != null && (openedForModeration || record == null)) return "Requested by someone on this server" + val display = progress?.display ?: return "Not in your library" + return if (display == RequestDisplayState.InLibrary) null else "Requested" + } + + /** The primary action's label. */ + val primaryActionTitle: String + get() = when (val action = primaryAction) { + RequestPrimaryAction.Loading -> "" + RequestPrimaryAction.Request -> if (endedRequest == null) "Request" else "Request Again" + RequestPrimaryAction.Submitting -> "Requesting…" + is RequestPrimaryAction.OpenInLibrary -> "Open in Library" + is RequestPrimaryAction.Status -> progress + ?.takeIf { action.state == it.display && action.state != RequestDisplayState.Pending } + ?.longLabel + ?: action.state.detailTitle + } + + /** The hint under a plain Request button. */ + val showsRequestHint: Boolean + get() = actionErrorMessage == null && primaryAction == RequestPrimaryAction.Request && endedRequest == null + + /** Whether the status card belongs on the page. */ + val showsStatus: Boolean + get() = progress?.let { it.display != RequestDisplayState.InLibrary } == true + + /** The admin can retry a failed request, independent of an action in flight. */ + private val isRetryableByAdmin: Boolean + get() = moderationRecord?.let { + (RequestDisplayState.of(it) as? RequestDisplayState.NeedsAttention)?.attention == RequestAttention.Failed + } == true + + /** + * The user's record while it's still the latest word on the title. A failed + * or declined one stops counting once the title is in the library or + * another request for it is under way. + */ + private val currentOwnRecord: MediaRequest? + get() { + val record = record ?: return null + val detail = detail + if (record.outcome == RequestOutcome.Active || detail == null) return record + if (RequestDisplayState.of(detail.availability, detail.request) == RequestDisplayState.InLibrary) return null + val current = detail.request.requestId + if (current != null && current != record.id) return null + return record + } + + private val activeRecordState: RequestDisplayState? + get() { + val record = record?.takeIf { it.outcome == RequestOutcome.Active } ?: return null + return RequestDisplayState.of(record).takeIf { + it == RequestDisplayState.Pending || it == RequestDisplayState.OnTheWay + } + } +} + +class RequestDetailViewModel( + private val repository: RequestsRepository, + private val mediaType: String, + private val tmdbId: Int, + private val featureStore: RequestsFeatureStore? = null, + private val holdLifetime: Duration = RequestActionHold.Lifetime, + private val timeSource: TimeSource = TimeSource.Monotonic, +) : ViewModel() { + + private val cache = repository.cache + private val key = RequestDetailCache.Key(mediaType, tmdbId) + + private val _uiState: MutableStateFlow + val uiState: StateFlow + + /** The one request this page decides on, once chosen, so a refresh never moves the buttons to another requester. */ + private var selectedModerationId: String? + private var moderationHold: RequestActionHold? = null + private var cancelHold: RequestActionHold? = null + /** The lapse timer of an unconfirmed create; null when none is held. */ + private var submissionHold: Job? = null + private var reloadJob: Job? = null + + init { + // First frame from what the app already knows: the finished page when + // this title was read before, the tapped card or record otherwise, and + // the status from the user's own records. load() refreshes all of it. + // Admin records seed the page only for a user who can still act on them. + val moderates = featureStore?.canModerate?.value == true + val pinned = cache.pinnedModerationRecord(key)?.takeIf { moderates } + val moderation = pinned ?: cache.moderationRecord(key)?.takeIf { moderates } + val detail = cache.firstFrameDetail(key) + _uiState = MutableStateFlow( + RequestDetailUiState( + detail = detail, + record = cache.ownRecord(key), + moderationRecord = moderation, + openedForModeration = pinned != null, + isLoading = detail == null, + ), + ) + uiState = _uiState.asStateFlow() + selectedModerationId = moderation?.id + // The pin is for this one opening; the next visit is an ordinary page. + pinned?.let(cache::unpinModeration) + load() + viewModelScope.launch { + // Another surface mutated this title (a cancel from My Requests while + // this page sits in the back stack). + repository.lastUpdate.drop(1).filterNotNull().collect { event -> + val record = event.record + val state = _uiState.value + if (record.mediaType != mediaType || record.tmdbId != tmdbId) return@collect + // This page's own action re-reads when it finishes. + if (state.isSubmitting || state.isCancelling || state.isModerating) return@collect + reloadJob?.cancel() + reloadJob = viewModelScope.launch { fetch() } + } + } + } + + fun load() { + viewModelScope.launch { fetch() } + } + + /** + * Reads the title, then the user's and the admin's records for it. [ownMaxAge] + * lets a recent read of the user's requests stand in; settling an uncertain + * action passes zero so it sees the server's latest. + */ + private suspend fun fetch(ownMaxAge: Duration = RequestsRepository.MineFreshness) { + _uiState.update { it.copy(isLoading = it.detail == null, error = null) } + when (val fresh = repository.detail(mediaType, tmdbId)) { + is ApiResult.Success -> { + _uiState.update { it.copy(detail = fresh.data, hasFreshDetail = true) } + // Supporting reads run side by side after the title; each keeps + // its previous value on failure, so a slow list never blocks the page. + val moderationLookup = viewModelScope.async { loadModerationRecord() } + val own = loadOwnRecords(ownMaxAge) + val moderation = moderationLookup.await() + _uiState.update { state -> + var next = state + if (own != null) { + next = next.copy(record = RequestDetailCache.currentRecord(own)) + // A timed-out cancel can still land after this read; + // only a changed or missing request settles it. + cancelHold?.let { hold -> + if (hold.isSettled(own.firstOrNull { it.id == hold.requestId })) { + cancelHold = null + next = next.releasingCancelHold() + } + } + } + if (moderation != null) { + next = next.copy(moderationRecord = moderation.record) + // An unchanged request doesn't show what a lost call did. + if (moderationHold?.isSettled(moderation.record) == true) { + moderationHold = null + next = next.releasingModerationHold() + } + } + // A create still being processed reads as no request at all: + // the hold ends only when the page no longer offers Request. + val released = next.releasingSubmissionHold() + if (next.isSubmissionUnconfirmed && released.primaryAction != RequestPrimaryAction.Request) { + submissionHold?.cancel() + submissionHold = null + next = released + } + next.copy(isLoading = false) + } + } + is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { + it.copy( + isLoading = false, + error = fresh.errorMessage("Failed to load request details").takeIf { _ -> it.detail == null }, + ) + } + } + } + + /** + * The user's requests for this title, from a read of their list no older + * than [maxAge]; null when the read fails, so the page keeps what it has. + * The list has no per-title filter, so a recent read stands in for a new one. + */ + private suspend fun loadOwnRecords(maxAge: Duration): List? { + if (repository.ensureMine(maxAge) !is ApiResult.Success) return null + return repository.mine.value.filter { it.mediaType == mediaType && it.tmdbId == tmdbId } + } + + private class ModerationLookup(val record: MediaRequest?) + + /** Null when the reads fail (the page keeps what it has); a null record means nothing awaits a decision. */ + private suspend fun loadModerationRecord(): ModerationLookup? { + if (featureStore?.canModerate?.value != true) return ModerationLookup(null) + // Filtered to this title on the server: one short page each. + val pendingRead = viewModelScope.async { + repository.adminRequests(status = RequestStatus.Pending, outcome = RequestOutcome.Active, mediaType = mediaType, tmdbId = tmdbId) + } + val failedRead = repository.adminRequests(outcome = RequestOutcome.Failed, mediaType = mediaType, tmdbId = tmdbId) + val pending = pendingRead.await() + if (pending !is ApiResult.Success || failedRead !is ApiResult.Success) return null + val matches = (pending.data + failedRead.data).filter { it.mediaType == mediaType && it.tmdbId == tmdbId } + // Several users can have failed requests for one title: stay on the + // exact request the page opened with. Once it's decided, offer nothing + // rather than another requester's request. + selectedModerationId?.let { selected -> return ModerationLookup(matches.firstOrNull { it.id == selected }) } + selectedModerationId = matches.firstOrNull()?.id + return ModerationLookup(matches.firstOrNull()) + } + + /** One tap, no confirmation dialog: the button is the confirmation. */ + fun submitRequest() { + val state = _uiState.value + val detail = state.detail ?: return + if (state.isSubmitting || state.primaryAction != RequestPrimaryAction.Request) return + _uiState.update { it.copy(isSubmitting = true, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.create(detail.toCreateMediaRequest()) + when { + result is ApiResult.Success -> { + // The server's answer stands even if the re-read below fails, + // so Request can't come back for a request that exists. + _uiState.update { it.applyingOwn(result.data).copy(submittedCount = it.submittedCount + 1) } + // Re-read so the page reflects the server's state, not a local + // guess. Still submitting meanwhile, so this page's own + // broadcast doesn't trigger a second read. + fetch() + _uiState.update { it.copy(isSubmitting = false) } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold the action until a read shows the + // request, or the hold lapses. + holdSubmission() + _uiState.update { + it.copy(isSubmitting = false, isSubmissionUnconfirmed = true, actionErrorMessage = RequestActionCopy.UnconfirmedSubmit) + } + fetch(ownMaxAge = Duration.ZERO) + } + else -> _uiState.update { + it.copy(isSubmitting = false, actionErrorMessage = RequestActionCopy.failure(result, "Failed to submit request")) + } + } + } + } + + fun cancel() { + val state = _uiState.value + val record = state.record ?: return + if (!state.canCancel) return + _uiState.update { it.copy(isCancelling = true, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.cancel(record.id) + when { + result is ApiResult.Success -> { + _uiState.update { it.applyingOwn(result.data) } + fetch() + _uiState.update { it.copy(isCancelling = false) } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold Cancel until the request changes, or the hold lapses. + holdCancel(record) + _uiState.update { + it.copy(isCancelling = false, isCancelUnconfirmed = true, actionErrorMessage = RequestActionCopy.UnconfirmedCancel) + } + fetch(ownMaxAge = Duration.ZERO) + } + else -> _uiState.update { + it.copy(isCancelling = false, actionErrorMessage = RequestActionCopy.failure(result, "Couldn't cancel the request")) + } + } + } + } + + fun moderate(action: AdminRequestAction) { + val state = _uiState.value + val moderation = state.moderationRecord ?: return + if (state.isModerating) return + _uiState.update { it.copy(isModerating = true, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.adminAction(moderation.id, action) + when { + result is ApiResult.Success -> { + // Still moderating through the re-read, so the decision + // buttons can't come back for the request just decided. + _uiState.update { it.copy(moderationRecord = result.data, moderatedCount = it.moderatedCount + 1) } + fetch() + _uiState.update { it.copy(isModerating = false) } + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hide the decision until a read shows it landed. + holdModeration(moderation) + _uiState.update { + it.copy(isModerating = false, isModerationUnconfirmed = true, actionErrorMessage = RequestActionCopy.UnconfirmedModeration) + } + fetch(ownMaxAge = Duration.ZERO) + } + else -> _uiState.update { + it.copy(isModerating = false, actionErrorMessage = RequestActionCopy.failure(result, "Something went wrong")) + } + } + } + } + + /** Ends the hold when its lifetime runs out even if no read settles it; a newer hold keeps its own clock. */ + private fun holdModeration(request: MediaRequest) { + val hold = RequestActionHold(request, timeSource, holdLifetime) + moderationHold = hold + viewModelScope.launch { + delay(holdLifetime) + if (moderationHold !== hold) return@launch + moderationHold = null + _uiState.update { it.releasingModerationHold() } + fetch(ownMaxAge = Duration.ZERO) + } + } + + private fun holdCancel(request: MediaRequest) { + val hold = RequestActionHold(request, timeSource, holdLifetime) + cancelHold = hold + viewModelScope.launch { + delay(holdLifetime) + if (cancelHold !== hold) return@launch + cancelHold = null + _uiState.update { it.releasingCancelHold() } + fetch(ownMaxAge = Duration.ZERO) + } + } + + private fun holdSubmission() { + submissionHold?.cancel() + submissionHold = viewModelScope.launch { + delay(holdLifetime) + submissionHold = null + _uiState.update { it.releasingSubmissionHold() } + fetch(ownMaxAge = Duration.ZERO) + } + } + + /** The page as the server's answer to the user's own create or cancel leaves it. */ + private fun RequestDetailUiState.applyingOwn(record: MediaRequest): RequestDetailUiState = copy( + record = record, + detail = detail?.copy(request = record.toRequestState()), + ) + + private fun RequestDetailUiState.releasingModerationHold(): RequestDetailUiState = copy( + isModerationUnconfirmed = false, + actionErrorMessage = actionErrorMessage.takeUnless { it == RequestActionCopy.UnconfirmedModeration }, + ) + + private fun RequestDetailUiState.releasingCancelHold(): RequestDetailUiState = copy( + isCancelUnconfirmed = false, + actionErrorMessage = actionErrorMessage.takeUnless { it == RequestActionCopy.UnconfirmedCancel }, + ) + + private fun RequestDetailUiState.releasingSubmissionHold(): RequestDetailUiState = copy( + isSubmissionUnconfirmed = false, + actionErrorMessage = actionErrorMessage.takeUnless { it == RequestActionCopy.UnconfirmedSubmit }, + ) + + private fun RequestMediaDetail.toCreateMediaRequest(): CreateMediaRequest = CreateMediaRequest( + mediaType = mediaType, + tmdbId = tmdbId, + tvdbId = tvdbId, + imdbId = imdbId, + title = title, + year = year, + overview = overview, + posterPath = posterPath, + backdropPath = backdropPath, + ) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestsViewModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestsViewModels.kt index e9d95ac164..61871092ad 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestsViewModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RequestsViewModels.kt @@ -2,109 +2,239 @@ package org.siloserver.silo.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import org.siloserver.silo.model.request.CreateMediaRequest +import org.siloserver.silo.model.feature.RequestsFeatureStore import org.siloserver.silo.model.request.MediaRequest +import org.siloserver.silo.model.request.MyRequestsBucket +import org.siloserver.silo.model.request.RequestActionCopy +import org.siloserver.silo.model.request.RequestActionHold import org.siloserver.silo.model.request.RequestDiscoverySection -import org.siloserver.silo.model.request.RequestMediaDetail +import org.siloserver.silo.model.request.RequestDisplayState import org.siloserver.silo.model.request.RequestMediaResult import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.RequestMutationFailure +import org.siloserver.silo.model.request.RequestOutcome +import org.siloserver.silo.model.request.RequestStatus +import org.siloserver.silo.model.request.applyingRequestUpdate import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.errorMessage import org.siloserver.silo.repository.RequestsRepository +import kotlin.time.Duration +import kotlin.time.TimeSource import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class RequestsUiState( + /** The first load, with nothing to show yet. */ val isLoading: Boolean = true, val isRefreshing: Boolean = false, - val isEnabled: Boolean = false, + val hasLoaded: Boolean = false, val sections: List = emptyList(), + /** The signed-in user's requests, needs-attention first, newest first within each group. */ + val myRequests: List = emptyList(), + /** Requests waiting on this admin's decision; zero for everyone else. */ + val pendingApprovals: Int = 0, + /** More requests wait than [pendingApprovals]: the count reads one page. */ + val morePendingApprovals: Boolean = false, val error: String? = null, -) + val query: String = "", + val searchResults: List = emptyList(), + /** TMDB's total for the query, for the "N results" line. */ + val searchTotal: Int = 0, + val isSearching: Boolean = false, + val hasSearched: Boolean = false, +) { + /** True while the hub shows discover content (no active query). */ + val isShowingDiscover: Boolean get() = query.isBlank() + + /** Requests still moving, for the summary card. */ + val inProgressCount: Int + get() = myRequests.count { MyRequestsBucket.of(RequestDisplayState.of(it)) == MyRequestsBucket.InMotion } + + /** Requests that need the user, for the summary card. */ + val needsAttentionCount: Int + get() = myRequests.count { MyRequestsBucket.of(RequestDisplayState.of(it)) == MyRequestsBucket.NeedsAttention } + + val onTheWayCount: Int get() = myRequests.count { RequestDisplayState.of(it) == RequestDisplayState.OnTheWay } + val pendingCount: Int get() = myRequests.count { RequestDisplayState.of(it) == RequestDisplayState.Pending } +} +/** + * The Requests hub: TMDB search, the user's own requests strip, a status + * summary, and the discover carousels. Fetches fresh on every visit: request + * state changes server-side, and a stale "Pending" is worse than a placeholder. + */ class RequestsViewModel( private val repository: RequestsRepository, + private val featureStore: RequestsFeatureStore? = null, + /** False where the page reads the full approval queue itself (TV), so the hub doesn't read it twice. */ + private val countsPendingApprovals: Boolean = true, + /** Loads on creation; the TV page loads when it composes. */ + loadOnInit: Boolean = true, ) : ViewModel() { private val _uiState = MutableStateFlow(RequestsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var searchJob: Job? = null + private var loadGeneration = 0 + init { - load() + if (loadOnInit) load() + viewModelScope.launch { + repository.lastUpdate.drop(1).filterNotNull().collect { applyRequestUpdate(it.record) } + } + viewModelScope.launch { + repository.lastModeration.drop(1).filterNotNull().collect { loadPendingApprovals() } + } + featureStore?.let { store -> + // Moderation can be confirmed after the hub's first load. + viewModelScope.launch { store.canModerate.drop(1).collect { loadPendingApprovals() } } + } } fun load() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetchRequestsHome() - } + viewModelScope.launch { fetch() } } fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - fetchRequestsHome() + _uiState.update { it.copy(isRefreshing = true) } + fetch() _uiState.update { it.copy(isRefreshing = false) } } } - private suspend fun fetchRequestsHome() { - when (val status = repository.status()) { - is ApiResult.Success -> { - if (!status.data.requestsEnabled) { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = false, - sections = emptyList(), - error = "Requests are not enabled on this server.", - ) - } - return - } - fetchDiscover() + /** Loads discover and the user's requests; returns whether both read. */ + suspend fun fetch(): Boolean { + val generation = ++loadGeneration + _uiState.update { it.copy(isLoading = it.sections.isEmpty() && it.myRequests.isEmpty(), error = null) } + // The admin queue can take many pages; it fills its card when it lands. + val approvals = viewModelScope.launch { loadPendingApprovals() } + val discover = viewModelScope.async { repository.discover() } + val mine = repository.refreshMine() + val sections = discover.await() + if (generation != loadGeneration) return false + val succeeded = sections is ApiResult.Success && mine is ApiResult.Success + if (succeeded) { + val records = repository.mine.value + val ordered = MyRequestsBucket.bucket(records).flatMap { it.second } + _uiState.update { + it.copy( + isLoading = false, + hasLoaded = true, + sections = mergeDiscoverCarousels((sections as ApiResult.Success).data.sections), + myRequests = ordered, + error = null, + ) } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = false, - error = status.errorMessage("Failed to load request status"), - ) - } + repository.prefetch(ordered) + } else { + val failure: ApiResult<*> = if (sections is ApiResult.Success) mine else sections + _uiState.update { + // Keep prior content on a transient failure; only surface the + // error when there is nothing to show instead. + it.copy( + isLoading = false, + hasLoaded = true, + error = failure.errorMessage("Failed to load requests") + .takeIf { _ -> it.sections.isEmpty() && it.myRequests.isEmpty() }, + ) } } + approvals.join() + return succeeded } - private suspend fun fetchDiscover() { - when (val discover = repository.discover()) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = true, - sections = mergeDiscoverCarousels(discover.data.sections), - error = null, + /** The hub's approvals card is a nudge, not a list: a failed read hides it. */ + private suspend fun loadPendingApprovals() { + if (!countsPendingApprovals || featureStore?.canModerate?.value != true) { + _uiState.update { it.copy(pendingApprovals = 0, morePendingApprovals = false) } + return + } + // One page answers the card; it doesn't need the whole queue. + val result = repository.adminRequestCount(status = RequestStatus.Pending, outcome = RequestOutcome.Active) + val pending = (result as? ApiResult.Success)?.data + _uiState.update { it.copy(pendingApprovals = pending?.count ?: 0, morePendingApprovals = pending?.hasMore == true) } + } + + /** Debounced TMDB search (300 ms), as the Apple hub does. */ + fun onQueryChanged(value: String) { + searchJob?.cancel() + _uiState.update { it.copy(query = value) } + val trimmed = value.trim() + if (trimmed.isEmpty()) { + _uiState.update { it.copy(searchResults = emptyList(), searchTotal = 0, isSearching = false, hasSearched = false) } + return + } + searchJob = viewModelScope.launch { + delay(300) + search(trimmed) + } + } + + /** Runs the current query now (the keyboard's search action). */ + fun submitSearch() { + val trimmed = _uiState.value.query.trim() + if (trimmed.isEmpty()) return + searchJob?.cancel() + searchJob = viewModelScope.launch { search(trimmed) } + } + + private suspend fun search(query: String) { + _uiState.update { it.copy(isSearching = true) } + val result = repository.search(query = query) + // A replacement search owns the state now. + if (_uiState.value.query.trim() != query) return + _uiState.update { state -> + when (result) { + is ApiResult.Success -> { + val results = result.data.results.filter { it.mediaType.isRequestableVideo() } + state.copy( + isSearching = false, + hasSearched = true, + searchResults = results, + searchTotal = maxOf(result.data.totalResults, results.size), ) } + else -> state.copy(isSearching = false, hasSearched = true, searchResults = emptyList(), searchTotal = 0) } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoading = false, - isEnabled = true, - error = discover.errorMessage("Failed to load requests"), - ) - } + } + } + + /** Patches every visible surface in place after a mutation anywhere in the app. */ + private fun applyRequestUpdate(record: MediaRequest) { + _uiState.update { state -> + val index = state.myRequests.indexOfFirst { it.id == record.id } + val mine = when { + index >= 0 && record.outcome == RequestOutcome.Cancelled -> state.myRequests.filterNot { it.id == record.id } + index >= 0 -> state.myRequests.toMutableList().also { it[index] = record } + record.outcome == RequestOutcome.Active -> listOf(record) + state.myRequests + else -> state.myRequests } + state.copy( + searchResults = state.searchResults.applyingRequestUpdate(record), + sections = state.sections.map { it.copy(results = it.results.applyingRequestUpdate(record)) }, + myRequests = mine, + ) } } + + override fun onCleared() { + searchJob?.cancel() + super.onCleared() + } } +private fun String.isRequestableVideo(): Boolean = this == RequestMediaType.Movie || this == RequestMediaType.Series + data class RequestSearchUiState( val query: String = "", val submittedQuery: String = "", @@ -120,6 +250,7 @@ data class RequestSearchUiState( get() = submittedQuery.isNotBlank() && query.trim() == submittedQuery } +/** The "Available to request" section of the app's global search. */ class RequestSearchViewModel( private val repository: RequestsRepository, ) : ViewModel() { @@ -129,6 +260,14 @@ class RequestSearchViewModel( private var searchJob: Job? = null + init { + viewModelScope.launch { + repository.lastUpdate.drop(1).filterNotNull().collect { event -> + _uiState.update { it.copy(results = it.results.applyingRequestUpdate(event.record)) } + } + } + } + fun onQueryChanged(value: String) { _uiState.update { current -> val trimmed = value.trim() @@ -230,193 +369,169 @@ class RequestSearchViewModel( } } -data class RequestDetailUiState( - val isLoading: Boolean = true, - val isSubmitting: Boolean = false, - val detail: RequestMediaDetail? = null, - val error: String? = null, - val notice: String? = null, -) - -class RequestDetailViewModel( - private val repository: RequestsRepository, - private val mediaType: String, - private val tmdbId: Int, -) : ViewModel() { - - private val _uiState = MutableStateFlow(RequestDetailUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - load() - } - - fun load() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null, notice = null) } - when (val result = repository.detail(mediaType, tmdbId)) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - isLoading = false, - detail = result.data, - error = null, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoading = false, - detail = null, - error = result.errorMessage("Failed to load request details"), - ) - } - } - } - } - } - - fun submitRequest() { - val detail = _uiState.value.detail - if (detail == null) { - _uiState.update { it.copy(error = "Request details are not loaded.") } - return - } - - viewModelScope.launch { - _uiState.update { it.copy(isSubmitting = true, error = null, notice = null) } - when (val result = repository.create(detail.toCreateMediaRequest())) { - is ApiResult.Success -> { - val refreshedDetail = when (val detailResult = repository.detail(mediaType, tmdbId)) { - is ApiResult.Success -> detailResult.data - else -> detail.copy( - request = detail.request.copy( - status = result.data.status, - requestable = false, - requestId = result.data.id, - ), - ) - } - _uiState.update { - it.copy( - isSubmitting = false, - detail = refreshedDetail, - error = null, - notice = "Request submitted.", - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isSubmitting = false, - error = result.errorMessage("Failed to submit request"), - ) - } - } - } - } - } - - private fun RequestMediaDetail.toCreateMediaRequest(): CreateMediaRequest = CreateMediaRequest( - mediaType = mediaType, - tmdbId = tmdbId, - tvdbId = tvdbId, - imdbId = imdbId, - title = title, - year = year, - overview = overview, - posterPath = posterPath, - backdropPath = backdropPath, - ) -} - data class MyRequestsUiState( val isLoading: Boolean = true, val isRefreshing: Boolean = false, - val requests: List = emptyList(), - val actionInFlightId: String? = null, + val hasLoaded: Boolean = false, + /** Ordered, non-empty buckets, newest first within each. */ + val buckets: List>> = emptyList(), + /** The request being cancelled; its row dims and takes no taps. */ + val cancellingId: String? = null, + /** Requests whose cancel was sent without a usable answer; held until the request changes, or the hold lapses. */ + val unconfirmedCancelIds: Set = emptySet(), + /** Inline message for a failed cancel; cleared on the next action. */ + val actionErrorMessage: String? = null, val error: String? = null, -) +) { + val requests: List get() = buckets.flatMap { it.second } + val isEmpty: Boolean get() = hasLoaded && buckets.isEmpty() + + /** Whether [record] may offer Cancel: pending, and no cancel of it held. */ + fun canCancel(record: MediaRequest): Boolean = + RequestDisplayState.of(record).isCancelable && record.id !in unconfirmedCancelIds && cancellingId == null +} class MyRequestsViewModel( private val repository: RequestsRepository, + /** Loads on creation; the TV page only uses this model to cancel. */ + loadOnInit: Boolean = true, + private val holdLifetime: Duration = RequestActionHold.Lifetime, + private val timeSource: TimeSource = TimeSource.Monotonic, ) : ViewModel() { private var loadGeneration = 0 + private val cancelHolds = mutableMapOf() private val _uiState = MutableStateFlow(MyRequestsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var reloadJob: Job? = null init { - load() - } - - fun load() { - val generation = ++loadGeneration + if (loadOnInit) load() viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - refreshMine(generation) + // A mutation elsewhere (detail submit) while this list is mounted: + // the list is short, so a full refetch is the simplest correct answer. + repository.lastUpdate.drop(1).filterNotNull().collect { + if (!_uiState.value.hasLoaded || _uiState.value.cancellingId != null) return@collect + reloadJob?.cancel() + reloadJob = viewModelScope.launch { fetch() } + } } } - fun refresh() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - refreshMine(generation) - if (generation == loadGeneration) { - _uiState.update { it.copy(isRefreshing = false) } - } - } + fun load() { + viewModelScope.launch { fetch() } } - fun cancel(id: String) { + fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(actionInFlightId = id, error = null) } - when (val result = repository.cancel(id)) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - requests = repository.mine.value, - actionInFlightId = null, - error = null, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - actionInFlightId = null, - error = result.errorMessage("Failed to cancel request"), - ) - } - } - } + _uiState.update { it.copy(isRefreshing = true) } + fetch() + _uiState.update { it.copy(isRefreshing = false) } } } - private suspend fun refreshMine(generation: Int) { + /** Reads the list; returns whether the read succeeded. */ + suspend fun fetch(): Boolean { + val generation = ++loadGeneration + _uiState.update { it.copy(isLoading = it.buckets.isEmpty(), error = null) } val result = repository.refreshMine() - if (generation != loadGeneration) return - when (result) { + if (generation != loadGeneration) return false + return when (result) { is ApiResult.Success -> { + val buckets = MyRequestsBucket.bucket(repository.mine.value) _uiState.update { it.copy( isLoading = false, - requests = repository.mine.value, + hasLoaded = true, + buckets = buckets, error = null, ) } + settleCancelHolds() + repository.prefetch(buckets.flatMap { it.second }) + true } is ApiResult.Error, is ApiResult.NetworkError -> { _uiState.update { it.copy( isLoading = false, - error = result.errorMessage("Failed to load your requests"), + error = result.errorMessage("Failed to load your requests").takeIf { _ -> it.buckets.isEmpty() }, ) } + false + } + } + } + + /** + * Cancels one of the user's requests. [refresh] re-reads the list that shows + * the row when it isn't this model's (the TV hub) and returns whether the + * read succeeded; it runs only to settle an uncertain cancel. + */ + fun cancel(record: MediaRequest, refresh: (suspend () -> Boolean)? = null) { + val state = _uiState.value + if (state.cancellingId != null || record.id in state.unconfirmedCancelIds) return + _uiState.update { it.copy(cancellingId = record.id, actionErrorMessage = null) } + viewModelScope.launch { + val result = repository.cancel(record.id) + when { + result is ApiResult.Success -> { + _uiState.update { it.copy(cancellingId = null) } + if (refresh == null) fetch() + } + RequestMutationFailure.isUncertain(result) -> { + // Never resend: hold Cancel until the request changes, or the hold lapses. + val reread: suspend () -> Unit = { + if (refresh == null) { + fetch() + } else if (refresh()) { + settleCancelHolds() + } + } + holdCancel(record, reread) + _uiState.update { it.copy(cancellingId = null, actionErrorMessage = RequestActionCopy.UnconfirmedCancel) } + reread() + } + else -> _uiState.update { + it.copy(cancellingId = null, actionErrorMessage = RequestActionCopy.failure(result, "Couldn't cancel the request")) + } } } } + + /** + * Holds Cancel on [record], and ends the hold when its lifetime runs out + * even if no read settles it. A newer hold for the same request keeps its own clock. + */ + private fun holdCancel(record: MediaRequest, reread: suspend () -> Unit) { + val hold = RequestActionHold(record, timeSource, holdLifetime) + cancelHolds[record.id] = hold + _uiState.update { it.copy(unconfirmedCancelIds = cancelHolds.keys.toSet()) } + viewModelScope.launch { + delay(holdLifetime) + if (cancelHolds[record.id] !== hold) return@launch + cancelHolds.remove(record.id) + publishCancelHolds() + reread() + } + } + + /** Releases the holds the last read of the user's requests settles; an unchanged request keeps its hold. */ + private fun settleCancelHolds() { + if (cancelHolds.isEmpty()) return + val records = repository.mine.value + cancelHolds.entries.removeAll { (id, hold) -> hold.isSettled(records.firstOrNull { it.id == id }) } + publishCancelHolds() + } + + private fun publishCancelHolds() { + _uiState.update { + it.copy( + unconfirmedCancelIds = cancelHolds.keys.toSet(), + actionErrorMessage = it.actionErrorMessage.takeUnless { m -> + cancelHolds.isEmpty() && m == RequestActionCopy.UnconfirmedCancel + }, + ) + } + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStoreTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStoreTest.kt index c2a05bb686..822865b2ff 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStoreTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/RequestsFeatureStoreTest.kt @@ -3,26 +3,62 @@ package org.siloserver.silo.model.feature import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.AdminRequestCapabilities import org.siloserver.silo.model.request.CreateMediaRequest import org.siloserver.silo.model.request.MediaRequest import org.siloserver.silo.model.request.RequestMediaDetail import org.siloserver.silo.model.request.RequestMediaPage +import org.siloserver.silo.model.request.RequestMediaResult +import org.siloserver.silo.model.request.RequestMediaType import org.siloserver.silo.model.request.RequestsDiscoverResponse import org.siloserver.silo.model.request.RequestsFeatureStatus import org.siloserver.silo.model.request.RequestsListResponse import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.RequestsApi +import org.siloserver.silo.repository.RequestDetailCache import org.siloserver.silo.repository.RequestsRepository class RequestsFeatureStoreTest { + @Test + fun aBlockedProfileKeepsRequestsHidden() = runTest { + val store = RequestsFeatureStore(RequestsRepository(FakeRequestsApi(ApiResult.Success(available(allowed = false))))) + + store.refresh() + + assertFalse(store.isEnabled.value) + } + + @Test + fun moderationFollowsTheAdminCapabilityProbe() = runTest { + val admin = RequestsFeatureStore( + RequestsRepository( + FakeRequestsApi( + ApiResult.Success(available()), + capabilities = ApiResult.Success(AdminRequestCapabilities(available = true)), + ), + ), + ) + val member = RequestsFeatureStore(RequestsRepository(FakeRequestsApi(ApiResult.Success(available())))) + + admin.refresh() + member.refresh() + + assertTrue(admin.canModerate.value) + assertFalse(member.canModerate.value) + admin.reset() + assertFalse(admin.canModerate.value) + } + @Test fun startsHiddenAndFollowsSuccessfulStatusProbe() = runTest { val store = RequestsFeatureStore( RequestsRepository( FakeRequestsApi( - ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), + ApiResult.Success(available()), ), ), ) @@ -37,7 +73,7 @@ class RequestsFeatureStoreTest { @Test fun transientFailureKeepsPreviousCapabilityValue() = runTest { val api = FakeRequestsApi( - ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), + ApiResult.Success(available()), ApiResult.NetworkError(IllegalStateException("offline")), ) val store = RequestsFeatureStore(RequestsRepository(api)) @@ -50,23 +86,31 @@ class RequestsFeatureStoreTest { @Test fun resetHidesRequestsBeforeNextProbe() = runTest { - val store = RequestsFeatureStore( - RequestsRepository( - FakeRequestsApi( - ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), - ), + val repository = RequestsRepository( + FakeRequestsApi( + ApiResult.Success(available()), ), ) + val store = RequestsFeatureStore(repository) + val seen = RequestMediaResult(mediaType = RequestMediaType.Movie, tmdbId = 7, title = "Seen") + repository.cache.seed(seen) store.refresh() store.reset() assertFalse(store.isEnabled.value) + // Sign-out and server or profile switches all reset here, so the + // previous session's request pages go with it. + assertNull(repository.cache.firstFrameDetail(RequestDetailCache.Key(RequestMediaType.Movie, 7))) } } +private fun available(allowed: Boolean = true) = + RequestsFeatureStatus(requestsEnabled = true, allowed = allowed, state = "available") + private class FakeRequestsApi( vararg statusResults: ApiResult, + private val capabilities: ApiResult = ApiResult.Error(403, "forbidden", ""), ) : RequestsApi { private val statusResults = statusResults.toMutableList() @@ -107,4 +151,16 @@ private class FakeRequestsApi( override suspend fun cancel(id: String): ApiResult = ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun adminCapabilities(): ApiResult = capabilities + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult = ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun adminAction(id: String, action: AdminRequestAction, reason: String?): ApiResult = + ApiResult.NetworkError(IllegalStateException("not used")) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestDisplayStateTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestDisplayStateTest.kt new file mode 100644 index 0000000000..48af41b9ae --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestDisplayStateTest.kt @@ -0,0 +1,168 @@ +package org.siloserver.silo.model.request + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.RequestDetailCache + +class RequestDisplayStateTest { + + @Test + fun serverStateDecidesBeforeStatus() { + // A finished download the library hasn't picked up yet: `completed` + // alone would read as in library. + assertEquals(RequestDisplayState.OnTheWay, display(status = RequestStatus.Completed, state = RequestUserState.Processing)) + assertEquals(RequestDisplayState.OnTheWay, display(status = RequestStatus.Completed, state = RequestUserState.PartiallyAvailable)) + assertEquals(RequestDisplayState.InLibrary, display(status = RequestStatus.Completed, state = RequestUserState.Available)) + assertEquals( + RequestDisplayState.NeedsAttention(RequestAttention.Declined, null), + display(status = RequestStatus.Pending, state = RequestUserState.Declined), + ) + } + + @Test + fun unknownOrMissingStateFallsBackToStatusAndOutcome() { + assertEquals(RequestDisplayState.InLibrary, display(status = RequestStatus.Completed)) + assertEquals(RequestDisplayState.OnTheWay, display(status = "brand_new_stage", state = "brand_new_state")) + assertEquals( + RequestDisplayState.NeedsAttention(RequestAttention.Failed, "No indexer"), + display(status = RequestStatus.Downloading, outcome = RequestOutcome.Failed, lastError = "No indexer"), + ) + assertTrue(display(status = RequestStatus.Pending, outcome = RequestOutcome.Cancelled) is RequestDisplayState.Unavailable) + } + + @Test + fun cardStateIgnoresTheRequestabilityReasonOnceARequestExists() { + val annotated = RequestState(status = RequestStatus.Approved, requestable = false, reason = RequestReason.AlreadyRequested) + assertEquals(RequestDisplayState.OnTheWay, RequestDisplayState.of(RequestAvailability.Missing, annotated)) + assertNull(RequestDisplayState.of(RequestAvailability.Missing, RequestState(requestable = true))) + // A series in the library with its missing seasons on the way reads as on the way. + val seasons = RequestState(status = RequestStatus.Downloading, state = RequestUserState.Approved) + assertEquals(RequestDisplayState.OnTheWay, RequestDisplayState.of(RequestAvailability.Available, seasons)) + assertNull(RequestDisplayState.OnTheWay.libraryItemToOpen("item-1")) + assertEquals("item-1", RequestDisplayState.InLibrary.libraryItemToOpen("item-1")) + } + + @Test + fun progressPlacesEachStateOnTheTrack() { + val pending = RequestProgress.of(record(status = RequestStatus.Pending)) + assertEquals(1, pending.completedSteps) + assertEquals(RequestStep.Approval, pending.currentStep) + + val queued = RequestProgress.of(record(status = RequestStatus.Queued)) + assertEquals(RequestStep.Download, queued.currentStep) + assertEquals("Queued", queued.shortLabel) + + val adding = RequestProgress.of(record(status = RequestStatus.Completed, state = RequestUserState.Processing)) + assertEquals(RequestStep.Library, adding.currentStep) + assertEquals(3, adding.completedSteps) + assertEquals("Adding to your library", adding.longLabel) + + val landed = RequestProgress.of(record(status = RequestStatus.Completed, state = RequestUserState.Available)) + assertEquals(4, landed.completedSteps) + assertNull(landed.currentStep) + + val failed = RequestProgress.of(record(status = RequestStatus.Downloading, outcome = RequestOutcome.Failed)) + assertEquals(RequestStep.Download, failed.currentStep) + assertEquals(RequestStatusTint.Rose, failed.tint) + } + + @Test + fun bucketsPutRequestsThatNeedTheUserAboveFinishedOnes() { + val buckets = MyRequestsBucket.bucket( + listOf( + record(id = "landed", status = RequestStatus.Completed, state = RequestUserState.Available), + record(id = "old", status = RequestStatus.Pending, createdAt = "2026-09-01T00:00:00Z"), + record(id = "new", status = RequestStatus.Approved, createdAt = "2026-09-02T00:00:00.5Z"), + record(id = "failed", status = RequestStatus.Pending, outcome = RequestOutcome.Failed), + record(id = "gone", status = RequestStatus.Pending, outcome = RequestOutcome.Cancelled), + ), + ) + assertEquals( + listOf(MyRequestsBucket.InMotion, MyRequestsBucket.NeedsAttention, MyRequestsBucket.Landed), + buckets.map { it.first }, + ) + assertEquals(listOf("new", "old"), buckets.first().second.map { it.id }) + } + + @Test + fun theNewestRequestSpeaksForItsTitle() { + val declined = record(id = "declined", outcome = RequestOutcome.Declined, createdAt = "2026-09-01T00:00:00Z") + val cancelled = record(id = "cancelled", outcome = RequestOutcome.Cancelled, createdAt = "2026-09-02T00:00:00Z") + // Cancelling a newer request doesn't bring back an older decline. + assertNull(RequestDetailCache.currentRecord(listOf(declined, cancelled))) + val active = record(id = "active", createdAt = "2026-08-01T00:00:00Z") + assertEquals("active", RequestDetailCache.currentRecord(listOf(declined, cancelled, active))?.id) + } + + @Test + fun aRequestActionHoldEndsOnlyWhenTheRequestChangesLeavesOrLapses() { + val clock = TestTimeSource() + val request = record(id = "r1", updatedAt = "2026-09-01T00:00:00Z") + val hold = RequestActionHold(request, clock, lifetime = 60.seconds) + + assertFalse(hold.isSettled(request)) + assertTrue(hold.isSettled(request.copy(updatedAt = "2026-09-01T00:01:00Z"))) + assertTrue(hold.isSettled(null)) + clock += 60.seconds + assertTrue(hold.isSettled(request)) + } + + @Test + fun aProxyGatewayErrorIsUncertainButTheServersOwnProblemIsNot() { + assertTrue(RequestMutationFailure.isUncertain(ApiResult.Error(504, "", ""))) + assertTrue(RequestMutationFailure.isUncertain(ApiResult.Error(502, "", ""))) + assertFalse(RequestMutationFailure.isUncertain(ApiResult.Error(503, "capability_unavailable", "Requests are unavailable."))) + assertFalse(RequestMutationFailure.isUncertain(ApiResult.Error(500, "", ""))) + } + + @Test + fun aDeclineExplainsItselfWithTheOutcomeReasonNotTheDownloadError() { + val declined = record(state = RequestUserState.Declined, lastError = "Radarr unreachable").copy(outcomeReason = "Already on disc") + assertEquals(RequestDisplayState.NeedsAttention(RequestAttention.Declined, "Already on disc"), RequestDisplayState.of(declined)) + val failed = record(outcome = RequestOutcome.Failed, lastError = "Radarr unreachable") + assertEquals(RequestDisplayState.NeedsAttention(RequestAttention.Failed, "Radarr unreachable"), RequestDisplayState.of(failed)) + } + + @Test + fun reasonCopyHidesUnknownCodesButKeepsServerSentences() { + assertEquals("Request limit reached", requestReasonCopy(RequestReason.QuotaExceeded)) + assertEquals("One or more fulfillment targets failed", requestReasonCopy("one or more fulfillment targets failed")) + assertNull(requestReasonCopy("some_new_code")) + assertEquals("Request failed · No indexer", RequestDisplayState.NeedsAttention(RequestAttention.Failed, "No indexer").detailTitle) + } + + private fun display( + status: String, + outcome: String = RequestOutcome.Active, + state: String? = null, + lastError: String = "", + ): RequestDisplayState = RequestDisplayState.of(record(status = status, outcome = outcome, state = state, lastError = lastError)) +} + +internal fun record( + id: String = "r1", + tmdbId: Int = 1, + status: String = RequestStatus.Pending, + outcome: String = RequestOutcome.Active, + state: String? = null, + lastError: String = "", + createdAt: String = "2026-09-01T00:00:00Z", + updatedAt: String = createdAt, +): MediaRequest = MediaRequest( + id = id, + mediaType = RequestMediaType.Movie, + tmdbId = tmdbId, + title = "Film", + status = status, + outcome = outcome, + state = state, + lastError = lastError, + createdAt = createdAt, + updatedAt = updatedAt, +) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt index 25b0327bd9..4574ede6e8 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt @@ -28,80 +28,15 @@ class RequestPresentationTest { } @Test - fun `badge status prefers availability then status then requestable then reason`() { - assertEquals(RequestAvailability.Available, result(availability = RequestAvailability.Available).badgeStatus()) - assertEquals(RequestStatus.Pending, result(requestStatus = RequestStatus.Pending).badgeStatus()) - assertEquals("request", result(requestable = true).badgeStatus()) - assertEquals("not allowed", result(reason = "not allowed").badgeStatus()) - assertEquals(RequestAvailability.Missing, result().badgeStatus()) - } - - @Test - fun `display label maps known tokens and title-cases the rest`() { - assertEquals("In Library", RequestAvailability.Available.requestDisplayLabel()) - assertEquals("Missing", RequestAvailability.Missing.requestDisplayLabel()) - assertEquals("Request", "request".requestDisplayLabel()) - assertEquals("Movie", RequestMediaType.Movie.requestDisplayLabel()) - assertEquals("Series", RequestMediaType.Series.requestDisplayLabel()) - assertEquals("All", RequestMediaType.All.requestDisplayLabel()) - assertEquals("Pending", RequestStatus.Pending.requestDisplayLabel()) - assertEquals("Partially Available", "partially_available".requestDisplayLabel()) - } - - @Test - fun `reason message reads each server reason as a sentence`() { - assertEquals( - "This title is already in your library.", - RequestState(reason = RequestReason.AlreadyAvailable).reasonMessage(), - ) - assertEquals( - "Requests are disabled on this server.", - RequestState(reason = RequestReason.RequestsDisabled).reasonMessage(), - ) - assertEquals( - "Your account is blocked from making requests.", - RequestState(reason = RequestReason.Blocked).reasonMessage(), - ) - assertEquals( - "You've reached your request limit.", - RequestState(reason = RequestReason.QuotaExceeded).reasonMessage(), - ) - } - - @Test - fun `reason message is empty for an existing request, an unknown code, or no reason`() { - assertNull( - RequestState(status = RequestStatus.Downloading, reason = RequestReason.AlreadyRequested).reasonMessage(), - ) - assertNull(RequestState(reason = "some_future_reason").reasonMessage()) - assertNull(RequestState(requestable = true).reasonMessage()) - } - - @Test - fun `reason message keeps readable reason text from the server`() { - assertEquals("Already requested", RequestState(reason = "Already requested").reasonMessage()) - } - - @Test - fun `can cancel only while active and pending`() { - assertTrue(request(status = RequestStatus.Pending, outcome = RequestOutcome.Active).canCancel()) - assertFalse(request(status = RequestStatus.Downloading, outcome = RequestOutcome.Active).canCancel()) - assertFalse(request(status = RequestStatus.Pending, outcome = RequestOutcome.Cancelled).canCancel()) - } - - @Test - fun `target summary joins fields and truncates with ellipsis after two targets`() { - assertNull(request().targetSummary()) - - val summary = request( - targets = listOf( - target(id = 1, instanceName = "Radarr 4K", quality = "2160p", status = "queued"), - target(id = 2, instanceName = "Radarr", quality = "1080p", status = "queued"), - target(id = 3, instanceName = "Backup", quality = "720p", status = "queued"), - ), - ).targetSummary() - - assertEquals("Radarr 4K • 2160p • queued, Radarr • 1080p • queued, …", summary) + fun `cards open the library only when their status reads in library`() { + val inLibrary = result(availability = RequestAvailability.Available).copy(libraryContentId = "item-1") + assertEquals("item-1", inLibrary.libraryItemToOpen()) + // A series in the library with missing seasons on the way opens the request. + val seasonsComing = inLibrary.copy(request = RequestState(status = RequestStatus.Downloading, state = RequestUserState.Approved)) + assertNull(seasonsComing.libraryItemToOpen()) + val landed = request(status = RequestStatus.Completed).copy(state = RequestUserState.Available, libraryContentId = "item-2") + assertEquals("item-2", landed.libraryItemToOpen()) + assertNull(landed.copy(state = RequestUserState.Processing).libraryItemToOpen()) } private fun result( @@ -132,19 +67,4 @@ class RequestPresentationTest { createdAt = "2026-06-12T00:00:00Z", updatedAt = "2026-06-12T00:00:00Z", ) - - private fun target( - id: Long, - instanceName: String, - quality: String, - status: String, - ): RequestTarget = RequestTarget( - id = id.toString(), - requestId = "request-1", - instanceName = instanceName, - quality = quality, - status = status, - createdAt = "2026-06-12T00:00:00Z", - updatedAt = "2026-06-12T00:00:00Z", - ) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/RequestsV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/RequestsV2Test.kt index 0512c21614..fc325de595 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/RequestsV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/RequestsV2Test.kt @@ -68,4 +68,44 @@ class RequestsV2Test { assertEquals(1, calls) client.close() } + + @Test fun adminQueueFiltersByTitleAndWalksPages() = runTest { + var calls = 0 + val client = HttpClient(MockEngine { request -> + assertEquals("/api/v2/admin/requests", request.url.encodedPath) + assertEquals("pending", request.url.parameters["status"]) + assertEquals("active", request.url.parameters["outcome"]) + assertEquals("series", request.url.parameters["media_type"]) + assertEquals("42", request.url.parameters["q"]) + calls++ + respond(if (calls == 1) """{"items":[$record],"page":{"has_more":true,"next_cursor":"next"}}""" + else """{"items":[$record],"page":{"has_more":false}}""", headers = headersOf(HttpHeaders.ContentType, "application/json")) + }) + val result = DefaultRequestsApi(client, ApiV2Gate.Unrestricted) + .adminRequests(status = "pending", outcome = "active", mediaType = "series", tmdbId = 42) + assertEquals(2, assertIs>(result).data.requests.size) + assertEquals(2, calls) + client.close() + } + + @Test fun moderationIsOnePostToItsActionPath() = runTest { + var calls = 0 + val client = HttpClient(MockEngine { request -> + calls++ + assertEquals(HttpMethod.Post, request.method) + assertEquals("/api/v2/admin/requests/r1/retry", request.url.encodedPath) + respond(record.replace("\"pending\"", "\"approved\""), headers = headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(SiloJson) } } + val result = DefaultRequestsApi(client, ApiV2Gate.Unrestricted) + .adminAction("r1", org.siloserver.silo.model.request.AdminRequestAction.Retry) + assertEquals("approved", assertIs>(result).data.status) + assertEquals(1, calls) + client.close() + } + + @Test fun userFacingStateDecodes() { + val decoded = SiloJson.decodeFromString(record.replace("\"outcome\":\"active\"", "\"outcome\":\"active\",\"state\":\"processing\",\"approved_at\":\"2026-09-06T00:00:00Z\"")) + assertEquals("processing", decoded.state) + assertEquals("2026-09-06T00:00:00Z", decoded.approvedAt) + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/RequestsRepositoryTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/RequestsRepositoryTest.kt index f1b02fad28..995ea29fdb 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/RequestsRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/RequestsRepositoryTest.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.repository +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.AdminRequestCapabilities import org.siloserver.silo.model.request.CreateMediaRequest import org.siloserver.silo.model.request.MediaRequest import org.siloserver.silo.model.request.RequestMediaDetail @@ -12,10 +14,14 @@ import org.siloserver.silo.model.request.RequestsListResponse import org.siloserver.silo.model.request.RequestStatus import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.RequestsApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue private fun stubRequest( @@ -39,6 +45,7 @@ private class FakeRequestsApi( private val mineResult: ApiResult = ApiResult.Success(RequestsListResponse()), private val createResult: ApiResult = ApiResult.NetworkError(IllegalStateException("no fake")), private val cancelResult: ApiResult = ApiResult.NetworkError(IllegalStateException("no fake")), + private val mineGate: CompletableDeferred? = null, ) : RequestsApi { var mineCalls = 0 @@ -70,6 +77,7 @@ private class FakeRequestsApi( offset: Int?, ): ApiResult { mineCalls++ + mineGate?.await() return mineResult } @@ -77,6 +85,19 @@ private class FakeRequestsApi( ApiResult.NetworkError(IllegalStateException("no fake")) override suspend fun cancel(id: String): ApiResult = cancelResult + + override suspend fun adminCapabilities(): ApiResult = + ApiResult.NetworkError(IllegalStateException("no fake")) + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult = ApiResult.NetworkError(IllegalStateException("no fake")) + + override suspend fun adminAction(id: String, action: AdminRequestAction, reason: String?): ApiResult = + ApiResult.NetworkError(IllegalStateException("no fake")) } class RequestsRepositoryTest { @@ -154,4 +175,59 @@ class RequestsRepositoryTest { assertTrue(repo.mine.first().isEmpty()) } + + @Test + fun `a read that answers after reset stores nothing`() = runTest { + val previous = stubRequest("a", 1) + val gate = CompletableDeferred() + val api = FakeRequestsApi(mineResult = ApiResult.Success(RequestsListResponse(listOf(previous))), mineGate = gate) + val repo = RequestsRepository(api) + + val read = async { repo.refreshMine() } + runCurrent() + // The profile switches while the previous profile's list is in flight. + repo.reset() + gate.complete(Unit) + + assertTrue(read.await() is ApiResult.Error) + assertTrue(repo.mine.first().isEmpty()) + assertNull(repo.cache.ownRecord(previous.cacheKey())) + } + + @Test + fun `a recent complete read answers for one title until reset`() = runTest { + val api = FakeRequestsApi(mineResult = ApiResult.Success(RequestsListResponse(listOf(stubRequest("a", 1))))) + val repo = RequestsRepository(api) + + repo.refreshMine() + repo.ensureMine() + assertEquals(1, api.mineCalls) + + repo.reset() + repo.ensureMine() + assertEquals(2, api.mineCalls) + } + + @Test + fun `a list read that a create overtook keeps the created request`() = runTest { + val gate = CompletableDeferred() + val created = stubRequest("created", 2) + val api = FakeRequestsApi( + mineResult = ApiResult.Success(RequestsListResponse(listOf(stubRequest("old", 1)))), + createResult = ApiResult.Success(created), + mineGate = gate, + ) + val repo = RequestsRepository(api) + + val read = async { repo.refreshMine() } + runCurrent() + repo.create(CreateMediaRequest(mediaType = RequestMediaType.Movie, tmdbId = 2, title = "Movie 2")) + gate.complete(Unit) + read.await() + + assertEquals(listOf(created), repo.mine.first()) + // The snapshot doesn't count as fresh, so the next caller reads again. + repo.ensureMine() + assertEquals(2, api.mineCalls) + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestDetailStatusTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestDetailStatusTest.kt new file mode 100644 index 0000000000..8bf1464047 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestDetailStatusTest.kt @@ -0,0 +1,85 @@ +package org.siloserver.silo.viewmodel + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.RequestAttention +import org.siloserver.silo.model.request.RequestAvailability +import org.siloserver.silo.model.request.RequestDisplayState +import org.siloserver.silo.model.request.RequestMediaDetail +import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.RequestOutcome +import org.siloserver.silo.model.request.RequestState +import org.siloserver.silo.model.request.RequestStatus +import org.siloserver.silo.model.request.RequestUserState +import org.siloserver.silo.model.request.record + +/** The detail page's status decisions (Apple `RequestDetailStatusTitleTests` / `RequestsV2Tests` parity). */ +class RequestDetailStatusTest { + + private val requestable = RequestMediaDetail( + mediaType = RequestMediaType.Movie, + tmdbId = 1, + title = "Film", + availability = RequestAvailability.Missing, + request = RequestState(requestable = true), + ) + + @Test + fun aFinishedDownloadTheTitleReadCallsRequestableStaysOnItsWay() { + // The server's per-title state skips a completed request that hasn't + // reached the library; the user's own record still knows. + val state = RequestDetailUiState( + detail = requestable, + record = record(status = RequestStatus.Completed, state = RequestUserState.Processing), + ) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.OnTheWay), state.primaryAction) + assertEquals("Adding to your library", state.primaryActionTitle) + } + + @Test + fun aFailedRequestBecomesThePageStatusAndTheRequesterCanAskAgain() { + val failed = record(status = RequestStatus.Downloading, outcome = RequestOutcome.Failed) + val requester = RequestDetailUiState(detail = requestable, record = failed) + assertEquals(RequestPrimaryAction.Request, requester.primaryAction) + assertEquals("Request Again", requester.primaryActionTitle) + assertEquals(RequestDisplayState.NeedsAttention(RequestAttention.Failed, null), requester.progress?.display) + + // An admin who can retry it gets Retry as the only action. + val admin = RequestDetailUiState(detail = requestable, moderationRecord = failed) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.NeedsAttention(RequestAttention.Failed, null)), admin.primaryAction) + assertEquals(listOf(AdminRequestAction.Retry), admin.moderationActions) + } + + @Test + fun anOldFailureStopsCountingOnceAnotherRequestIsUnderWay() { + val detail = requestable.copy( + request = RequestState(status = RequestStatus.Approved, requestable = false, requestId = "someone-else"), + ) + val state = RequestDetailUiState(detail = detail, record = record(id = "mine", outcome = RequestOutcome.Failed)) + assertNull(state.endedRequest) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.OnTheWay), state.primaryAction) + } + + @Test + fun pendingRequestsOfferCancelToTheRequesterAndDecisionsToTheAdmin() { + val pending = record(status = RequestStatus.Pending) + val detail = requestable.copy(request = RequestState(status = RequestStatus.Pending, requestId = pending.id)) + assertEquals(true, RequestDetailUiState(detail = detail, record = pending).canCancel) + val moderating = RequestDetailUiState(detail = detail, moderationRecord = pending, openedForModeration = true) + assertEquals(false, moderating.canCancel) + assertEquals(listOf(AdminRequestAction.Approve, AdminRequestAction.Decline), moderating.moderationActions) + assertEquals("Requested by someone on this server", moderating.eyebrow) + } + + @Test + fun aRequestableFailedAnnotationStillOffersRequestToTheRequester() { + // A card patched from the user's failed record carries its state but + // stays requestable; the page must not block on it. + val detail = requestable.copy( + request = RequestState(status = RequestStatus.Downloading, state = RequestUserState.Failed, requestable = true), + ) + assertEquals(RequestPrimaryAction.Request, RequestDetailUiState(detail = detail).primaryAction) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestsViewModelTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestsViewModelTest.kt index adb346e7b2..48927ed4c5 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/RequestsViewModelTest.kt @@ -1,6 +1,9 @@ package org.siloserver.silo.viewmodel +import org.siloserver.silo.model.request.AdminRequestAction +import org.siloserver.silo.model.request.AdminRequestCapabilities import org.siloserver.silo.model.request.CreateMediaRequest +import org.siloserver.silo.model.request.RequestDisplayState import org.siloserver.silo.model.request.MediaRequest import org.siloserver.silo.model.request.RequestAvailability import org.siloserver.silo.model.request.RequestDiscoverySection @@ -17,6 +20,7 @@ import org.siloserver.silo.model.request.RequestsListResponse import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.RequestsApi import org.siloserver.silo.repository.RequestsRepository +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -48,7 +52,7 @@ class RequestsViewModelTest { } @Test - fun `requests view model loads status then discover sections`() = runTest(dispatcher) { + fun `requests view model loads discover sections and the user's requests`() = runTest(dispatcher) { // Server sections are per-media-type; the VM merges them into the // hub's two carousels (iOS RequestCarouselMerge parity). val section = RequestDiscoverySection( @@ -56,37 +60,26 @@ class RequestsViewModelTest { title = "Trending Movies", results = listOf(stubResult(tmdbId = 11, title = "The Signal")), ) + val failed = stubRequest(id = "r-failed", tmdbId = 70, title = "Broken", outcome = RequestOutcome.Failed) + val pending = stubRequest(id = "r-pending", tmdbId = 71, title = "Waiting") val api = FakeRequestsApi( - statusResult = ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), discoverResult = ApiResult.Success(RequestsDiscoverResponse(listOf(section))), + mineResult = ApiResult.Success(RequestsListResponse(listOf(failed, pending))), ) val viewModel = RequestsViewModel(RequestsRepository(api)) val state = viewModel.uiState.value assertFalse(state.isLoading) - assertTrue(state.isEnabled) + assertTrue(state.hasLoaded) assertEquals(listOf("trending"), state.sections.map { it.key }) assertEquals("Trending now", state.sections.single().title) assertEquals(listOf(11), state.sections.single().results.map { it.tmdbId }) + // In progress before needs-you, matching the My Requests buckets. + assertEquals(listOf("r-pending", "r-failed"), state.myRequests.map { it.id }) + assertEquals(1, state.inProgressCount) + assertEquals(1, state.needsAttentionCount) assertNull(state.error) - assertEquals(listOf("status", "discover"), api.calls) - } - - @Test - fun `requests view model skips discover when requests are disabled`() = runTest(dispatcher) { - val api = FakeRequestsApi( - statusResult = ApiResult.Success(RequestsFeatureStatus(requestsEnabled = false)), - ) - - val viewModel = RequestsViewModel(RequestsRepository(api)) - val state = viewModel.uiState.value - - assertFalse(state.isLoading) - assertFalse(state.isEnabled) - assertTrue(state.sections.isEmpty()) - assertEquals("Requests are not enabled on this server.", state.error) - assertEquals(listOf("status"), api.calls) } @Test @@ -229,6 +222,7 @@ class RequestsViewModelTest { mediaType = RequestMediaType.Movie, tmdbId = 33, ) + assertEquals(RequestPrimaryAction.Request, viewModel.uiState.value.primaryAction) viewModel.submitRequest() val state = viewModel.uiState.value @@ -236,8 +230,9 @@ class RequestsViewModelTest { assertFalse(state.isLoading) assertFalse(state.isSubmitting) assertEquals(refreshedDetail, state.detail) - assertEquals("Request submitted.", state.notice) - assertNull(state.error) + assertEquals(1, state.submittedCount) + assertEquals(RequestPrimaryAction.Status(RequestDisplayState.Pending), state.primaryAction) + assertNull(state.actionErrorMessage) assertEquals( CreateMediaRequest( mediaType = RequestMediaType.Movie, @@ -251,16 +246,66 @@ class RequestsViewModelTest { ), api.createRequests.single(), ) + // The create patched the user's list, so the re-read reuses it. assertEquals( - listOf("detail:movie:33", "detail:movie:33"), + listOf("detail:movie:33", "mine", "detail:movie:33"), api.calls, ) } + @Test + fun `an unconfirmed create stays held until a read shows the request`() = runTest(dispatcher) { + val requestable = RequestMediaDetail( + mediaType = RequestMediaType.Movie, + tmdbId = 33, + title = "Deep Archive", + request = RequestState(requestable = true), + ) + val api = FakeRequestsApi( + // The first read, then the one right after the timeout: the server + // hasn't committed the create yet. + detailResults = ArrayDeque(listOf(ApiResult.Success(requestable), ApiResult.Success(requestable))), + createResult = ApiResult.NetworkError(IllegalStateException("timed out")), + ) + val viewModel = RequestDetailViewModel(RequestsRepository(api), RequestMediaType.Movie, 33) + + viewModel.submitRequest() + assertTrue(viewModel.uiState.value.isSubmissionUnconfirmed) + assertTrue(viewModel.uiState.value.primaryAction != RequestPrimaryAction.Request) + viewModel.submitRequest() + assertEquals(1, api.createRequests.size) + + // The hold lapses on its own. + dispatcher.scheduler.advanceTimeBy(61_000) + assertFalse(viewModel.uiState.value.isSubmissionUnconfirmed) + } + + @Test + fun `an approvals read sent before a decision does not bring the row back`() = runTest(dispatcher) { + val waiting = stubRequest(id = "r-1", tmdbId = 80, title = "Waiting") + val gate = CompletableDeferred() + val api = FakeRequestsApi( + adminHandler = { status, _ -> + gate.await() + ApiResult.Success(RequestsListResponse(if (status == RequestStatus.Pending) listOf(waiting) else emptyList())) + }, + adminActionResult = ApiResult.Success(waiting.copy(status = RequestStatus.Approved)), + ) + val viewModel = RequestApprovalsViewModel(RequestsRepository(api), loadOnInit = false) + viewModel.refresh() + + viewModel.perform(AdminRequestAction.Approve, waiting) + dispatcher.scheduler.advanceTimeBy(1_000) + gate.complete(Unit) + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.uiState.value.awaitingApproval.isEmpty()) + assertEquals(listOf("r-1"), api.adminActionCalls) + } + @Test fun `requests view model falls back to default message for blank discover errors`() = runTest(dispatcher) { val api = FakeRequestsApi( - statusResult = ApiResult.Success(RequestsFeatureStatus(requestsEnabled = true)), discoverResult = ApiResult.Error(code = 500, error = "internal", message = ""), ) @@ -270,8 +315,8 @@ class RequestsViewModelTest { } @Test - fun `requests view model shows network copy when status request cannot reach the server`() = runTest(dispatcher) { - val api = FakeRequestsApi(statusResult = ApiResult.NetworkError(IllegalStateException("offline"))) + fun `requests view model shows network copy when discover cannot reach the server`() = runTest(dispatcher) { + val api = FakeRequestsApi(discoverResult = ApiResult.NetworkError(IllegalStateException("offline"))) val viewModel = RequestsViewModel(RequestsRepository(api)) @@ -327,13 +372,15 @@ class RequestsViewModelTest { val viewModel = MyRequestsViewModel(RequestsRepository(api)) assertEquals(listOf(pending), viewModel.uiState.value.requests) + api.mineResult = ApiResult.Success(RequestsListResponse(listOf(cancelled))) - viewModel.cancel("request-1") + viewModel.cancel(pending) val state = viewModel.uiState.value assertFalse(state.isLoading) - assertNull(state.actionInFlightId) - assertEquals(listOf(cancelled), state.requests) + assertNull(state.cancellingId) + // A cancelled request leaves My Requests. + assertTrue(state.requests.isEmpty()) assertNull(state.error) assertEquals(listOf("request-1"), api.cancelCalls) } @@ -356,7 +403,7 @@ private class FakeRequestsApi( ArrayDeque(listOf(ApiResult.NetworkError(IllegalStateException("no detail fake")))), private val createResult: ApiResult = ApiResult.NetworkError(IllegalStateException("no create fake")), - private val mineResult: ApiResult = + var mineResult: ApiResult = ApiResult.Success(RequestsListResponse()), private val cancelResult: ApiResult = ApiResult.NetworkError(IllegalStateException("no cancel fake")), @@ -371,12 +418,15 @@ private class FakeRequestsApi( limit: Int?, offset: Int?, ) -> ApiResult)? = null, + private val adminHandler: (suspend (status: String?, outcome: String?) -> ApiResult)? = null, + private val adminActionResult: ApiResult = ApiResult.Error(403, "forbidden", ""), ) : RequestsApi { val calls = mutableListOf() val searchCalls = mutableListOf() val createRequests = mutableListOf() val cancelCalls = mutableListOf() + val adminActionCalls = mutableListOf() override suspend fun status(): ApiResult { calls += "status" @@ -428,6 +478,21 @@ private class FakeRequestsApi( cancelCalls += id return cancelResult } + + override suspend fun adminCapabilities(): ApiResult = + ApiResult.Error(403, "forbidden", "") + + override suspend fun adminRequests( + status: String?, + outcome: String?, + mediaType: String?, + tmdbId: Int?, + ): ApiResult = adminHandler?.invoke(status, outcome) ?: ApiResult.Error(403, "forbidden", "") + + override suspend fun adminAction(id: String, action: AdminRequestAction, reason: String?): ApiResult { + adminActionCalls += id + return adminActionResult + } } private fun stubResult( From 1d834f5f979585cc54111413c00a240b54929361 Mon Sep 17 00:00:00 2001 From: Suspense Date: Fri, 2 Oct 2026 03:34:10 +0200 Subject: [PATCH 06/87] feat(downloads): save poster and series artwork with downloads for offline display (#432) * feat(downloads): save poster and series artwork with downloads for offline display Download rows loaded the catalog's signed poster URL, so offline they showed only the ThumbHash once Coil's cache no longer held that URL. Fetch the poster and series poster listed in the offline manifest with each new download, store them with its assets, and show them in the Downloads tab tiles. Episode rows use the series poster, never the 16:9 episode still. Closes #431 * fix(downloads): refuse redirects on offline artwork fetches The server streams artwork itself, so a redirect is never legitimate. Following one would fetch a URL the same-server check never vetted. * fix(downloads): refresh tiles when only the poster ThumbHash is saved The saved-artwork query skipped rows whose only artwork was posterThumbhash, so a movie whose poster fetch failed never triggered a Downloads reload and kept a blank tile. --- .../14.json | 1004 +++++++++++++++++ .../silo/common/data/db/SiloDatabase.kt | 3 +- .../silo/common/data/db/dao/DownloadDao.kt | 22 + .../common/data/db/entity/DownloadEntity.kt | 7 + .../common/downloads/DownloadMetadataStore.kt | 11 + .../downloads/DownloadSidecarMapping.kt | 6 + .../silo/common/downloads/DownloadStorage.kt | 9 + .../common/downloads/DownloadTileArtwork.kt | 49 + .../silo/common/downloads/DownloadWorker.kt | 31 +- .../downloads/OfflineTrackAssetFetcher.kt | 152 ++- .../common/ui/components/ThumbhashImage.kt | 9 +- .../data/db/SiloDatabaseMigrationTest.kt | 29 + .../downloads/DownloadMetadataStoreTest.kt | 56 + .../downloads/DownloadSidecarArtworkTest.kt | 106 ++ .../downloads/OfflineTrackAssetFetcherTest.kt | 188 ++- .../screens/downloads/DownloadsViewModel.kt | 131 ++- .../silo/model/download/DownloadSidecar.kt | 9 + .../silo/model/download/OfflineArtwork.kt | 68 ++ .../silo/model/download/OfflineArtworkTest.kt | 79 ++ 19 files changed, 1878 insertions(+), 91 deletions(-) create mode 100644 android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/14.json create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineArtwork.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineArtworkTest.kt diff --git a/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/14.json b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/14.json new file mode 100644 index 0000000000..db0539046e --- /dev/null +++ b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/14.json @@ -0,0 +1,1004 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "96fd5a7fa0b67f01e9dd5635ed13546a", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, `offlineTracksJson` TEXT, `offlinePosterPath` TEXT, `offlineSeriesPosterPath` TEXT, `seriesPosterThumbhash` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "offlineTracksJson", + "columnName": "offlineTracksJson", + "affinity": "TEXT" + }, + { + "fieldPath": "offlinePosterPath", + "columnName": "offlinePosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "offlineSeriesPosterPath", + "columnName": "offlineSeriesPosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesPosterThumbhash", + "columnName": "seriesPosterThumbhash", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '96fd5a7fa0b67f01e9dd5635ed13546a')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt index bff238b764..9d6bea2a7b 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt @@ -56,7 +56,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 13, + version = 14, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -70,6 +70,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12), AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), ], ) abstract class SiloDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DownloadDao.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DownloadDao.kt index 2fb8adce43..aabd725171 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DownloadDao.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DownloadDao.kt @@ -4,8 +4,18 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow import org.siloserver.silo.common.data.db.entity.DownloadEntity +/** The artwork columns of one downloads row, for [DownloadDao.observeSavedArtwork]. */ +data class DownloadArtworkRow( + val recordId: String, + val posterThumbhash: String?, + val offlinePosterPath: String?, + val offlineSeriesPosterPath: String?, + val seriesPosterThumbhash: String?, +) + /** * Downloads projection access. Scoped reads seed the Downloads tab and the * offline player without a server round-trip; status scans drive @@ -69,6 +79,18 @@ interface DownloadDao { @Query("SELECT * FROM downloads ORDER BY updatedAtMs DESC") suspend fun getAllAcrossScopes(): List + /** + * Rows that carry saved artwork, across scopes. Room re-queries on every + * downloads write (progress and status included), so collectors should + * apply `distinctUntilChanged` and react only when artwork actually changes. + */ + @Query( + "SELECT recordId, posterThumbhash, offlinePosterPath, offlineSeriesPosterPath, seriesPosterThumbhash " + + "FROM downloads WHERE posterThumbhash IS NOT NULL OR offlinePosterPath IS NOT NULL " + + "OR offlineSeriesPosterPath IS NOT NULL OR seriesPosterThumbhash IS NOT NULL ORDER BY recordId", + ) + fun observeSavedArtwork(): Flow> + /** First download (any scope) for a content id — offline player contentId→file lookup. */ @Query("SELECT * FROM downloads WHERE contentId = :contentId LIMIT 1") suspend fun findByContentId(contentId: String): DownloadEntity? diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt index ea39b119e4..647a030dec 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt @@ -86,4 +86,11 @@ data class DownloadEntity( * downloaded file's audio tracks and the local subtitle sidecars. Null for * downloads completed before offline track data was captured. */ val offlineTracksJson: String? = null, + /** Local path of the saved item poster (episode image for episodes); null + * for downloads completed before artwork was saved, or when none was. */ + val offlinePosterPath: String? = null, + /** Local path of the saved parent series poster (episode downloads only). */ + val offlineSeriesPosterPath: String? = null, + /** ThumbHash of the parent series poster (episode downloads only). */ + val seriesPosterThumbhash: String? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt index 6cc16cd208..fe8bbd4b6b 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt @@ -1,7 +1,10 @@ package org.siloserver.silo.common.downloads import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import org.siloserver.silo.common.data.db.SiloDatabase +import org.siloserver.silo.common.data.db.dao.DownloadArtworkRow import org.siloserver.silo.model.download.DownloadSidecar /** @@ -69,4 +72,12 @@ class DownloadMetadataStore(private val db: SiloDatabase) { suspend fun deleteAllForServer(serverId: String) = downloadDao.deleteAllForServer(serverId) + + /** + * Saved-artwork columns of every download that has any, emitting only when + * they change. The download capture writes artwork after the completed + * status, so the Downloads tab watches this to pick up the saved images. + */ + fun savedArtworkChanges(): Flow> = + downloadDao.observeSavedArtwork().distinctUntilChanged() } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt index dd859337de..ac48df83b9 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt @@ -60,6 +60,9 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit effectiveQuality = record.effectiveQuality, revision = record.revision, offlineTracksJson = offlineTracks?.let { mappingJson.encodeToString(it) }, + offlinePosterPath = offlinePosterPath, + offlineSeriesPosterPath = offlineSeriesPosterPath, + seriesPosterThumbhash = seriesPosterThumbhash, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -102,5 +105,8 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = offlineTracks = offlineTracksJson?.let { runCatching { mappingJson.decodeFromString(it) }.getOrNull() }, + offlinePosterPath = offlinePosterPath, + offlineSeriesPosterPath = offlineSeriesPosterPath, + seriesPosterThumbhash = seriesPosterThumbhash, updatedAtMs = updatedAtMs, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt index 33321250e6..5977a0efc6 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt @@ -155,6 +155,14 @@ class DownloadStorage( fun offlineSubtitleDirectory(serverId: String, profileId: String, fileId: Int): File? = containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString(), SUBTITLES_DIR) + /** + * Private directory for the artwork saved with one download. Separate from + * [offlineSubtitleDirectory], which the track capture clears on every run, + * and removed with the download by [delete]. + */ + fun offlineArtworkDirectory(serverId: String, profileId: String, fileId: Int): File? = + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString(), ARTWORK_DIR) + private val offlineAssetsRoot: File get() = File(baseDir, OFFLINE_ASSETS_DIR) /** Sum of bytes across every downloaded file under this storage. */ @@ -190,6 +198,7 @@ class DownloadStorage( private companion object { const val OFFLINE_ASSETS_DIR = "download-assets" const val SUBTITLES_DIR = "subtitles" + const val ARTWORK_DIR = "artwork" } } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt new file mode 100644 index 0000000000..3213c46383 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt @@ -0,0 +1,49 @@ +package org.siloserver.silo.common.downloads + +import org.siloserver.silo.model.download.DownloadMediaType +import org.siloserver.silo.model.download.DownloadSidecar +import java.io.File + +/** Image source and blur-up placeholder for a download's 2:3 tile. */ +data class DownloadTileArtwork(val url: String?, val thumbhash: String?) + +/** + * The 2:3 tile artwork for one download. A movie uses its saved poster, else + * the catalog [DownloadSidecar.posterUrl]. An episode uses the saved series + * poster, else the catalog URL (already the series poster): its own saved + * `poster` is a 16:9 still and never goes in a 2:3 tile. Saved paths are + * checked on disk, so call off the main thread. + */ +fun DownloadSidecar.tileArtwork(isFile: (String) -> Boolean = ::isSavedArtworkFile): DownloadTileArtwork = + listOf(this).tileArtwork(isFile) + +/** + * The tile artwork for a group of downloads (a series or season row): any + * member's saved series poster, then (movies only) any member's saved poster, + * then the first catalog poster URL. + */ +fun List.tileArtwork(isFile: (String) -> Boolean = ::isSavedArtworkFile): DownloadTileArtwork { + val saved = firstNotNullOfOrNull { sidecar -> sidecar.offlineSeriesPosterPath?.takeIf(isFile) } + ?: firstNotNullOfOrNull { sidecar -> + sidecar.offlinePosterPath?.takeIf { !sidecar.isEpisodeDownload() && isFile(it) } + } + return DownloadTileArtwork( + url = saved?.let(::localArtworkUrl) ?: firstNotNullOfOrNull { it.posterUrl }, + thumbhash = firstNotNullOfOrNull { it.seriesPosterThumbhash } ?: firstNotNullOfOrNull { it.posterThumbhash }, + ) +} + +/** + * True for a TV episode download. Rows written before `mediaType` was stored + * count as episodes when they carry a series title, as the Downloads tab does. + */ +fun DownloadSidecar.isEpisodeDownload(): Boolean = + when (DownloadMediaType.fromWire(mediaType)) { + DownloadMediaType.TvShow -> true + DownloadMediaType.Unknown -> !seriesTitle.isNullOrBlank() + else -> false + } + +private fun isSavedArtworkFile(path: String): Boolean = File(path).isFile + +private fun localArtworkUrl(path: String): String = "file://$path" diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt index 8c668ecaba..2cf835154e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt @@ -478,9 +478,10 @@ class DownloadWorker( } /** - * Fetches the offline manifest's audio tracks and subtitle sidecars for a - * published video download and stores them on its metadata row. Best - * effort: every failure leaves the download playable the legacy way. + * Fetches the offline manifest's audio tracks, subtitle sidecars and + * artwork for a published video download and stores them on its metadata + * row. Best effort: every failure leaves the download playable the legacy + * way, and a failed artwork fetch only leaves that image's path empty. */ private suspend fun captureOfflineTracks( downloadId: String, @@ -492,21 +493,39 @@ class DownloadWorker( if (!OfflineTrackAssetFetcher.appliesTo(mediaType)) return try { requireOwner() - val tracks = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { + val assets = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { transferAuthority?.let { managedDownloadAuth(it.scope) } } ?: return + val tracks = assets.tracks + val artwork = assets.artwork ownedWrite { val existing = metadataStore.readSidecar(serverId, profileId, fileId) ?.takeIf { it.record.id == downloadId } if (existing != null) { metadataStore.writeSidecar( serverId, profileId, - existing.copy(offlineTracks = tracks, updatedAtMs = System.currentTimeMillis()), + existing.copy( + offlineTracks = tracks, + offlinePosterPath = artwork.posterPath, + offlineSeriesPosterPath = artwork.seriesPosterPath, + // A movie queued without a thumbhash takes the + // manifest's. An episode's poster_thumbhash hashes + // its 16:9 still, not the series poster its tile shows. + posterThumbhash = existing.posterThumbhash + ?: artwork.posterThumbhash.takeUnless { existing.isEpisodeDownload() }, + seriesPosterThumbhash = artwork.seriesPosterThumbhash ?: existing.seriesPosterThumbhash, + updatedAtMs = System.currentTimeMillis(), + ), ) } true } - Log.i(TAG, "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} subtitles=${tracks.subtitles.size}") + Log.i( + TAG, + "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} " + + "subtitles=${tracks.subtitles.size} poster=${artwork.posterPath != null} " + + "seriesPoster=${artwork.seriesPosterPath != null}", + ) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt index d88c7733f2..3bc006585c 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt @@ -7,19 +7,26 @@ import io.ktor.client.plugins.timeout import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get import io.ktor.client.request.prepareGet +import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode +import io.ktor.http.contentLength import io.ktor.http.encodeURLPathPart import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import org.siloserver.silo.model.download.DownloadMediaType -import org.siloserver.silo.model.download.OfflineManifestTracks +import org.siloserver.silo.model.download.OFFLINE_ARTWORK_POSTER +import org.siloserver.silo.model.download.OFFLINE_ARTWORK_SERIES_POSTER +import org.siloserver.silo.model.download.OfflineArtworkFiles +import org.siloserver.silo.model.download.OfflineManifestArtwork import org.siloserver.silo.model.download.OfflineManifestSubtitle import org.siloserver.silo.model.download.OfflineSubtitleFile import org.siloserver.silo.model.download.OfflineTrackInfo +import org.siloserver.silo.model.download.decodeOfflineManifestArtwork import org.siloserver.silo.model.download.decodeOfflineManifestTracks +import org.siloserver.silo.model.download.isOfflineArtworkFetchUrl import org.siloserver.silo.model.download.isOfflineSubtitleFetchUrl import org.siloserver.silo.model.download.offlineSubtitleExtension import org.siloserver.silo.model.download.offlineSubtitleFormat @@ -28,10 +35,17 @@ import org.siloserver.silo.playback.orNullIfBlank import java.io.File import java.io.IOException +/** What one offline-manifest capture produced for a completed download. */ +internal data class OfflineAssets( + val tracks: OfflineTrackInfo, + val artwork: OfflineArtworkFiles, +) + /** * Captures what offline video playback needs once the media bytes are down: - * the offline manifest's audio tracks (positions inside the delivered file) and - * the subtitle sidecars it lists, each fetched once into private storage. + * the offline manifest's audio tracks (positions inside the delivered file), + * the subtitle sidecars it lists, and the poster (plus the series poster for + * episodes), each fetched once into private storage. * * Everything here is best effort. A missing manifest, an older server, or a * sidecar that fails to fetch never fails the video download; the download @@ -42,14 +56,20 @@ internal class OfflineTrackAssetFetcher( private val storage: DownloadStorage, private val manifestRetryDelayMs: Long = 2_000, ) { + // The server streams artwork itself, so a redirect is never legitimate; + // refusing it keeps the fetch on the URL isOfflineArtworkFetchUrl vetted. + private val artworkHttpClient = httpClient.config { followRedirects = false } + suspend fun fetch( downloadId: String, serverId: String, profileId: String, fileId: Int, configure: HttpRequestBuilder.() -> Unit, - ): OfflineTrackInfo? { - val manifest = fetchManifest(downloadId, configure) ?: return null + ): OfflineAssets? { + // One manifest request feeds both the track and the artwork capture. + val body = fetchManifestBody(downloadId, configure) ?: return null + val manifest = decodeOfflineManifestTracks(body) ?: return null val directory = storage.offlineSubtitleDirectory(serverId, profileId, fileId) // A replaced download (new revision) must not keep the previous @@ -62,7 +82,8 @@ internal class OfflineTrackAssetFetcher( fetchSubtitle(downloadId, ordinal, subtitle, directory, configure) } } - return manifest.toOfflineTrackInfo(saved) + val artwork = fetchArtwork(downloadId, serverId, profileId, fileId, decodeOfflineManifestArtwork(body), configure) + return OfflineAssets(tracks = manifest.toOfflineTrackInfo(saved), artwork = artwork) } /** @@ -70,10 +91,10 @@ internal class OfflineTrackAssetFetcher( * blip or a transient server error is retried briefly here; a missing * manifest (older server) is not. */ - private suspend fun fetchManifest( + private suspend fun fetchManifestBody( downloadId: String, configure: HttpRequestBuilder.() -> Unit, - ): OfflineManifestTracks? { + ): String? { repeat(MANIFEST_ATTEMPTS) { attempt -> if (attempt > 0) delay(manifestRetryDelayMs * attempt) try { @@ -81,7 +102,7 @@ internal class OfflineTrackAssetFetcher( configure() } val status = response.status - if (status == HttpStatusCode.OK) return decodeOfflineManifestTracks(response.bodyAsText()) + if (status == HttpStatusCode.OK) return response.bodyAsText() Log.i(TAG, "manifest unavailable id=$downloadId status=${status.value}") if (status.value < 500 && status != HttpStatusCode.TooManyRequests) return null } catch (e: CancellationException) { @@ -93,6 +114,98 @@ internal class OfflineTrackAssetFetcher( return null } + /** + * Saves the manifest's `poster` and, when listed, `series_poster` into the + * download's artwork directory. Each image is independent: a failure leaves + * that path null and never affects the download or the other image. + */ + private suspend fun fetchArtwork( + downloadId: String, + serverId: String, + profileId: String, + fileId: Int, + manifest: OfflineManifestArtwork?, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineArtworkFiles { + val directory = storage.offlineArtworkDirectory(serverId, profileId, fileId) + // Same slot, new revision: drop the previous revision's images first. + directory?.deleteRecursively() + if (manifest == null) return OfflineArtworkFiles() + val urls = manifest.artworkUrls + suspend fun save(kind: String, url: String?): String? = + if (directory == null || url.isNullOrBlank()) null + else fetchArtworkImage(downloadId, kind, url, directory, configure) + return OfflineArtworkFiles( + posterPath = save(OFFLINE_ARTWORK_POSTER, urls.poster), + seriesPosterPath = save(OFFLINE_ARTWORK_SERIES_POSTER, urls.seriesPoster), + posterThumbhash = manifest.posterThumbhash.orNullIfBlank(), + seriesPosterThumbhash = manifest.seriesPosterThumbhash.orNullIfBlank(), + ) + } + + private suspend fun fetchArtworkImage( + downloadId: String, + kind: String, + url: String, + directory: File, + configure: HttpRequestBuilder.() -> Unit, + ): String? { + if (!isOfflineArtworkFetchUrl(url, kind)) { + Log.w(TAG, "skipping artwork with unexpected reference id=$downloadId kind=$kind") + return null + } + val target = File(directory, kind) + val partial = File(directory, "$kind.part") + return try { + if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory") + artworkHttpClient.prepareGet(url.trim()) { + configure() + timeout { + requestTimeoutMillis = ARTWORK_REQUEST_TIMEOUT_MS + socketTimeoutMillis = ARTWORK_IDLE_TIMEOUT_MS + } + }.execute { response -> + if (response.status != HttpStatusCode.OK) { + throw IOException("HTTP ${response.status.value}") + } + val declared = response.contentLength() + if (declared != null && declared > MAX_ARTWORK_BYTES) { + throw IOException("artwork exceeds $MAX_ARTWORK_BYTES bytes") + } + val written = copyCapped(response, partial, MAX_ARTWORK_BYTES, "artwork") + if (written == 0L) throw IOException("empty artwork") + } + if (!partial.renameTo(target)) throw IOException("could not publish $target") + target.absolutePath + } catch (e: CancellationException) { + partial.delete() + throw e + } catch (e: Exception) { + Log.w(TAG, "artwork fetch failed id=$downloadId kind=$kind", e) + partial.delete() + target.delete() + null + } + } + + /** Streams [response]'s body into [partial], failing once it passes [maxBytes]. */ + private suspend fun copyCapped(response: HttpResponse, partial: File, maxBytes: Long, what: String): Long { + var written = 0L + response.bodyAsChannel().toInputStream().use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + val read = input.read(buffer) + if (read < 0) break + written += read + if (written > maxBytes) throw IOException("$what exceeds $maxBytes bytes") + output.write(buffer, 0, read) + } + } + } + return written + } + private suspend fun fetchSubtitle( downloadId: String, ordinal: Int, @@ -122,19 +235,7 @@ internal class OfflineTrackAssetFetcher( if (response.status != HttpStatusCode.OK) { throw IOException("HTTP ${response.status.value}") } - var written = 0L - response.bodyAsChannel().toInputStream().use { input -> - partial.outputStream().use { output -> - val buffer = ByteArray(BUFFER_BYTES) - while (true) { - val read = input.read(buffer) - if (read < 0) break - written += read - if (written > MAX_SUBTITLE_BYTES) throw IOException("subtitle exceeds $MAX_SUBTITLE_BYTES bytes") - output.write(buffer, 0, read) - } - } - } + val written = copyCapped(response, partial, MAX_SUBTITLE_BYTES, "subtitle") if (written == 0L) throw IOException("empty subtitle") } if (!partial.renameTo(target)) throw IOException("could not publish $target") @@ -166,7 +267,12 @@ internal class OfflineTrackAssetFetcher( /** PGS tracks for a feature run to tens of MB; text is far smaller. */ private const val MAX_SUBTITLE_BYTES = 256L * 1024 * 1024 - /** Only video downloads have tracks to capture. */ + /** A poster is a few hundred KB; anything past this is not a poster. */ + internal const val MAX_ARTWORK_BYTES = 10L * 1024 * 1024 + private const val ARTWORK_REQUEST_TIMEOUT_MS = 60_000L + private const val ARTWORK_IDLE_TIMEOUT_MS = 30_000L + + /** Only video downloads have tracks and artwork to capture. */ fun appliesTo(mediaType: String?): Boolean = when (DownloadMediaType.fromWire(mediaType)) { DownloadMediaType.Movie, DownloadMediaType.TvShow, DownloadMediaType.Unknown -> true diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt index 9eef272cdb..2a744dbf20 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt @@ -89,8 +89,9 @@ private fun decodeThumbhashPainter(hash: String): BitmapPainter? = * * Decodes [thumbhash] (a tiny base64-encoded ThumbHash) into a small bitmap and * shows it immediately, crossfading to the full network image as it loads — so - * posters/backdrops blur up instead of popping in from a flat color. Falls back - * to a neutral placeholder when no thumbhash is supplied or decoding fails. + * posters/backdrops blur up instead of popping in from a flat color. The + * ThumbHash stays up when the image fails to load. Falls back to a neutral + * placeholder when no thumbhash is supplied or decoding fails. * * @param url The remote image URL to load. * @param thumbhash Base64-encoded ThumbHash for the instant blurred preview. @@ -187,6 +188,9 @@ fun ThumbhashImage( contentScale = contentScale, alignment = alignment, placeholder = placeholder, + // A failed load (offline, expired URL) keeps the blur-up rather + // than dropping to an empty box. + error = placeholder, colorFilter = colorFilter, onSuccess = { onSuccess?.invoke() }, onError = { onError?.invoke() }, @@ -255,6 +259,7 @@ fun ThumbhashImage( // Matches the cover above, so the swap at the gate is pixel-identical // when a thumbhash exists. placeholder = placeholder, + error = placeholder, colorFilter = colorFilter, onSuccess = { state -> fullImageReady = true diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt index db0e94ae1b..fc64031c6b 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt @@ -144,6 +144,35 @@ class SiloDatabaseMigrationTest { } } + @Test + fun migration13To14KeepsOfflineTracksAndAddsNullableArtworkColumns() { + val name = "migration-13-to-14" + migrationHelper.createDatabase(name, 13).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, posterThumbhash, " + + "mediaType, status, kind, fileSize, bytesSent, createdAt, updatedAtMs, revision, offlineTracksJson) VALUES " + + "('s', 'p', 42, 'dl_1', 'ep_1', 'Example', 'THUMB', 'tv', 'completed', 'queued', 1024, 1024, " + + "'2026-09-29T00:00:00Z', 123, 7, '{}')", + ) + } + migrationHelper.runMigrationsAndValidate(name, 14, true).use { database -> + database.query( + "SELECT recordId, posterThumbhash, revision, offlineTracksJson, offlinePosterPath, " + + "offlineSeriesPosterPath, seriesPosterThumbhash FROM downloads", + ).use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("dl_1", cursor.getString(0)) + assertEquals("THUMB", cursor.getString(1)) + assertEquals(7, cursor.getInt(2)) + assertEquals("{}", cursor.getString(3)) + assertNull(cursor.getString(4)) + assertNull(cursor.getString(5)) + assertNull(cursor.getString(6)) + assertEquals(false, cursor.moveToNext()) + } + } + } + private companion object { const val DATABASE_NAME = "migration-7-to-8" } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt index 7659f0082d..34f5bac1eb 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt @@ -5,7 +5,13 @@ import androidx.test.core.app.ApplicationProvider import org.siloserver.silo.common.data.db.SiloDatabase import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadSidecar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.siloserver.silo.common.data.db.dao.DownloadArtworkRow import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.AfterTest @@ -221,4 +227,54 @@ class DownloadMetadataStoreTest { assertNotNull(store.readSidecar("server", "profile", 7)) } + + @Test + fun `savedArtworkChanges emits on artwork writes and not on other column writes`() = runBlocking { + val emissions = Channel>(Channel.UNLIMITED) + val collector = launch(Dispatchers.Default) { store.savedArtworkChanges().collect { emissions.send(it) } } + suspend fun next() = withTimeout(10_000) { emissions.receive() } + try { + assertEquals(emptyList(), next()) + + val downloading = stubSidecar(7).let { + it.copy(record = it.record.copy(status = "downloading", bytesSent = 10)) + } + // A new row and progress ticks carry no artwork. + store.writeSidecar("srv1", "profA", downloading) + store.writeSidecar("srv1", "profA", downloading.copy(record = downloading.record.copy(bytesSent = 20))) + val completed = stubSidecar(7).copy(offlinePosterPath = "/art/poster") + store.writeSidecar("srv1", "profA", completed) + assertEquals(listOf(DownloadArtworkRow("dl-7", null, "/art/poster", null, null)), next()) + + // Status and title rewrites keep the artwork as it was. + store.writeSidecar("srv1", "profA", completed.copy(title = "Renamed", updatedAtMs = 2L)) + store.writeSidecar("srv1", "profA", completed.copy(offlineSeriesPosterPath = "/art/series_poster")) + // Ordered invalidation: had the rename emitted, it would be received here. + assertEquals( + listOf(DownloadArtworkRow("dl-7", null, "/art/poster", "/art/series_poster", null)), + next(), + ) + } finally { + collector.cancel() + } + } + + @Test + fun `savedArtworkChanges emits when only the poster thumbhash is saved`() = runBlocking { + val emissions = Channel>(Channel.UNLIMITED) + val collector = launch(Dispatchers.Default) { store.savedArtworkChanges().collect { emissions.send(it) } } + suspend fun next() = withTimeout(10_000) { emissions.receive() } + try { + assertEquals(emptyList(), next()) + + // A completed movie queued without a hash, whose poster fetch failed: + // the capture saves only the manifest's ThumbHash. + val completed = stubSidecar(7) + store.writeSidecar("srv1", "profA", completed) + store.writeSidecar("srv1", "profA", completed.copy(posterThumbhash = "hash")) + assertEquals(listOf(DownloadArtworkRow("dl-7", "hash", null, null, null)), next()) + } finally { + collector.cancel() + } + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt new file mode 100644 index 0000000000..bbcff5735f --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt @@ -0,0 +1,106 @@ +package org.siloserver.silo.common.downloads + +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadSidecar +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DownloadSidecarArtworkTest { + + private val sidecar = DownloadSidecar( + record = DownloadRecord( + id = "dl_1", + contentId = "sr_1", + mediaFileId = 42, + kind = "queued", + status = "completed", + createdAt = "2026-09-29T00:00:00Z", + ), + title = "Example", + posterUrl = "https://silo.example/api/v2/artwork/poster", + posterThumbhash = "EPISODE", + updatedAtMs = 1L, + ) + + @Test + fun savedArtworkSurvivesTheRoomRow() { + val withArtwork = sidecar.copy( + offlinePosterPath = "/data/download-assets/srv/prof/42/artwork/poster", + offlineSeriesPosterPath = "/data/download-assets/srv/prof/42/artwork/series_poster", + seriesPosterThumbhash = "SERIES", + ) + + val entity = withArtwork.toEntity("srv", "prof") + assertEquals("/data/download-assets/srv/prof/42/artwork/poster", entity.offlinePosterPath) + assertEquals("/data/download-assets/srv/prof/42/artwork/series_poster", entity.offlineSeriesPosterPath) + assertEquals("SERIES", entity.seriesPosterThumbhash) + assertEquals(withArtwork, entity.toSidecar()) + } + + @Test + fun rowsWithoutArtworkStillLoad() { + val restored = sidecar.toEntity("srv", "prof").toSidecar() + + assertNull(restored.offlinePosterPath) + assertNull(restored.offlineSeriesPosterPath) + assertNull(restored.seriesPosterThumbhash) + } + + @Test + fun movieTilesUseTheSavedPosterThenTheCatalogUrl() { + val movie = sidecar.copy(mediaType = "movie", posterThumbhash = "MOVIE", offlinePosterPath = "/art/poster") + + assertEquals(DownloadTileArtwork("file:///art/poster", "MOVIE"), movie.tileArtwork { true }) + // A file removed behind the app's back falls back to the catalog URL. + assertEquals( + DownloadTileArtwork("https://silo.example/api/v2/artwork/poster", "MOVIE"), + movie.tileArtwork { false }, + ) + } + + @Test + fun episodeTilesUseTheSavedSeriesPosterButNeverTheEpisodeStill() { + val episode = sidecar.copy( + mediaType = "tv", + posterThumbhash = null, + offlinePosterPath = "/art/poster", + offlineSeriesPosterPath = "/art/series_poster", + seriesPosterThumbhash = "SERIES", + ) + + assertEquals(DownloadTileArtwork("file:///art/series_poster", "SERIES"), episode.tileArtwork { true }) + // The saved still exists, but the tile falls back to the catalog series poster. + assertEquals( + DownloadTileArtwork("https://silo.example/api/v2/artwork/poster", "SERIES"), + episode.tileArtwork { it == "/art/poster" }, + ) + // Rows from before mediaType was stored count as episodes by series title. + val legacy = episode.copy(mediaType = "", seriesTitle = "Show", offlineSeriesPosterPath = null) + assertEquals( + DownloadTileArtwork("https://silo.example/api/v2/artwork/poster", "SERIES"), + legacy.tileArtwork { true }, + ) + } + + @Test + fun groupTilesUseAnyMembersSavedSeriesPoster() { + val first = sidecar.copy(mediaType = "tv", offlinePosterPath = "/art/1/poster") + val second = sidecar.copy( + mediaType = "tv", + offlinePosterPath = "/art/2/poster", + offlineSeriesPosterPath = "/art/2/series_poster", + seriesPosterThumbhash = "SERIES", + ) + + assertEquals( + DownloadTileArtwork("file:///art/2/series_poster", "SERIES"), + listOf(first, second).tileArtwork { true }, + ) + // No series poster on disk: the catalog URL, not an episode still. + assertEquals( + DownloadTileArtwork("https://silo.example/api/v2/artwork/poster", "SERIES"), + listOf(first, second).tileArtwork { !it.endsWith("series_poster") }, + ) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt index a41eb6c8ae..01ce74d27b 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -5,12 +5,15 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.defaultRequest +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -36,15 +39,42 @@ class OfflineTrackAssetFetcherTest { {"language": "eng", "format": "sup", "forced": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:4"}, {"language": "ger", "format": "sub", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:1"}, {"language": "spa", "format": "srt", "fetch_url": "https://elsewhere.example/sub.srt"} - ] + ], + "poster_thumbhash": "EPISODE", + "series_poster_thumbhash": "SERIES", + "artwork_urls": { + "poster": "/api/v2/downloads/dl_1/artwork/poster", + "backdrop": "/api/v2/downloads/dl_1/artwork/backdrop", + "series_poster": "/api/v2/downloads/dl_1/artwork/series_poster" + } + } + """.trimIndent() + + /** A movie manifest, or an episode manifest from a server without series posters. */ + private val movieManifest = """ + { + "download_id": "dl_1", + "poster_thumbhash": "MOVIE", + "artwork_urls": {"poster": "/api/v2/downloads/dl_1/artwork/poster"} } """.trimIndent() + private val posterBytes = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x01) + private val seriesPosterBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) + private val requested = mutableListOf() private fun client( manifestStatus: HttpStatusCode = HttpStatusCode.OK, transientManifestFailures: Int = 0, + manifestBody: String = manifest, + artwork: (kind: String) -> Pair = { kind -> + when (kind) { + "poster" -> HttpStatusCode.OK to posterBytes + "series_poster" -> HttpStatusCode.OK to seriesPosterBytes + else -> HttpStatusCode.NotFound to ByteArray(0) + } + }, ) = HttpClient( MockEngine { request -> val path = request.url.encodedPath @@ -52,7 +82,11 @@ class OfflineTrackAssetFetcherTest { when { path.endsWith("/manifest") && requested.count { it.endsWith("/manifest") } <= transientManifestFailures -> respond("", HttpStatusCode.ServiceUnavailable) - path.endsWith("/manifest") -> respond(manifest, manifestStatus) + path.endsWith("/manifest") -> respond(manifestBody, manifestStatus) + path.contains("/artwork/") -> { + val (status, bytes) = artwork(path.substringAfterLast('/')) + respond(bytes, status) + } path.endsWith("/subtitles/embedded:2") -> respond("[Script Info]\nTitle: x\n") // The server failed to read the external sidecar. path.endsWith("/subtitles/external:0") -> respond("", HttpStatusCode.NotFound) @@ -71,7 +105,7 @@ class OfflineTrackAssetFetcherTest { val info = assertNotNull( OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, - ) + ).tracks assertEquals(listOf("eng", "jpn"), info.audioTracks.map { it.language }) assertEquals(1, info.defaultAudioPosition()) @@ -91,13 +125,16 @@ class OfflineTrackAssetFetcherTest { @Test fun deletingTheDownloadRemovesItsSidecars() = runBlocking { val storage = DownloadStorage(tmp.newFolder("filesDir")) - val info = assertNotNull( + val assets = assertNotNull( OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, ) + val artworkDirectory = assertNotNull(storage.offlineArtworkDirectory("srv", "prof", 42)) + assertTrue(artworkDirectory.isDirectory) storage.delete("srv", "prof", 42) - assertTrue(info.subtitles.none { File(it.path).exists() }) + assertTrue(assets.tracks.subtitles.none { File(it.path).exists() }) + assertFalse(artworkDirectory.exists()) } @Test @@ -117,12 +154,151 @@ class OfflineTrackAssetFetcherTest { val info = assertNotNull( OfflineTrackAssetFetcher(client(transientManifestFailures = 2), storage, manifestRetryDelayMs = 0) .fetch("dl_1", "srv", "prof", 42) {}, - ) + ).tracks assertEquals(3, requested.count { it.endsWith("/manifest") }) assertEquals(2, info.audioTracks.size) } + @Test + fun savesThePosterAndSeriesPosterFromTheSameManifest() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + val poster = File(assertNotNull(artwork.posterPath)) + val seriesPoster = File(assertNotNull(artwork.seriesPosterPath)) + assertContentEquals(posterBytes, poster.readBytes()) + assertContentEquals(seriesPosterBytes, seriesPoster.readBytes()) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42) + assertEquals(directory, poster.parentFile) + assertEquals(directory, seriesPoster.parentFile) + assertTrue(directory!!.listFiles()!!.none { it.name.endsWith(".part") }) + assertEquals("EPISODE", artwork.posterThumbhash) + assertEquals("SERIES", artwork.seriesPosterThumbhash) + // One manifest request serves tracks and artwork; the backdrop is not fetched. + assertEquals(1, requested.count { it.endsWith("/manifest") }) + assertEquals( + listOf("/api/v2/downloads/dl_1/artwork/poster", "/api/v2/downloads/dl_1/artwork/series_poster"), + requested.filter { it.contains("/artwork/") }, + ) + } + + @Test + fun aMissingSeriesPosterIsSkipped() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(manifestBody = movieManifest), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + assertNotNull(artwork.posterPath) + assertNull(artwork.seriesPosterPath) + assertEquals("MOVIE", artwork.posterThumbhash) + assertNull(artwork.seriesPosterThumbhash) + assertEquals(listOf("/api/v2/downloads/dl_1/artwork/poster"), requested.filter { it.contains("/artwork/") }) + } + + @Test + fun aFailedArtworkFetchKeepsTheRestOfTheCapture() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val client = client( + artwork = { kind -> + if (kind == "poster") HttpStatusCode.NotFound to ByteArray(0) else HttpStatusCode.OK to seriesPosterBytes + }, + ) + + val assets = assertNotNull(OfflineTrackAssetFetcher(client, storage).fetch("dl_1", "srv", "prof", 42) {}) + + assertNull(assets.artwork.posterPath) + assertNotNull(assets.artwork.seriesPosterPath) + assertEquals(2, assets.tracks.audioTracks.size) + assertEquals(2, assets.tracks.subtitles.size) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42)!! + assertFalse(File(directory, "poster").exists()) + assertFalse(File(directory, "poster.part").exists()) + } + + @Test + fun oversizedArtworkIsDiscarded() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val tooLarge = ByteArray((OfflineTrackAssetFetcher.MAX_ARTWORK_BYTES + 1).toInt()) + val client = client( + manifestBody = movieManifest, + artwork = { HttpStatusCode.OK to tooLarge }, + ) + + val artwork = assertNotNull(OfflineTrackAssetFetcher(client, storage).fetch("dl_1", "srv", "prof", 42) {}).artwork + + assertNull(artwork.posterPath) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42)!! + assertTrue(directory.listFiles().orEmpty().isEmpty()) + } + + @Test + fun foreignArtworkUrlsAreNotFollowed() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val foreign = """ + {"artwork_urls": { + "poster": "https://elsewhere.example/poster.jpg", + "series_poster": "/api/v2/downloads/dl_1/artwork/poster" + }} + """.trimIndent() + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(manifestBody = foreign), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + assertNull(artwork.posterPath) + // A series_poster URL that names a different kind is rejected too. + assertNull(artwork.seriesPosterPath) + assertTrue(requested.none { it.contains("/artwork/") || it.contains("poster.jpg") }) + } + + @Test + fun artworkRedirectsAreNotFollowed() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val client = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + requested += path + when { + path.endsWith("/manifest") -> respond(movieManifest) + path.contains("/artwork/") -> respond( + "", + HttpStatusCode.Found, + headersOf(HttpHeaders.Location, "https://elsewhere.example/poster.jpg"), + ) + else -> respond(posterBytes) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://silo.example/") } + } + + val artwork = assertNotNull(OfflineTrackAssetFetcher(client, storage).fetch("dl_1", "srv", "prof", 42) {}).artwork + + assertNull(artwork.posterPath) + assertTrue(requested.none { it.contains("poster.jpg") }) + } + + @Test + fun aRecaptureReplacesThePreviousArtwork() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {} + + val artwork = assertNotNull( + OfflineTrackAssetFetcher(client(manifestBody = movieManifest), storage).fetch("dl_1", "srv", "prof", 42) {}, + ).artwork + + assertNull(artwork.seriesPosterPath) + val directory = storage.offlineArtworkDirectory("srv", "prof", 42)!! + assertEquals(listOf("poster"), directory.listFiles()!!.map { it.name }) + } + @Test fun onlyVideoDownloadsCaptureTracks() { assertTrue(OfflineTrackAssetFetcher.appliesTo("movie")) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt index 13a724a494..a9ce6879d1 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt @@ -9,6 +9,7 @@ import org.siloserver.silo.common.downloads.DownloadReclaimPlan import org.siloserver.silo.common.downloads.DownloadReclaimPlanner import org.siloserver.silo.common.downloads.DownloadStorage import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory +import org.siloserver.silo.common.downloads.tileArtwork import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.model.download.DownloadMediaType import org.siloserver.silo.model.download.DownloadRecord @@ -27,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -304,6 +306,15 @@ class DownloadsViewModel( // Finish any offline delete interrupted by a crash between the durable // tombstone and the on-device cleanup (idempotent byte/metadata delete). finishPendingDeletions() + // The capture saves artwork after the completed status the records + // collector reloads on, so a later artwork write needs its own + // reload. Subscribed before the initial read below, so the dropped + // first emission describes the state that read also sees. + launch { + metadataStore.savedArtworkChanges().drop(1).collect { + publishSections(repository.records.value, forceReload = true) + } + } // Backfill + initial sidecar read. reloadSidecarMetadata() refreshSubscriptionsInternal() @@ -320,44 +331,51 @@ class DownloadsViewModel( ) } - repository.records.collect { records -> - // Room is the source of truth for what's downloaded locally; the - // tab is built from the Room sidecars (which always carry - // title/poster/mediaType/series), NOT from server records joined - // to a map that can be empty (the old path rendered a bare - // contentId + MOVIES + "missing file" whenever the join missed). - // Live byte-progress/status is overlaid from the in-memory server - // records for in-flight items. Reload sidecars when a record the - // map doesn't know about appears (newly enqueued), or when a - // download finished: the worker writes the completed sidecar - // (status + final file URI) before publishing the record, and - // the local completion is what the row's Ready/Play state reads. - val (sections, bytesUsed) = withContext(Dispatchers.IO) { - val unseenCompletions = records.filter { sidecarMissesCompletion(it) }.map { it.id } - if (unseenCompletions.isNotEmpty() || records.any { it.id !in metadataByRecordId }) { - reloadSidecarMetadata() - // Reload once per disagreement: a row the server holds as - // completed while it downloads again locally must not - // re-read Room on every progress tick. - completionReloadIds = (completionReloadIds + unseenCompletions).filterTo(mutableSetOf()) { - metadataByRecordId[it]?.record?.statusEnum() != DownloadStatus.Completed - } - } - val sects = buildSections(records.associateBy { it.id }) - sects to sects.sumOf { it.totalBytesUsed } - } - _uiState.update { - it.copy( - isLoading = false, - // Preserve any delete/refresh failure — refresh() and - // clearError() manage the error lifecycle explicitly; - // a progress tick must not wipe it before it's shown. - error = it.error, - sections = sections, - totalBytesUsed = bytesUsed, - ) + repository.records.collect { records -> publishSections(records) } + } + } + + /** + * Rebuilds the tab from [records] and the Room sidecars and publishes it. + * [forceReload] re-reads the sidecars even when no record is new or newly + * completed (a later sidecar write, such as saved artwork). + */ + private suspend fun publishSections(records: List, forceReload: Boolean = false) { + // Room is the source of truth for what's downloaded locally; the + // tab is built from the Room sidecars (which always carry + // title/poster/mediaType/series), NOT from server records joined + // to a map that can be empty (the old path rendered a bare + // contentId + MOVIES + "missing file" whenever the join missed). + // Live byte-progress/status is overlaid from the in-memory server + // records for in-flight items. Reload sidecars when a record the + // map doesn't know about appears (newly enqueued), or when a + // download finished: the worker writes the completed sidecar + // (status + final file URI) before publishing the record, and + // the local completion is what the row's Ready/Play state reads. + val (sections, bytesUsed) = withContext(Dispatchers.IO) { + val unseenCompletions = records.filter { sidecarMissesCompletion(it) }.map { it.id } + if (forceReload || unseenCompletions.isNotEmpty() || records.any { it.id !in metadataByRecordId }) { + reloadSidecarMetadata() + // Reload once per disagreement: a row the server holds as + // completed while it downloads again locally must not + // re-read Room on every progress tick. + completionReloadIds = (completionReloadIds + unseenCompletions).filterTo(mutableSetOf()) { + metadataByRecordId[it]?.record?.statusEnum() != DownloadStatus.Completed } } + val sects = buildSections(records.associateBy { it.id }) + sects to sects.sumOf { it.totalBytesUsed } + } + _uiState.update { + it.copy( + isLoading = false, + // Preserve any delete/refresh failure — refresh() and + // clearError() manage the error lifecycle explicitly; + // a progress tick must not wipe it before it's shown. + error = it.error, + sections = sections, + totalBytesUsed = bytesUsed, + ) } } @@ -593,19 +611,22 @@ class DownloadsViewModel( val sidecars = metadataByRecordId.values.toList() val watchedStates = userItemStatePort.localContentStates(sidecars.map { it.record.contentId }) val liveById = repository.records.value.associateBy { it.id } - val rows = sidecars.map { sidecar -> - val live = liveById[sidecar.record.id] - val item = sidecar.toDownloadItem(live) - DownloadReclaimCandidate( - recordId = sidecar.record.id, - contentId = sidecar.record.contentId, - mediaFileId = sidecar.record.mediaFileId, - title = sidecar.title, - status = (live ?: sidecar.record).status, - fileSizeBytes = item.fileSizeBytes, - completed = watchedStates[sidecar.record.contentId]?.watched == true, - updatedAtMs = sidecar.updatedAtMs, - ) + // toDownloadItem stats the media file and saved artwork on disk. + val rows = withContext(Dispatchers.IO) { + sidecars.map { sidecar -> + val live = liveById[sidecar.record.id] + val item = sidecar.toDownloadItem(live) + DownloadReclaimCandidate( + recordId = sidecar.record.id, + contentId = sidecar.record.contentId, + mediaFileId = sidecar.record.mediaFileId, + title = sidecar.title, + status = (live ?: sidecar.record).status, + fileSizeBytes = item.fileSizeBytes, + completed = watchedStates[sidecar.record.contentId]?.watched == true, + updatedAtMs = sidecar.updatedAtMs, + ) + } } // Never plan the file the player is currently using (reachable via // PiP -> Downloads): deleting it yanks the bytes out from under a live @@ -707,13 +728,16 @@ class DownloadsViewModel( // No real bytes on disk: a completed-but-missing row is 0 (don't show a // stale size or inflate the header); an in-flight row uses live bytesSent. ?: if (status == DownloadStatus.Completed) 0L else rec.bytesSent + // Saved artwork is checked on disk here, like the media file above; + // callers build items on Dispatchers.IO. + val artwork = tileArtwork() return DownloadItem( id = record.id, contentId = record.contentId, title = title, subtitle = subtitle, - posterUrl = posterUrl, - posterThumbhash = posterThumbhash, + posterUrl = artwork.url, + posterThumbhash = artwork.thumbhash, fileSizeBytes = shownBytes, progress = displayProgress, isComplete = fileState.isComplete, @@ -787,8 +811,9 @@ class DownloadsViewModel( } val seriesEntries = bySeries.map { (seriesKey, group) -> val seriesTitle = group.firstNotNullOfOrNull { it.seriesTitle?.takeIf { t -> t.isNotBlank() } } ?: "Series" - val poster = group.firstNotNullOfOrNull { it.posterUrl } - val thumb = group.firstNotNullOfOrNull { it.posterThumbhash } + val artwork = group.tileArtwork() + val poster = artwork.url + val thumb = artwork.thumbhash val bySeason = LinkedHashMap>() for (sc in group) bySeason.getOrPut(sc.seasonNumber ?: -1) { mutableListOf() } += sc val seasons = bySeason.toSortedMap().map { (season, seasonScs) -> diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt index 31681b1e23..bffc6aadf1 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt @@ -72,6 +72,15 @@ data class DownloadSidecar( * it, captured from the offline manifest when the download completed. Null * for downloads completed before this existed (legacy offline playback). */ val offlineTracks: OfflineTrackInfo? = null, + /** Absolute path of the item's own poster (an episode's image for episodes), + * saved from the offline manifest when the download completed. Null when + * none was saved, including downloads completed before this existed. */ + val offlinePosterPath: String? = null, + /** Absolute path of the parent series poster saved with an episode download. + * Null for movies and for servers whose manifest does not list it. */ + val offlineSeriesPosterPath: String? = null, + /** ThumbHash of the parent series poster, from the episode's offline manifest. */ + val seriesPosterThumbhash: String? = null, /** Wall-clock millis when the sidecar was last written. Diagnostic only; * helps debug stale-file scenarios via `ls -la`. */ val updatedAtMs: Long, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineArtwork.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineArtwork.kt new file mode 100644 index 0000000000..004225b34c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineArtwork.kt @@ -0,0 +1,68 @@ +package org.siloserver.silo.model.download + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * The artwork part of `GET /api/v2/downloads/{id}/manifest`: the proxy URLs to + * fetch once while online and the inline thumbhashes. + * + * For an episode entry `poster` is the episode's own image and + * `series_poster` is the parent series poster. The `series_poster` fields exist + * only on servers that name the series poster in episode manifests; older + * servers and movie manifests omit them. + */ +@Serializable +data class OfflineManifestArtwork( + @SerialName("poster_thumbhash") val posterThumbhash: String? = null, + @SerialName("series_poster_thumbhash") val seriesPosterThumbhash: String? = null, + @SerialName("artwork_urls") val artworkUrls: OfflineManifestArtworkUrls = OfflineManifestArtworkUrls(), +) + +@Serializable +data class OfflineManifestArtworkUrls( + val poster: String? = null, + @SerialName("series_poster") val seriesPoster: String? = null, +) + +/** Artwork saved beside a download, plus the thumbhashes the manifest listed. */ +data class OfflineArtworkFiles( + /** Absolute path of the saved `poster` image, or null when none was saved. */ + val posterPath: String? = null, + /** Absolute path of the saved `series_poster` image, or null when none was saved. */ + val seriesPosterPath: String? = null, + val posterThumbhash: String? = null, + val seriesPosterThumbhash: String? = null, +) + +private val offlineArtworkJson = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true +} + +/** Decodes the manifest's artwork subset; null for a body that is not a manifest object. */ +fun decodeOfflineManifestArtwork(body: String): OfflineManifestArtwork? = + runCatching { offlineArtworkJson.decodeFromString(OfflineManifestArtwork.serializer(), body) }.getOrNull() + +/** Artwork kinds saved with a download, as named by the manifest and the proxy route. */ +const val OFFLINE_ARTWORK_POSTER = "poster" +const val OFFLINE_ARTWORK_SERIES_POSTER = "series_poster" + +/** + * Only the same-server managed-download artwork proxy path for [kind] + * (`/api/v2/downloads/{id}/artwork/{kind}`) is fetched. The request carries the + * download owner's credentials, so an absolute or foreign reference in a + * manifest must never be followed. + */ +fun isOfflineArtworkFetchUrl(url: String, kind: String): Boolean { + val trimmed = url.trim() + if (!trimmed.startsWith(OFFLINE_ARTWORK_DOWNLOADS_PREFIX)) return false + if (trimmed.contains("://") || trimmed.contains("..") || trimmed.contains('\\')) return false + if (trimmed.contains('?') || trimmed.contains('#')) return false + val segments = trimmed.substring(OFFLINE_ARTWORK_DOWNLOADS_PREFIX.length).split('/') + return segments.size == 3 && segments[0].isNotEmpty() && segments[1] == "artwork" && segments[2] == kind +} + +private const val OFFLINE_ARTWORK_DOWNLOADS_PREFIX = "/api/v2/downloads/" diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineArtworkTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineArtworkTest.kt new file mode 100644 index 0000000000..75d815774e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineArtworkTest.kt @@ -0,0 +1,79 @@ +package org.siloserver.silo.model.download + +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 OfflineArtworkTest { + + @Test + fun decodesAnEpisodeManifestWithASeriesPoster() { + val artwork = assertNotNull( + decodeOfflineManifestArtwork( + """ + { + "download_id": "dl_1", + "audio_tracks": [{"index": 0, "language": "eng"}], + "poster_thumbhash": "EPISODE", + "backdrop_thumbhash": "BACKDROP", + "series_poster_thumbhash": "SERIES", + "artwork_urls": { + "poster": "/api/v2/downloads/dl_1/artwork/poster", + "backdrop": "/api/v2/downloads/dl_1/artwork/backdrop", + "logo": "/api/v2/downloads/dl_1/artwork/logo", + "series_poster": "/api/v2/downloads/dl_1/artwork/series_poster" + } + } + """.trimIndent(), + ), + ) + + assertEquals("EPISODE", artwork.posterThumbhash) + assertEquals("SERIES", artwork.seriesPosterThumbhash) + assertEquals("/api/v2/downloads/dl_1/artwork/poster", artwork.artworkUrls.poster) + assertEquals("/api/v2/downloads/dl_1/artwork/series_poster", artwork.artworkUrls.seriesPoster) + } + + @Test + fun serversWithoutSeriesPostersDecodeWithoutThem() { + val artwork = assertNotNull( + decodeOfflineManifestArtwork( + """{"poster_thumbhash": "MOVIE", "artwork_urls": {"poster": "/api/v2/downloads/dl_1/artwork/poster"}}""", + ), + ) + + assertEquals("MOVIE", artwork.posterThumbhash) + assertNull(artwork.seriesPosterThumbhash) + assertNull(artwork.artworkUrls.seriesPoster) + } + + @Test + fun aManifestWithNoArtworkDecodesEmpty() { + val artwork = assertNotNull(decodeOfflineManifestArtwork("""{"download_id": "dl_1", "artwork_urls": null}""")) + + assertNull(artwork.posterThumbhash) + assertNull(artwork.artworkUrls.poster) + assertNull(decodeOfflineManifestArtwork("not json")) + } + + @Test + fun onlySameServerArtworkProxyPathsForTheKindAreFetched() { + assertTrue(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertTrue(isOfflineArtworkFetchUrl(" /api/v2/downloads/dl_1/artwork/series_poster ", OFFLINE_ARTWORK_SERIES_POSTER)) + + assertFalse(isOfflineArtworkFetchUrl("https://evil.example/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("//evil.example/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/backdrop", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/poster", OFFLINE_ARTWORK_SERIES_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/subtitles/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/../users/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/a/b/artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads//artwork/poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1/artwork/poster?x=1", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("/api/v2/downloads/dl_1\\artwork\\poster", OFFLINE_ARTWORK_POSTER)) + assertFalse(isOfflineArtworkFetchUrl("", OFFLINE_ARTWORK_POSTER)) + } +} From d7d8580f3d38fbb9269365ef06e90ddb03e70e10 Mon Sep 17 00:00:00 2001 From: Suspense Date: Fri, 2 Oct 2026 03:34:46 +0200 Subject: [PATCH 07/87] feat(tv): add Release Date library sort on Android TV (#433) The library sort entry labelled Year was named ReleaseDate and sent year, under a stale note that the server lacked release_date. Rename it to Year (still sends year, so saved sorts restore unchanged) and add a Release Date entry that sends release_date, newest first. Library browse and collection grids list both; favorites and watchlist keep Year only. --- .../ui/screens/library/TvLibraryDetailViewModel.kt | 12 ++++++------ .../library/TvLibrarySubdestinationViewModelTest.kt | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt index 60d8a432f2..c6757c7666 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -80,9 +80,8 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { ListOrder("Recently Saved", "__list_order"), Title("Title", "title"), DateAdded("Date Added", "added_at"), - // Server expects "year" for release-date sort (matches phone); the old - // "release_date" value was unsupported. - ReleaseDate("Year", "year"), + ReleaseDate("Release Date", "release_date"), + Year("Year", "year"), Rating("Rating", "rating_imdb"), Runtime("Runtime", "runtime"), Resolution("Resolution", "resolution"), @@ -101,7 +100,7 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { // No direction to report — the order is whatever the source defines. CollectionOrder, ListOrder -> "Default" Title, Author, Narrator, SeriesName -> if (order == "asc") "A–Z" else "Z–A" - ReleaseDate, DateAdded -> if (order == "asc") "Oldest" else "Newest" + ReleaseDate, Year, DateAdded -> if (order == "asc") "Oldest" else "Newest" Runtime -> if (order == "asc") "Shortest" else "Longest" Rating, Resolution -> if (order == "asc") "Lowest" else "Highest" } @@ -115,7 +114,7 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { if (org.siloserver.silo.model.navigation.isAudiobookLikeLibraryType(libraryType)) { listOf(Title, Author, Narrator, SeriesName, DateAdded, Runtime) } else { - listOf(Title, DateAdded, ReleaseDate, Rating, Runtime, Resolution) + listOf(Title, DateAdded, ReleaseDate, Year, Rating, Runtime, Resolution) } /** @@ -135,7 +134,7 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { * `added_at` by for these sources. */ fun availableForPersonalList(): List = - listOf(ListOrder, Title, DateAdded, ReleaseDate, Rating, Runtime) + listOf(ListOrder, Title, DateAdded, Year, Rating, Runtime) } } @@ -859,6 +858,7 @@ internal val TvLibrarySortOption.defaultOrder: String TvLibrarySortOption.SeriesName -> "asc" TvLibrarySortOption.DateAdded, TvLibrarySortOption.ReleaseDate, + TvLibrarySortOption.Year, TvLibrarySortOption.Rating, TvLibrarySortOption.Runtime, TvLibrarySortOption.Resolution -> "desc" diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt index 8ea3007851..9a7b8b8858 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt @@ -106,7 +106,7 @@ class TvLibrarySubdestinationViewModelTest { viewModel.onTabSelected(TvLibraryTab.Browse) awaitState { requests.catalogRequestCount() >= 1 } viewModel.onSortKeySelected(TvLibrarySortOption.ReleaseDate) - awaitState { requests.lastCatalogRequestOrNull()?.query?.get("sort") == "-year" } + awaitState { requests.lastCatalogRequestOrNull()?.query?.get("sort") == "-release_date" } val requestsBeforeReentry = requests.catalogRequestCount() // Re-entering the screen (back out of item detail) re-issues the @@ -115,7 +115,7 @@ class TvLibrarySubdestinationViewModelTest { viewModel.onTabSelected(TvLibraryTab.Browse) settle() - assertEquals("year", viewModel.uiState.value.browseFilter.sort) + assertEquals("release_date", viewModel.uiState.value.browseFilter.sort) assertEquals("desc", viewModel.uiState.value.browseFilter.order) assertEquals(requestsBeforeReentry, requests.catalogRequestCount()) } From 9d54e3cc385ccedc7f1aca08c4102ca1dcc42cca Mon Sep 17 00:00:00 2001 From: Suspense Date: Fri, 2 Oct 2026 03:35:17 +0200 Subject: [PATCH 08/87] feat(detail): mark a whole season watched on Android phone and TV (#437) * feat(detail): mark a whole season watched on Android phone and TV Phone: long-press a season chip, or use the series overflow menu, to mark the selected season watched or unwatched. TV: the More dialog adds the same entry while a season tab is selected. Both send the season id to the existing watched endpoint, update optimistically, roll back on failure, and re-read the season list, series hero, and visible episodes afterwards. Season and series writes now clear this device's older resume positions for the episodes they reached, and marking a whole series refreshes the episode list and season state on both apps. Closes #435 Closes #436 * fix(detail): read the server fresh after a season or series watched change After a watched write, the season list and episode refreshes now skip the cached fallback and coalesced requests that still hold the pre-write state (CatalogRepository fresh reads). Resume positions are also cleared for a season whose episodes were not loaded yet, and the TV rollback keeps episodes marked on their own while the season write was pending. * fix(detail): undo an episode's failed write in a failed season rollback When both a season write and an overlapping single-episode write fail, the episode's own rollback restores the season's optimistic state. The season rollback skipped that episode as changed since it began, leaving it marked. It now restores episodes whose latest individual write failed. * fix(detail): keep an episode's last successful write in a failed season rollback A failed latest episode write does not mean every episode write since the season write began failed. The season rollback now skips any episode with a successful individual write since then, which the episode's own rollback already left showing. * fix(detail): restore episodes with no newer write in a failed season rollback An episode write admitted before the season write but finishing after it changed the phone's success record, so the season rollback skipped an episode it should restore. Restore whenever no episode write was admitted after the season write began. * fix(detail): clear resume state and respect series writes in season watched changes Marking a season or series on phone now clears the loaded episodes' resume points along with setting played, as TV already did, so Resume does not offer the old position if the follow-up read fails. A season write that fails after a newer series write began re-reads the server instead of restoring its stale snapshot, on both apps. * fix(detail): tighten resume cleanup and overlapping series writes Resume cleanup after a season or series write now checks the write's identity generation, so a server, account, or profile switch in between cannot clear another profile's rows, and a series write also clears seasons that were not loaded. A season rollback treats a newer series write as superseding only if it has not failed, and a successful series write leaves episodes changed on their own since it began. * fix(detail): apply a series write to episodes whose newer write failed A successful series write skipped every episode with an individual write admitted since it began, including writes that failed and rolled back. It now skips only episodes whose newer write did not fail. * test(detail): cover a series write after an episode's failed write * fix(detail): keep an episode's successful write when a series write lands A successful series write now leaves an episode that had a successful individual write since the series write began, even if a later write to that episode failed, matching the season rollback rule. * fix(detail): compare episode successes by admission order on phone A late success from an episode write admitted before a series or season write counted as a newer change on phone. Compare the successful write's admission generation with the one captured when the parent write began, as TV already does with its watermark. --- .../repository/RoomUserItemStateRepository.kt | 17 + .../screens/detail/DetailSharedComponents.kt | 67 ++-- .../ui/screens/detail/ItemDetailScreen.kt | 3 + .../ui/screens/detail/ItemDetailViewModel.kt | 246 ++++++++++++- .../ui/screens/detail/SeriesDetailContent.kt | 34 +- .../screens/detail/MobileDetailActionsTest.kt | 323 +++++++++++++++++- .../tv/ui/screens/detail/TvEpisodeWindow.kt | 7 + .../ui/screens/detail/TvItemDetailScreen.kt | 21 ++ .../screens/detail/TvItemDetailViewModel.kt | 208 ++++++++++- .../detail/TvNextUpSelectionHandoffTest.kt | 87 ++++- .../silo/repository/CatalogRepository.kt | 30 +- .../silo/repository/port/UserItemStatePort.kt | 13 + 12 files changed, 1014 insertions(+), 42 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt index 750bc4b063..66ee60c65e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt @@ -293,6 +293,23 @@ class RoomUserItemStateRepository( .latestProgress() } + override suspend fun clearLocalPlaybackProgressBefore( + contentIds: List, + admittedAtMs: Long, + identityGeneration: Long, + ) { + if (contentIds.isEmpty()) return + val snapshot = snapshotProvider() ?: return + if (snapshot.isIdentityGenerationStamped && snapshot.identityGeneration != identityGeneration) return + val profileId = snapshot.profileId ?: return + db.withTransaction { + val clearedAtMs = now() + contentIds.distinct().forEach { contentId -> + userStateDao.clearPlaybackProgressBefore(snapshot.serverId, profileId, contentId, admittedAtMs, clearedAtMs) + } + } + } + override suspend fun localPlaybackProgressForContent(contentIds: List): Map { if (contentIds.isEmpty()) return emptyMap() val snapshot = snapshotProvider() ?: return emptyMap() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt index 8da1755177..d7145f9c56 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt @@ -2,7 +2,9 @@ package org.siloserver.silo.android.ui.screens.detail import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -37,6 +39,7 @@ import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Surface @@ -1386,12 +1389,15 @@ fun SectionHeader( // ── Season chips ────────────────────────────────────────────── +@OptIn(ExperimentalFoundationApi::class) @Composable fun SeasonChips( seasons: List, selectedSeasonNumber: Int, onSeasonSelected: (Int) -> Unit, modifier: Modifier = Modifier, + /** Long-press menu on a chip; null hides it. */ + onSeasonWatchedChange: ((Season, Boolean) -> Unit)? = null, ) { // The series overview deliberately keeps its single selected chip. iOS // renders "Season 1" even when there is no alternative season because it @@ -1437,27 +1443,50 @@ fun SeasonChips( ) { season -> val isSelected = season.seasonNumber == selectedSeasonNumber val label = phoneSeasonLabel(season) + val isWatched = season.userData?.played == true + var menuExpanded by remember { mutableStateOf(false) } // iOS PhoneSeasonChips: 14pt (semibold selected / medium // unselected), hpad 16, height 36, unselected fill white-0.06. - Surface( - shape = PillShape, - color = if (isSelected) Color.White else Color.White.copy(alpha = 0.06f), - border = if (isSelected) { - null - } else { - androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)) - }, - modifier = Modifier - .height(36.dp) - .clickable { onSeasonSelected(season.seasonNumber) }, - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = label, - fontSize = 14.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, - color = if (isSelected) Color.Black else Color.White, - modifier = Modifier.padding(horizontal = 16.dp), + Box { + Surface( + shape = PillShape, + color = if (isSelected) Color.White else Color.White.copy(alpha = 0.06f), + border = if (isSelected) { + null + } else { + androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)) + }, + modifier = Modifier + .height(36.dp) + .combinedClickable( + onClick = { onSeasonSelected(season.seasonNumber) }, + onLongClick = if (onSeasonWatchedChange != null && season.episodeCount > 0) { + { menuExpanded = true } + } else { + null + }, + ), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = label, + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = if (isSelected) Color.Black else Color.White, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(if (isWatched) "Mark Season Unwatched" else "Mark Season Watched") }, + onClick = { + menuExpanded = false + onSeasonWatchedChange?.invoke(season, !isWatched) + }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index e88e628b77..8b0a5411af 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -725,6 +725,9 @@ fun ItemDetailScreen( viewModel.setEpisodeWatched(episodeContentId, watched) }, onSeasonSelected = { viewModel.selectSeason(it) }, + onSeasonWatchedChange = { season, watched -> + viewModel.setSeasonWatched(season, watched) + }, onFavoriteClick = { viewModel.toggleFavorite() }, onWatchlistClick = { viewModel.toggleWatchlist() }, onToggleWatched = { viewModel.toggleWatched() }, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt index 52df271da6..a0bb7dc908 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt @@ -11,6 +11,7 @@ import org.siloserver.silo.model.catalog.FileVersion import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.LeafItemUserData import org.siloserver.silo.model.catalog.Season +import org.siloserver.silo.model.catalog.SeasonUserData import org.siloserver.silo.model.catalog.initialSeasonDisplayPlan import org.siloserver.silo.model.catalog.sortedForDisplay import org.siloserver.silo.model.download.DownloadCapability @@ -188,6 +189,11 @@ class ItemDetailViewModel( private var watchedMutationGeneration = 0 private val episodeWatchedMutationGenerations = mutableMapOf() + private val seasonWatchedMutationGenerations = mutableMapOf() + private var seasonsRefreshGeneration = 0 + private val failedEpisodeWatchedGenerations = mutableMapOf() + private val succeededEpisodeWatchedGenerations = mutableMapOf() + private var failedSeriesWatchedGeneration = -1 private val descriptionTranslation = DescriptionTranslationController( repository = metadataAiRepository, @@ -448,7 +454,14 @@ class ItemDetailViewModel( * Deliberately NOT [loadDetail] — no loading flashes, and the user's * season selection is preserved. */ - fun refreshOnReturn() { + fun refreshOnReturn() = refreshOnReturn(afterWatchedChange = false) + + /** + * [afterWatchedChange] reads the season list and episodes fresh from the + * server: a coalesced request or cached fallback could still hold the + * state from before the write. + */ + private fun refreshOnReturn(afterWatchedChange: Boolean) { val current = _uiState.value.detail ?: return viewModelScope.launch { // Local overlay first: the player's final position write is already @@ -473,10 +486,12 @@ class ItemDetailViewModel( } } if (current.type == "series") { + refreshSeasonsQuietly(current.contentId, fresh = afterWatchedChange) loadEpisodes( current.contentId, _uiState.value.selectedSeasonNumber, forceRefresh = true, + fresh = afterWatchedChange, ) } else if (current.type == "episode") { current.seriesId?.let { @@ -792,6 +807,7 @@ class ItemDetailViewModel( seasonsForDownloadRollup: List? = null, forceRefresh: Boolean = false, preferPrefetched: Boolean = false, + fresh: Boolean = false, ) { episodeLoadJob?.cancel() val cachedEpisodes = _uiState.value.episodesBySeason[seasonNumber] @@ -829,7 +845,7 @@ class ItemDetailViewModel( val result = if (!forceRefresh && preferPrefetched) { catalogRepository.getEpisodesForPrefetch(seriesId, seasonNumber, libraryId = libraryId) } else { - catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId) + catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId, fresh = fresh) } when (result) { is ApiResult.Success -> { @@ -1142,14 +1158,227 @@ class ItemDetailViewModel( val current = currentDetail.userData?.played == true val target = !current val generation = ++watchedMutationGeneration + val isSeries = currentDetail.type == "series" + if (isSeries) seasonsRefreshGeneration++ + val episodeGenerationsAtStart = episodeWatchedMutationGenerations.toMap() + val admittedAtMs = System.currentTimeMillis() updatePlayedState(target) val writeIntent = personalDataRepository.beginWatched(contentId, target) viewModelScope.launch { val writeResult = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch when (writeResult) { - is ApiResult.Success -> { /* already updated */ } - else -> if (generation == watchedMutationGeneration) updatePlayedState(current) + // The server applied a series change to every episode; re-read + // the seasons and episodes so their checkmarks follow. + is ApiResult.Success -> if (isSeries) { + // Episodes written on their own since this began keep that state: + // any successful write since, or a newer write that has not failed. + val changedSince = episodeWatchedMutationGenerations + .filter { (id, generation) -> + (succeededEpisodeWatchedGenerations[id] ?: 0) > (episodeGenerationsAtStart[id] ?: 0) || + (episodeGenerationsAtStart[id] != generation && failedEpisodeWatchedGenerations[id] != generation) + } + .keys + updateSeasonPlayedState(seasonNumber = null, played = target, skipEpisodeIds = changedSince) + val loaded = _uiState.value.episodesBySeason + userItemState.clearLocalPlaybackProgressBefore( + loaded.values.flatten().map { it.contentId }, + admittedAtMs, + writeIntent.identityGeneration, + ) + refreshAfterSeasonWatchedChange(currentDetail.contentId, changedSeasonNumber = null) + // Seasons not loaded yet are read after the visible refresh starts. + val unloaded = _uiState.value.seasons.map { it.seasonNumber }.filter { it !in loaded } + userItemState.clearLocalPlaybackProgressBefore( + unloaded.flatMap { seasonEpisodeIds(currentDetail.contentId, it) }, + admittedAtMs, + writeIntent.identityGeneration, + ) + } + else -> if (generation == watchedMutationGeneration) { + if (isSeries) failedSeriesWatchedGeneration = generation + updatePlayedState(current) + } + } + } + } + + /** + * Marks every episode of [season] from its chip or the series overflow + * menu. The server applies the change to the season's episodes. + */ + fun setSeasonWatched(season: Season, watched: Boolean) { + val state = _uiState.value + val detail = state.detail ?: return + if (detail.type != "series") return + val seriesId = detail.contentId + val seasonNumber = season.seasonNumber + val previousSeason = state.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val previousEpisodes = state.episodesBySeason[seasonNumber] + val episodeGenerationsAtStart = episodeWatchedMutationGenerations.toMap() + val admittedAtMs = System.currentTimeMillis() + + val seriesGenerationAtStart = watchedMutationGeneration + val generation = (seasonWatchedMutationGenerations[seasonNumber] ?: 0) + 1 + seasonWatchedMutationGenerations[seasonNumber] = generation + // A season list read already in flight predates this change. + seasonsRefreshGeneration++ + updateSeasonPlayedState(seasonNumber, watched) + val writeIntent = personalDataRepository.beginWatched(season.contentId, watched) + viewModelScope.launch { + val writeResult = personalDataRepository.performPersonalWrite(writeIntent) + if (!personalDataRepository.isCurrent(writeIntent)) return@launch + if (seasonWatchedMutationGenerations[seasonNumber] != generation) return@launch + when (writeResult) { + is ApiResult.Success -> { + // Both lists in case the cached page changed while the write was pending. + val episodeIds = (_uiState.value.episodesBySeason[seasonNumber].orEmpty() + previousEpisodes.orEmpty()) + .map { it.contentId } + .distinct() + .ifEmpty { seasonEpisodeIds(seriesId, seasonNumber) } + userItemState.clearLocalPlaybackProgressBefore(episodeIds, admittedAtMs, writeIntent.identityGeneration) + refreshAfterSeasonWatchedChange(seriesId, seasonNumber) + } + // A series write since this one began (and not failed) also + // covers this season; the snapshot is stale, so re-read the server. + else -> if (watchedMutationGeneration != seriesGenerationAtStart && + failedSeriesWatchedGeneration != watchedMutationGeneration + ) { + refreshAfterSeasonWatchedChange(seriesId, changedSeasonNumber = null) + } else { + restoreSeasonPlayedState( + previousSeason, + previousEpisodes, + episodeGenerationsAtStart, + ) + } + } + } + } + + /** + * Applies a season ([seasonNumber]) or whole-series (null) watched change to + * the loaded seasons and episodes. Like the server, both marking and + * unmarking clear an episode's resume point, so a failed follow-up read + * cannot leave Resume offering the old position. + */ + private fun updateSeasonPlayedState( + seasonNumber: Int?, + played: Boolean, + skipEpisodeIds: Set = emptySet(), + ) { + fun EpisodeListItem.updated(): EpisodeListItem = if (contentId in skipEpisodeIds) this else copy( + userData = (userData ?: LeafItemUserData()).copy( + played = played, + isInProgress = false, + positionSeconds = null, + ), + ) + fun matches(number: Int) = seasonNumber == null || number == seasonNumber + _uiState.update { state -> + state.copy( + seasons = state.seasons.map { season -> + if (!matches(season.seasonNumber)) season else season.copy( + userData = (season.userData ?: SeasonUserData()).copy(played = played), + ) + }, + episodes = if (matches(state.selectedSeasonNumber)) { + state.episodes.map { it.updated() } + } else { + state.episodes + }, + episodesBySeason = state.episodesBySeason.mapValues { (number, episodes) -> + if (matches(number)) episodes.map { it.updated() } else episodes + }, + ) + } + } + + private fun restoreSeasonPlayedState( + previousSeason: Season, + previousEpisodes: List?, + episodeGenerationsAtStart: Map, + ) { + val seasonNumber = previousSeason.seasonNumber + // An episode marked on its own while the season write was pending keeps + // that newer state; only the season write's own change is undone. + val previousById = previousEpisodes.orEmpty() + .filter { + val latest = episodeWatchedMutationGenerations[it.contentId] + // No episode write admitted since the season write began: restore. + // Otherwise restore only if none of those writes succeeded and the + // latest failed, having rolled back to this season's optimistic state. + val atStart = episodeGenerationsAtStart[it.contentId] ?: 0 + latest == episodeGenerationsAtStart[it.contentId] || + ((succeededEpisodeWatchedGenerations[it.contentId] ?: 0) <= atStart && + failedEpisodeWatchedGenerations[it.contentId] == latest) + } + .associateBy { it.contentId } + fun List.restored() = map { previousById[it.contentId] ?: it } + _uiState.update { state -> + val episodesBySeason = if (previousEpisodes != null) { + state.episodesBySeason.mapValues { (number, episodes) -> + if (number == seasonNumber) episodes.restored() else episodes + } + } else { + state.episodesBySeason - seasonNumber + } + state.copy( + seasons = state.seasons.map { if (it.seasonNumber == seasonNumber) previousSeason else it }, + episodes = if (state.selectedSeasonNumber == seasonNumber) state.episodes.restored() else state.episodes, + episodesBySeason = episodesBySeason, + ) + } + } + + /** Episode ids of a season whose page is not loaded; empty if the read fails. */ + private suspend fun seasonEpisodeIds(seriesId: String, seasonNumber: Int): List = + when (val result = catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId, fresh = true)) { + is ApiResult.Success -> result.data.episodes.map { it.contentId } + is ApiResult.Error, + is ApiResult.NetworkError -> emptyList() + } + + /** + * Re-reads what a season or series watched change affected: the season + * list (season watched state), the series hero, and the visible season's + * episodes. Other seasons' cached episodes are dropped when they may be + * stale so selecting them later loads fresh checkmarks. + */ + private fun refreshAfterSeasonWatchedChange(seriesId: String, changedSeasonNumber: Int?) { + _uiState.update { state -> + state.copy( + episodesBySeason = state.episodesBySeason.filterKeys { number -> + // The loaded page is the fallback if the selected season's reload fails. + number == state.selectedSeasonNumber || number == loadedSeasonNumber || + (changedSeasonNumber != null && number != changedSeasonNumber) + }, + ) + } + // Re-reads the series hero, the season list, and the visible episodes. + refreshOnReturn(afterWatchedChange = true) + } + + /** + * Replaces the season list (and its watched state) without touching the + * selection. Only the latest request publishes, and a season watched + * change bumps the generation so an older read cannot undo it. + */ + private fun refreshSeasonsQuietly(seriesId: String, fresh: Boolean = false) { + val generation = ++seasonsRefreshGeneration + viewModelScope.launch { + when (val result = catalogRepository.getSeasons(seriesId, libraryId = libraryId, fresh = fresh)) { + is ApiResult.Success -> { + val seasons = result.data.seasons.sortedForDisplay() + _uiState.update { state -> + if (generation != seasonsRefreshGeneration || state.detail?.contentId != seriesId || + seasons.isEmpty() + ) state else state.copy(seasons = seasons) + } + } + // Quiet refresh: on failure keep the season state on screen. + is ApiResult.Error, + is ApiResult.NetworkError -> Unit } } } @@ -1174,8 +1403,15 @@ class ItemDetailViewModel( val writeResult = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch when (writeResult) { - is ApiResult.Success -> Unit + // The season's watched state may have flipped with this episode. + is ApiResult.Success -> { + succeededEpisodeWatchedGenerations[episodeContentId] = generation + _uiState.value.detail + ?.takeIf { it.type == "series" } + ?.let { refreshSeasonsQuietly(it.contentId) } + } else -> if (episodeWatchedMutationGenerations[episodeContentId] == generation) { + failedEpisodeWatchedGenerations[episodeContentId] = generation updateEpisodePlayedState(episodeContentId, previous) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt index cd59cdac52..158d6785a7 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.AudioFile +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Circle import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -72,6 +74,7 @@ fun SeriesDetailContent( onEpisodeDetailClick: (String) -> Unit, onEpisodeWatchedChange: (String, Boolean) -> Unit, onSeasonSelected: (Int) -> Unit, + onSeasonWatchedChange: ((Season, Boolean) -> Unit)? = null, onFavoriteClick: () -> Unit, onWatchlistClick: () -> Unit, onToggleWatched: () -> Unit, @@ -113,6 +116,9 @@ fun SeriesDetailContent( // replacing it with a skeleton (and repainting different names) on every // horizontal episode selection. val fixedSeriesCredit = remember(detail.contentId, detail.cast) { seriesStarringCredit(detail) } + val markableSelectedSeason = selectedSeason?.takeIf { + onSeasonWatchedChange != null && it.episodeCount > 0 + } val episodeCountSubtitle = selectedSeason?.episodeCount?.takeIf { it > 0 }?.let { count -> "$count episode${if (count == 1) "" else "s"}" } @@ -169,6 +175,7 @@ fun SeriesDetailContent( seasons = seasons, selectedSeasonNumber = selectedSeasonNumber, onSeasonSelected = onSeasonSelected, + onSeasonWatchedChange = onSeasonWatchedChange, ) } if (showsEpisodeDetails) { @@ -281,9 +288,34 @@ fun SeriesDetailContent( onToggleWatchlist = onWatchlistClick, onToggleWatched = onToggleWatched, overflow = if ( - onWatchTogether != null || onSuggestToRoom != null + markableSelectedSeason != null || onWatchTogether != null || onSuggestToRoom != null ) { { dismiss -> + if (markableSelectedSeason != null && onSeasonWatchedChange != null) { + val seasonWatched = markableSelectedSeason.userData?.played == true + DropdownMenuItem( + text = { + Text( + "Mark ${phoneSeasonLabel(markableSelectedSeason)} " + + if (seasonWatched) "Unwatched" else "Watched", + ) + }, + leadingIcon = { + Icon( + if (seasonWatched) { + Icons.Outlined.Circle + } else { + Icons.Outlined.CheckCircle + }, + contentDescription = null, + ) + }, + onClick = { + dismiss() + onSeasonWatchedChange(markableSelectedSeason, !seasonWatched) + }, + ) + } if (onSuggestToRoom != null) { DropdownMenuItem( text = { Text("Suggest to Watch Together") }, diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt index f9cff258b6..85bd88318e 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt @@ -167,6 +167,301 @@ class MobileDetailActionsTest { assertEquals(true, viewModel.uiState.value.episodes.single().userData?.played) } + @Test + fun markingSeasonWatchedWritesTheSeasonAndRefreshesWhatItChanged() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val catalogRequests = mutableListOf() + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(catalogRequests)) + viewModel.seedSeriesDetail() + // uiState is a derived flow; let it observe the seeded state. + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[1], true) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(listOf("/api/v2/watched/season-2"), repository.watchedPaths) + assertEquals(listOf(true), repository.watchedCalls) + assertEquals(true, state.seasons[1].userData?.played) + assertFalse(state.seasons[0].userData?.played == true) + assertFalse(state.episodes.single().userData?.played == true) + assertTrue("/api/v2/catalog/series/series-1/seasons" in catalogRequests) + assertTrue("/api/v2/catalog/series/series-1/seasons/1/episodes" in catalogRequests) + // The changed season reloads when it is next selected; the visible one is kept. + assertEquals(setOf(1), state.episodesBySeason.keys) + } + + @Test + fun markingVisibleSeasonWatchedChecksItsEpisodes() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail() + // uiState is a derived flow; let it observe the seeded state. + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(listOf("/api/v2/watched/season-1"), repository.watchedPaths) + assertEquals(true, state.episodes.single().userData?.played) + assertEquals(true, state.episodesBySeason.getValue(1).single().userData?.played) + // Season 2 was not changed, so its cached episodes stay. + assertFalse(state.episodesBySeason.getValue(2).single().userData?.played == true) + } + + @Test + fun failedSeasonWatchedRestoresTheSeasonAndItsEpisodes() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Error(500, "failed", "season failed") }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val catalogRequests = mutableListOf() + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(catalogRequests)) + viewModel.seedSeriesDetail() + // uiState is a derived flow; let it observe the seeded state. + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(listOf("/api/v2/watched/season-1"), repository.watchedPaths) + assertFalse(state.seasons[0].userData?.played == true) + assertFalse(state.episodes.single().userData?.played == true) + assertFalse(state.episodesBySeason.getValue(1).single().userData?.played == true) + assertEquals(emptyList(), catalogRequests) + } + + @Test + fun failedSeasonWatchedKeepsEpisodesMarkedWhileItWasPending() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Error(500, "failed", "season failed") + }, + { ApiResult.Success(Unit) }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail( + seasonOneEpisodes = listOf( + EpisodeListItem(contentId = "s1e1", seasonNumber = 1, episodeNumber = 1), + EpisodeListItem( + contentId = "s1e2", + seasonNumber = 1, + episodeNumber = 2, + userData = LeafItemUserData(played = true), + ), + ), + ) + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + runCurrent() + viewModel.setEpisodeWatched("s1e2", false) + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/season-1", "/api/v2/watched/s1e2"), repository.watchedPaths) + val state = viewModel.uiState.value + assertFalse(state.seasons[0].userData?.played == true) + val played = state.episodes.associate { it.contentId to (it.userData?.played == true) } + // E1 reverts with the failed season write; E2 keeps its own successful change. + assertEquals(mapOf("s1e1" to false, "s1e2" to false), played) + } + + @Test + fun failedSeasonAndEpisodeWritesBothRollBack() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Error(500, "failed", "season failed") + }, + { ApiResult.Error(500, "failed", "episode failed") }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail() + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + runCurrent() + viewModel.setEpisodeWatched("season-1-episode-1", false) + advanceUntilIdle() + + // The episode's failed write rolls back to the season's optimistic state; + // the season rollback must then still undo that. + assertFalse(viewModel.uiState.value.episodes.single().userData?.played == true) + assertFalse(viewModel.uiState.value.seasons[0].userData?.played == true) + } + + @Test + fun markingSeasonWatchedClearsTheVisibleResumePoint() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + // Every catalog read fails, so the post-write refresh cannot correct the list. + val viewModel = itemDetailViewModel(repository, recordingCatalogRepository(mutableListOf())) + viewModel.seedSeriesDetail( + seasonOneEpisodes = listOf( + EpisodeListItem( + contentId = "s1e1", + seasonNumber = 1, + episodeNumber = 1, + userData = LeafItemUserData(isInProgress = true, positionSeconds = 120.0, durationSeconds = 1000.0), + ), + ), + ) + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + advanceUntilIdle() + + val episode = viewModel.uiState.value.episodes.single() + assertEquals(true, episode.userData?.played) + assertEquals(null, episode.userData?.positionSeconds) + assertFalse(episode.userData?.isInProgress == true) + } + + @Test + fun failedSeasonWriteDoesNotUndoANewerSeriesWrite() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Error(500, "failed", "season failed") + }, + { ApiResult.Success(Unit) }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(mutableListOf()), + contentId = "series-1", + ) + viewModel.seedSeriesDetail() + runCurrent() + + viewModel.setSeasonWatched(viewModel.uiState.value.seasons[0], true) + runCurrent() + viewModel.toggleWatched() + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/season-1", "/api/v2/watched/series-1"), repository.watchedPaths) + val state = viewModel.uiState.value + assertEquals(true, state.detail?.userData?.played) + assertEquals(true, state.seasons[0].userData?.played) + assertEquals(true, state.episodes.single().userData?.played) + } + + @Test + fun seriesWriteAppliesToAnEpisodeWhoseNewerWriteFailed() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Success(Unit) + }, + { ApiResult.Error(500, "failed", "episode failed") }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + // Every catalog read fails, so only the series callback can update the episode. + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(mutableListOf()), + contentId = "series-1", + ) + viewModel.seedSeriesDetail() + runCurrent() + + viewModel.toggleWatched() + runCurrent() + viewModel.setEpisodeWatched("season-1-episode-1", true) + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/series-1", "/api/v2/watched/season-1-episode-1"), repository.watchedPaths) + assertEquals(true, viewModel.uiState.value.episodes.single().userData?.played) + } + + @Test + fun seriesWriteKeepsAnEpisodeWrittenSuccessfullyWhileItWasPending() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf( + { + delay(100) + ApiResult.Success(Unit) + }, + { ApiResult.Success(Unit) }, + { ApiResult.Error(500, "failed", "episode failed") }, + ), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(mutableListOf()), + contentId = "series-1", + ) + viewModel.seedSeriesDetail( + seasonOneEpisodes = listOf( + EpisodeListItem( + contentId = "s1e1", + seasonNumber = 1, + episodeNumber = 1, + userData = LeafItemUserData(played = true), + ), + ), + ) + runCurrent() + + viewModel.toggleWatched() + runCurrent() + viewModel.setEpisodeWatched("s1e1", false) + runCurrent() + viewModel.setEpisodeWatched("s1e1", true) + advanceUntilIdle() + + // The successful unmark stands; the failed re-mark and the later series + // response do not overwrite it. + assertFalse(viewModel.uiState.value.episodes.single().userData?.played == true) + } + + @Test + fun markingSeriesWatchedRefreshesSeasonsAndVisibleEpisodes() = runItemDetailTest { + val repository = RecordingPersonalDataRepository( + mutableListOf({ ApiResult.Success(Unit) }), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) + val catalogRequests = mutableListOf() + val viewModel = itemDetailViewModel( + repository, + recordingCatalogRepository(catalogRequests), + contentId = "series-1", + ) + viewModel.seedSeriesDetail() + + viewModel.toggleWatched() + advanceUntilIdle() + + assertEquals(listOf("/api/v2/watched/series-1"), repository.watchedPaths) + assertEquals(true, viewModel.uiState.value.detail?.userData?.played) + assertTrue("/api/v2/catalog/series/series-1/seasons" in catalogRequests) + assertTrue("/api/v2/catalog/series/series-1/seasons/1/episodes" in catalogRequests) + assertEquals(setOf(1), viewModel.uiState.value.episodesBySeason.keys) + } + @Test fun selectingSeriesEpisodeImmediatelyResetsItsPlaybackOverrides() = runItemDetailTest { val viewModel = itemDetailViewModel( @@ -212,6 +507,18 @@ class MobileDetailActionsTest { } } + /** Records catalog paths; every read fails to decode, so state stays as seeded. */ + private fun kotlinx.coroutines.test.TestScope.recordingCatalogRepository( + requests: MutableList, + ): CatalogRepository = CatalogRepository( + CatalogApi( + dummyHttpClient(StandardTestDispatcher(testScheduler)) { request -> + requests += request.url.encodedPath + respond("{}") + }, + ), + ) + /** A catalog client that never answers, so a selection's loading flag stays observable. */ private fun kotlinx.coroutines.test.TestScope.pendingCatalogRepository(): CatalogRepository = CatalogRepository( CatalogApi(dummyHttpClient(StandardTestDispatcher(testScheduler)) { CompletableDeferred().await(); respond("{}") }), @@ -238,17 +545,18 @@ class MobileDetailActionsTest { } @Suppress("UNCHECKED_CAST") - private fun ItemDetailViewModel.seedSeriesDetail() { - val field = ItemDetailViewModel::class.java.getDeclaredField("_uiState") - field.isAccessible = true - val flow = field.get(this) as MutableStateFlow - val seasonOneEpisodes = listOf( + private fun ItemDetailViewModel.seedSeriesDetail( + seasonOneEpisodes: List = listOf( EpisodeListItem( contentId = "season-1-episode-1", seasonNumber = 1, episodeNumber = 1, ), - ) + ), + ) { + val field = ItemDetailViewModel::class.java.getDeclaredField("_uiState") + field.isAccessible = true + val flow = field.get(this) as MutableStateFlow val seasonTwoEpisodes = listOf( EpisodeListItem( contentId = "season-2-episode-1", @@ -328,6 +636,7 @@ class MobileDetailActionsTest { private val scope: AuthScopeSnapshot, private val engineDispatcher: kotlinx.coroutines.CoroutineDispatcher?, val watchedCalls: MutableList, + val watchedPaths: MutableList, ) : PersonalDataRepository( personalDataApi = PersonalDataApi( HttpClient( @@ -339,6 +648,7 @@ class MobileDetailActionsTest { return@addHandler respond("", HttpStatusCode.NotFound) } watchedCalls += request.method == HttpMethod.Post + watchedPaths += request.url.encodedPath when (val outcome = responses.removeFirstOrNull()?.invoke() ?: ApiResult.Success(Unit)) { is ApiResult.Success -> respond("", HttpStatusCode.NoContent) is ApiResult.Error -> respond( @@ -368,6 +678,7 @@ class MobileDetailActionsTest { AuthScopeSnapshot("s1", "p1", "https://silo.example", "pt", identityGeneration = 1), engineDispatcher, mutableListOf(), + mutableListOf(), ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvEpisodeWindow.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvEpisodeWindow.kt index 9c2f3e5d16..3aeac8bdcb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvEpisodeWindow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvEpisodeWindow.kt @@ -14,6 +14,13 @@ internal class TvEpisodeWindow { pages[season] = episodes.sortedBy { it.episodeNumber } } + /** Rewrites cached pages in place: one season, or every season when [season] is null. */ + fun mapEpisodes(season: Int?, transform: (EpisodeListItem) -> EpisodeListItem) { + pages.replaceAll { number, episodes -> + if (season == null || number == season) episodes.map(transform) else episodes + } + } + fun orderedSeasons(seasons: List): List = seasons.sortedWith(compareBy { it.isSpecialsForDisplay() }.thenBy { it.seasonNumber }) .map { it.seasonNumber }.distinct() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index 67f78db0d1..92b1782a76 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -844,6 +844,11 @@ private fun TvDetailContent( onSeriesClick = onSeriesClick, onSeasonClick = onSeasonClick, onWatchTogether = onWatchTogether, + // Season mode marks the selected season; + // Show mode offers only the whole series. + watchedSeason = state.seasons + .firstOrNull { it.seasonNumber == state.selectedSeason } + ?.takeIf { isSeriesDetail && !isShowingSeriesOverview && it.episodeCount > 0 }, ) }, ) @@ -1490,6 +1495,7 @@ private fun HeroActionRow( onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, onWatchTogether: (RoomSnapshot) -> Unit, + watchedSeason: org.siloserver.silo.model.catalog.Season? = null, ) { val suggestViewModel: TvSuggestToRoomViewModel = koinViewModel() val activeRoom by suggestViewModel.room.collectAsState() @@ -1777,6 +1783,21 @@ private fun HeroActionRow( }, ), ) + watchedSeason?.let { season -> + val seasonWatched = season.userData?.played == true + add( + TvDialogOption( + key = "season-watched", + title = "Mark ${tvSeasonPickerLabel(season)} " + + if (seasonWatched) "Unwatched" else "Watched", + selected = seasonWatched, + onClick = { + moreOpen = false + viewModel.onSetSeasonWatched(season, !seasonWatched) + }, + ), + ) + } if (canSuggestToRoom) { add( TvDialogOption( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt index 1910d241d4..fa577c66b6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -25,8 +25,10 @@ import org.siloserver.silo.model.catalog.FileVersion import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.LeafItemUserData import org.siloserver.silo.model.catalog.Season +import org.siloserver.silo.model.catalog.SeasonUserData import org.siloserver.silo.model.catalog.isAudiobookItemType import org.siloserver.silo.model.catalog.initialSeasonDisplayPlan +import org.siloserver.silo.model.catalog.sortedForDisplay import org.siloserver.silo.model.playback.combinedSubtitleSelectionIndexes import org.siloserver.silo.model.playback.ClientCodecCapabilities import org.siloserver.silo.model.playback.buildPlaybackSubtitleChoices @@ -685,7 +687,14 @@ class TvItemDetailViewModel( * ended. Deliberately NOT [loadAll] — no loading flashes, and the user's * season selection is preserved. */ - fun refreshOnReturn() { + fun refreshOnReturn() = refreshOnReturn(afterWatchedChange = false) + + /** + * [afterWatchedChange] reads the season list and episodes fresh from the + * server and skips repainting the rail from the on-disk catalog cache: + * the cache, and any coalesced request, still hold the pre-write state. + */ + private fun refreshOnReturn(afterWatchedChange: Boolean) { val current = _uiState.value.detail ?: return val playbackReturn = TvDetailTrackSelectionSession.consumePlaybackReturn(contentId) playbackReturn?.let { saved -> @@ -743,6 +752,9 @@ class TvItemDetailViewModel( // Null: too far behind to be given a delta, so re-check the lot. val favoritesToRecheck = TvFavoriteRevalidationSession.changedSince(favoritesRevalidatedThrough) + if (current.type.lowercase() == "series" && seriesId != null) { + refreshSeasonsQuietly(seriesId, fresh = afterWatchedChange) + } if (seriesId != null && season != null) { loadEpisodes( seriesId, @@ -750,6 +762,7 @@ class TvItemDetailViewModel( quiet = true, revalidateFavorites = favoritesToRecheck, favoritesVersion = favoritesVersion, + freshRead = afterWatchedChange, ) } } @@ -786,6 +799,14 @@ class TvItemDetailViewModel( if (current.isTogglingWatched) return val target = !current.isWatched val previousDetail = current.detail + val admittedAtMs = System.currentTimeMillis() + val seriesGeneration = if (previousDetail?.type?.lowercase() == "series") { + seasonsRefreshGeneration++ + ++seriesWatchMutationGeneration + } else { + null + } + val episodeMutationWatermark = nextEpisodeWatchMutationGeneration _uiState.update { it.copy( isTogglingWatched = true, @@ -800,6 +821,9 @@ class TvItemDetailViewModel( val result = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch if (result !is ApiResult.Success) { + if (seriesGeneration != null && seriesGeneration == seriesWatchMutationGeneration) { + failedSeriesWatchGeneration = seriesGeneration + } // Roll back on error. _uiState.update { it.copy( @@ -810,9 +834,45 @@ class TvItemDetailViewModel( } } else { _uiState.update { it.copy(isTogglingWatched = false) } + val isSeries = previousDetail?.type?.lowercase() == "series" + if (isSeries) { + // The server applied the change to every episode, so + // cached neighbour seasons in the carousel follow it. + // Episodes written on their own since this began keep that + // state: any successful write since, or a newer write that + // has not failed. + fun EpisodeListItem.applied(): EpisodeListItem { + val latest = lastEpisodeWatchMutation[contentId] ?: 0L + val keep = (succeededEpisodeWatchMutation[contentId] ?: 0L) > episodeMutationWatermark || + (latest > episodeMutationWatermark && failedEpisodeWatchMutation[contentId] != latest) + return if (keep) this else withWatchedPlaybackState(target) + } + episodeWindow.mapEpisodes(null) { it.applied() } + _uiState.update { state -> + state.copy(episodes = state.episodes.map { it.applied() }) + } + publishCarousel() + userItemState.clearLocalPlaybackProgressBefore( + (_uiState.value.carouselEpisodes + _uiState.value.episodes).map { it.contentId }, + admittedAtMs, + writeIntent.identityGeneration, + ) + } // Re-read server-resolved state (including series/season episode // resolution) without flashing the full detail loading screen. - refreshOnReturn() + refreshOnReturn(afterWatchedChange = isSeries) + if (isSeries && previousDetail != null) { + // Seasons outside the carousel are read after the visible refresh starts. + val loaded = (_uiState.value.carouselEpisodes + _uiState.value.episodes) + .map { it.seasonNumber } + .toSet() + val unloaded = _uiState.value.seasons.map { it.seasonNumber }.filter { it !in loaded } + userItemState.clearLocalPlaybackProgressBefore( + unloaded.flatMap { seasonEpisodeIds(previousDetail.contentId, it) }, + admittedAtMs, + writeIntent.identityGeneration, + ) + } } } finally { if (watchedMutationOwner == writeIntent) { @@ -823,6 +883,121 @@ class TvItemDetailViewModel( } } + /** + * Marks every episode of [season] from the series page's More dialog. The + * server applies the change to the season's episodes. + */ + fun onSetSeasonWatched(season: Season, watched: Boolean) { + val current = _uiState.value + val detail = current.detail?.takeIf { it.type.lowercase() == "series" } ?: return + val seasonNumber = season.seasonNumber + val previousSeason = current.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val previousPage = episodeWindow.get(seasonNumber) + val previousEpisodes = current.episodes.takeIf { current.selectedSeason == seasonNumber } + val admittedAtMs = System.currentTimeMillis() + val episodeMutationWatermark = nextEpisodeWatchMutationGeneration + val seriesGenerationAtStart = seriesWatchMutationGeneration + val generation = (seasonWatchMutationGenerations[seasonNumber] ?: 0L) + 1 + seasonWatchMutationGenerations[seasonNumber] = generation + // A season list read already in flight predates this change. + seasonsRefreshGeneration++ + + episodeWindow.mapEpisodes(seasonNumber) { it.withWatchedPlaybackState(watched) } + _uiState.update { state -> + state.copy( + seasons = state.seasons.map { + if (it.seasonNumber != seasonNumber) it else it.copy( + userData = (it.userData ?: SeasonUserData()).copy(played = watched), + ) + }, + episodes = if (state.selectedSeason == seasonNumber) { + state.episodes.map { it.withWatchedPlaybackState(watched) } + } else { + state.episodes + }, + ) + } + publishCarousel() + if (current.selectedSeason == seasonNumber) refreshNextUp(_uiState.value.episodes) + + val writeIntent = personalDataRepository.beginWatched(season.contentId, watched) + viewModelScope.launch { + val result = personalDataRepository.performPersonalWrite(writeIntent) + if (!personalDataRepository.isCurrent(writeIntent)) return@launch + if (seasonWatchMutationGenerations[seasonNumber] != generation) return@launch + seasonWatchMutationGenerations.remove(seasonNumber) + if (result is ApiResult.Success) { + val known = (episodeWindow.get(seasonNumber).orEmpty() + previousEpisodes.orEmpty()) + .filter { it.seasonNumber == seasonNumber } + .map { it.contentId } + val episodeIds = known.ifEmpty { seasonEpisodeIds(detail.contentId, seasonNumber) } + userItemState.clearLocalPlaybackProgressBefore(episodeIds, admittedAtMs, writeIntent.identityGeneration) + // Re-reads the series hero, the season list, and the selected season's episodes. + refreshOnReturn(afterWatchedChange = true) + return@launch + } + if (seriesWatchMutationGeneration != seriesGenerationAtStart && + failedSeriesWatchGeneration != seriesWatchMutationGeneration + ) { + // A series write since this one began also covers this season; + // the snapshot is stale, so re-read the server instead. + refreshOnReturn(afterWatchedChange = true) + return@launch + } + // Restore by id: carousel moves may have republished the optimistic + // page. An episode marked on its own since the season write began + // keeps that newer state. + val previousById = (previousPage.orEmpty() + previousEpisodes.orEmpty()) + .filter { + val latest = lastEpisodeWatchMutation[it.contentId] ?: 0L + // An episode written successfully on its own since the season + // write began keeps that state. Otherwise a newer episode + // write that failed rolled back to this season's optimistic + // state, so undo it here too. + (succeededEpisodeWatchMutation[it.contentId] ?: 0L) <= episodeMutationWatermark && + (latest <= episodeMutationWatermark || failedEpisodeWatchMutation[it.contentId] == latest) + } + .associateBy { it.contentId } + episodeWindow.mapEpisodes(seasonNumber) { previousById[it.contentId] ?: it } + _uiState.update { state -> + state.copy( + seasons = state.seasons.map { if (it.seasonNumber == seasonNumber) previousSeason else it }, + episodes = if (state.selectedSeason == seasonNumber) { + state.episodes.map { previousById[it.contentId] ?: it } + } else { + state.episodes + }, + ) + } + publishCarousel() + if (_uiState.value.selectedSeason == seasonNumber) refreshNextUp(_uiState.value.episodes) + } + } + + /** Episode ids of a season read fresh from the server; empty if the read fails. */ + private suspend fun seasonEpisodeIds(seriesContentId: String, seasonNumber: Int): List = + when (val r = catalogRepository.getEpisodes(seriesContentId, seasonNumber, libraryId = libraryId, fresh = true)) { + is ApiResult.Success -> r.data.episodes.map { it.contentId } + else -> emptyList() + } + + /** Replaces the season list (and its watched state) without touching the selection. */ + private fun refreshSeasonsQuietly(seriesContentId: String, fresh: Boolean = false) { + // Only the latest read publishes; a season watched change bumps the + // generation so an older read cannot undo its optimistic state. + val generation = ++seasonsRefreshGeneration + viewModelScope.launch { + val result = catalogRepository.getSeasons(seriesContentId, libraryId = libraryId, fresh = fresh) + if (result !is ApiResult.Success) return@launch + val seasons = result.data.seasons.sortedForDisplay() + _uiState.update { state -> + if (generation != seasonsRefreshGeneration || state.detail?.contentId != seriesContentId || + seasons.isEmpty() + ) state else state.copy(seasons = seasons) + } + } + } + fun onSetRating(stars: Int) { val current = _uiState.value if (current.isTogglingRating) return @@ -1282,6 +1457,15 @@ class TvItemDetailViewModel( private var episodeListGeneration: Long = 0 private var nextEpisodeWatchMutationGeneration: Long = 0 private val episodeWatchMutationGenerations = mutableMapOf() + private val seasonWatchMutationGenerations = mutableMapOf() + // Unlike episodeWatchMutationGenerations, kept after completion so a season + // rollback can tell which episodes changed on their own since it began. + private val lastEpisodeWatchMutation = mutableMapOf() + private val failedEpisodeWatchMutation = mutableMapOf() + private val succeededEpisodeWatchMutation = mutableMapOf() + private var seriesWatchMutationGeneration: Long = 0 + private var failedSeriesWatchGeneration: Long = -1 + private var seasonsRefreshGeneration: Long = 0 private var nextUpPlaybackDetailGeneration: Long = 0 private var nextUpSelectorRevision: Long = 0 private var pendingNextUpSelectionHandoff: PendingNextUpSelectionHandoff? = null @@ -1317,6 +1501,7 @@ class TvItemDetailViewModel( quiet: Boolean = false, revalidateFavorites: Set? = emptySet(), favoritesVersion: Long? = null, + freshRead: Boolean = false, ) { // Cancel any in-flight episode load so a slower response for a // previously-selected season can't overwrite episodes/next-up for the @@ -1335,9 +1520,14 @@ class TvItemDetailViewModel( if (!ownsRequest()) return@launch if (!quiet) _uiState.update { it.copy(episodesLoading = true) } - seedCachedEpisodes(seriesContentId, seasonNumber) + if (!freshRead) seedCachedEpisodes(seriesContentId, seasonNumber) if (!ownsRequest()) return@launch - val result = catalogRepository.getEpisodes(seriesContentId, seasonNumber, libraryId = libraryId) + val result = catalogRepository.getEpisodes( + seriesContentId, + seasonNumber, + libraryId = libraryId, + fresh = freshRead, + ) if (!ownsRequest()) return@launch when (result) { is ApiResult.Success -> { @@ -1471,6 +1661,7 @@ class TvItemDetailViewModel( val listGeneration = episodeListGeneration val mutationGeneration = ++nextEpisodeWatchMutationGeneration episodeWatchMutationGenerations[episodeContentId] = mutationGeneration + lastEpisodeWatchMutation[episodeContentId] = mutationGeneration val updatedEpisodes = previousEpisodes.map { episode -> if (episode.contentId == episodeContentId) episode.withWatchedPlaybackState(watched) else episode } @@ -1483,7 +1674,12 @@ class TvItemDetailViewModel( val result = personalDataRepository.performPersonalWrite(writeIntent) if (!personalDataRepository.isCurrent(writeIntent)) return@launch val isCurrentMutation = episodeWatchMutationGenerations[episodeContentId] == mutationGeneration + if (result is ApiResult.Success) { + succeededEpisodeWatchMutation[episodeContentId] = + maxOf(succeededEpisodeWatchMutation[episodeContentId] ?: 0L, mutationGeneration) + } if (result !is ApiResult.Success) { + if (isCurrentMutation) failedEpisodeWatchMutation[episodeContentId] = mutationGeneration if ( isCurrentMutation && previousEpisode != null && @@ -1515,6 +1711,10 @@ class TvItemDetailViewModel( if (!seriesId.isNullOrBlank() && season != null) { loadEpisodes(seriesId, season, quiet = true) } + // The season's watched state may have flipped with this episode. + if (detail?.type?.lowercase() == "series" && !seriesId.isNullOrBlank()) { + refreshSeasonsQuietly(seriesId) + } } } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt index 5ca3f44335..9888fc7402 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt @@ -701,6 +701,72 @@ class TvNextUpSelectionHandoffTest { assertNull(TvDetailTrackSelectionSession.recall(scenario.episodeTwoId)) } + @Test + fun markingSelectedSeasonWatchedChecksItsEpisodesAndRefreshesTheSeason() = runDetailTest { + val scenario = Scenario(suffix = "-season-watched") + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + val season = fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 } + val seasonRequestsBefore = scenario.seasonsRequests.get() + + fixture.viewModel.onSetSeasonWatched(season, true) + + val optimistic = fixture.viewModel.uiState.value + assertEquals(true, optimistic.seasons.first { it.seasonNumber == 1 }.userData?.played) + assertTrue(optimistic.episodes.all { it.userData?.played == true }) + awaitCondition { + scenario.seasonsRequests.get() > seasonRequestsBefore && + fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 }.userData?.played == true + } + assertEquals(1, scenario.seasonOneWatchRequests.get()) + assertTrue(scenario.episodeOneWatched && scenario.episodeTwoWatched) + assertTrue(fixture.viewModel.uiState.value.episodes.all { it.userData?.played == true }) + } + + @Test + fun failedSeasonWatchedRestoresTheSeasonAndItsEpisodes() = runDetailTest { + val scenario = Scenario(suffix = "-season-watched-fails") + scenario.seasonOneWatchFails = true + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + val season = fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 } + + fixture.viewModel.onSetSeasonWatched(season, true) + awaitCondition { + scenario.seasonOneWatchRequests.get() == 1 && + fixture.viewModel.uiState.value.episodes.none { it.userData?.played == true } + } + + val state = fixture.viewModel.uiState.value + assertFalse(state.seasons.first { it.seasonNumber == 1 }.userData?.played == true) + assertEquals(scenario.episodeOneId, state.nextUpEpisode?.contentId) + } + + @Test + fun failedSeasonWatchedKeepsAnEpisodeMarkedWhileItWasPending() = runDetailTest { + val scenario = Scenario(suffix = "-season-watched-overlap") + scenario.episodeTwoWatched = true + scenario.seasonOneWatchFails = true + scenario.seasonOneWatchGate = CompletableDeferred() + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + val season = fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 } + + fixture.viewModel.onSetSeasonWatched(season, true) + awaitCondition { scenario.seasonOneWatchRequests.get() == 1 } + fixture.viewModel.onSetEpisodeWatched(scenario.episodeTwoId, false) + awaitCondition { !scenario.episodeTwoWatched } + scenario.seasonOneWatchGate?.complete(Unit) + + awaitCondition { + fixture.viewModel.uiState.value.seasons.first { it.seasonNumber == 1 }.userData?.played != true && + fixture.viewModel.uiState.value.episodes.none { it.userData?.played == true } + } + val played = fixture.viewModel.uiState.value.episodes.associate { it.contentId to (it.userData?.played == true) } + // Episode one reverts with the failed season write; episode two keeps its own change. + assertEquals(mapOf(scenario.episodeOneId to false, scenario.episodeTwoId to false), played) + } + // ------------------------------------------------------------------ private val createdViewModels = mutableListOf() @@ -803,6 +869,9 @@ class TvNextUpSelectionHandoffTest { val seasonTwoEpisodeId = "season-2-episode-1$suffix" var episodeOneWatched = false var episodeTwoWatched = false + var seasonOneWatchFails = false + var seasonOneWatchGate: CompletableDeferred? = null + val seasonOneWatchRequests = AtomicInteger() var episodeTwoGate: CompletableDeferred? = null var episodeOneWatchGate: CompletableDeferred? = null var seasonsGate: CompletableDeferred? = null @@ -837,7 +906,7 @@ class TvNextUpSelectionHandoffTest { seasonsGate?.await() json( """{"items":[ - {"content_id":"season-1$suffix","season_number":1,"title":"Season 1"}, + {"content_id":"season-1$suffix","season_number":1,"title":"Season 1","user_data":{"played":${episodeOneWatched && episodeTwoWatched}}}, {"content_id":"season-2$suffix","season_number":2,"title":"Season 2"} ]}""".trimIndent(), ) @@ -876,6 +945,22 @@ class TvNextUpSelectionHandoffTest { episodeOneWatched = request.method.value != "DELETE" respond("", HttpStatusCode.NoContent) } + "/api/v2/watched/season-1$suffix" -> { + seasonOneWatchRequests.incrementAndGet() + seasonOneWatchGate?.await() + if (seasonOneWatchFails) { + respond( + content = """{"type":"about:blank","title":"failed","status":500}""", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + val watched = request.method.value != "DELETE" + episodeOneWatched = watched + episodeTwoWatched = watched + respond("", HttpStatusCode.NoContent) + } + } "/api/v2/watched/$episodeTwoId" -> { episodeTwoWatched = request.method.value != "DELETE" respond("", HttpStatusCode.NoContent) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt index 31895d75c2..3af09b0da9 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt @@ -204,10 +204,17 @@ class CatalogRepository( suspend fun getWatchDetail(contentId: String, libraryId: Int? = null): ApiResult = catalogApi.getWatchDetail(contentId, libraryId) - /** Lists seasons for a series (offline: last cached seasons). */ - suspend fun getSeasons(seriesId: String, libraryId: Int? = null): ApiResult { + /** + * Lists seasons for a series (offline: last cached seasons). + * + * [fresh] reads the server only: it neither joins a request already in + * flight nor falls back to the cache, both of which can still hold the + * state from before a write the caller just made. + */ + suspend fun getSeasons(seriesId: String, libraryId: Int? = null, fresh: Boolean = false): ApiResult { if (libraryId != null) return catalogApi.getSeasons(seriesId, libraryId) val requestIdentityGeneration = identityTransitions.generation.value + if (fresh) return fetchSeasons(seriesId, requestIdentityGeneration, serveCacheOnFailure = false) val warmRequest = detailRequestMutex.withLock { seasonsInFlight[requestIdentityGeneration to seriesId] } @@ -236,10 +243,19 @@ class CatalogRepository( return getSeasons(seriesId) } - /** Lists episodes for a specific season of a series (offline: last cached episodes). */ - suspend fun getEpisodes(seriesId: String, seasonNumber: Int, libraryId: Int? = null): ApiResult { + /** + * Lists episodes for a specific season of a series (offline: last cached + * episodes). [fresh] behaves as on [getSeasons]. + */ + suspend fun getEpisodes( + seriesId: String, + seasonNumber: Int, + libraryId: Int? = null, + fresh: Boolean = false, + ): ApiResult { if (libraryId != null) return catalogApi.getEpisodes(seriesId, seasonNumber, libraryId) val requestIdentityGeneration = identityTransitions.generation.value + if (fresh) return fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, serveCacheOnFailure = false) val requestKey = EpisodesRequestKey(requestIdentityGeneration, seriesId, seasonNumber) val warmRequest = detailRequestMutex.withLock { episodesInFlight[requestKey] } return warmRequest?.await() @@ -338,6 +354,7 @@ class CatalogRepository( private suspend fun fetchSeasons( seriesId: String, requestIdentityGeneration: Long, + serveCacheOnFailure: Boolean = true, ): ApiResult { val result = catalogApi.getSeasons(seriesId) if (result is ApiResult.Success) { @@ -346,7 +363,7 @@ class CatalogRepository( } return result } - if (result.canServeCache()) { + if (serveCacheOnFailure && result.canServeCache()) { catalogCache.getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } } return result @@ -356,6 +373,7 @@ class CatalogRepository( seriesId: String, seasonNumber: Int, requestIdentityGeneration: Long, + serveCacheOnFailure: Boolean = true, ): ApiResult { val result = catalogApi.getEpisodes(seriesId, seasonNumber) if (result is ApiResult.Success) { @@ -369,7 +387,7 @@ class CatalogRepository( } return result } - if (result.canServeCache()) { + if (serveCacheOnFailure && result.canServeCache()) { catalogCache.getCachedEpisodes(seriesId, seasonNumber)?.let { return ApiResult.Success(it) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt index 80d73ad444..ae892a7fe3 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt @@ -105,6 +105,19 @@ interface UserItemStatePort { /** Batched local resume snapshots for card/detail overlays. */ suspend fun localPlaybackProgressForContent(contentIds: List): Map = emptyMap() + /** + * Clears local resume samples recorded before [admittedAtMs] for [contentIds]. + * A season or series watched write names only its parent, so the episodes it + * reached keep their local progress unless the caller clears it here. + * [identityGeneration] is the write's; nothing is cleared once the active + * server, account, or profile has changed since. + */ + suspend fun clearLocalPlaybackProgressBefore( + contentIds: List, + admittedAtMs: Long, + identityGeneration: Long, + ) {} + /** * Durably record per-file track selections. These are local-only hints for * future playback starts, stored as stable fingerprints rather than raw UI From 6f1a366cc716a762f5f7cf6ce5410b7406085928 Mon Sep 17 00:00:00 2001 From: rangoDJ Date: Thu, 1 Oct 2026 21:49:16 -0400 Subject: [PATCH 09/87] fix(downloads): show why a series or episode download didn't start (#429) "Download series" ignored the batch endpoint's result, so a request the server rejected (such as the concurrent download limit) looked like nothing happened. Single downloads only reported success or failure. Both now report the start, and a rejected request shows the server's message in a toast, with a fallback when it has none. Co-authored-by: Claude Opus 5.5 --- .../ui/screens/detail/ItemDetailScreen.kt | 5 ++++ .../ui/screens/detail/ItemDetailViewModel.kt | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index 8b0a5411af..9fdde5658c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -206,6 +206,11 @@ fun ItemDetailScreen( } } } + LaunchedEffect(Unit) { + viewModel.downloadFailureMessages.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } val downloadStorage: DownloadStorage = koinInject() val serverRegistry: ServerRegistry = koinInject() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt index a0bb7dc908..b7c7d25d3a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt @@ -18,6 +18,7 @@ import org.siloserver.silo.model.download.DownloadCapability import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.statusEnum import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.errorMessage import org.siloserver.silo.model.catalog.isBookLikeItemType import org.siloserver.silo.metadata.DescriptionTranslationController import org.siloserver.silo.metadata.DescriptionTranslationPhase @@ -279,6 +280,20 @@ class ItemDetailViewModel( private val _downloadStartEvents = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 4) val downloadStartEvents: kotlinx.coroutines.flow.SharedFlow = _downloadStartEvents + /** Why a download couldn't start, such as the server's "concurrent + * download limit reached". The screen shows it; without it a rejected + * request, and above all a rejected series batch, looked like nothing + * happened. */ + private val _downloadFailureMessages = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 4) + val downloadFailureMessages: kotlinx.coroutines.flow.SharedFlow = _downloadFailureMessages + + private suspend fun reportDownloadStart(result: ApiResult<*>) { + _downloadStartEvents.emit(result is ApiResult.Success) + if (result !is ApiResult.Success) { + _downloadFailureMessages.emit(result.errorMessage("Couldn't start the download.")) + } + } + private suspend fun startDownload( version: FileVersion, displayTitle: String, @@ -335,7 +350,7 @@ class ItemDetailViewModel( downloadQualityOverride = downloadQuality, ) } - _downloadStartEvents.emit(result is ApiResult.Success) + reportDownloadStart(result) } /** Series-level "Download series" — uses the server's batch endpoint @@ -343,9 +358,11 @@ class ItemDetailViewModel( fun onSeriesDownloadTapped(downloadQuality: DownloadQuality? = null) { val detail = _uiState.value.detail ?: return viewModelScope.launch { - downloadEnqueuer.startSeries( - seriesContentId = detail.contentId, - downloadQualityOverride = downloadQuality, + reportDownloadStart( + downloadEnqueuer.startSeries( + seriesContentId = detail.contentId, + downloadQualityOverride = downloadQuality, + ), ) } } From 9518b262305a3137949b90720ce263617d1e46a3 Mon Sep 17 00:00:00 2001 From: Suspense Date: Fri, 2 Oct 2026 03:49:40 +0200 Subject: [PATCH 10/87] fix(overlays): honor a profile's poster badge switch on phone and TV (#438) Turning off "Card overlay badges" in the web settings stores the profile setting ui.card_overlays_enabled, which the Android apps never read, so badges kept showing on posters. The overlay store now reads it alongside ui.card_overlays and resolves "show badges" the way the web does: the profile's choice when it has one, otherwise the server-wide default from overlay-config. That default is no longer treated as a hard off, so a profile that turns badges on sees them too. --- .../silo/common/settings/OverlayPrefsStore.kt | 58 ++++++++-- .../common/settings/OverlayPrefsStoreTest.kt | 104 +++++++++++++++--- .../silo/network/api/SettingsApi.kt | 7 +- 3 files changed, 141 insertions(+), 28 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStore.kt index 8ea22291a3..fa92d6ac25 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStore.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull /** * Cached card-overlay configuration for the signed-in profile. Mirrors @@ -40,9 +42,11 @@ import kotlinx.serialization.json.JsonNull */ interface OverlayPrefsStore { /** - * `true` when the server allows overlays at all. An admin can flip - * this off globally; when `false`, cards should not render overlays - * even if the user has prefs configured. + * `true` when cards should render overlays at all. The profile's + * `ui.card_overlays_enabled` choice wins in either direction; a profile + * that has not chosen inherits the server-wide `overlays.enabled` + * default from `GET /settings/overlay-config`. When `false`, cards + * render no overlays even if the profile has prefs configured. */ val enabled: StateFlow @@ -96,6 +100,12 @@ class DefaultOverlayPrefsStore( @Volatile private var adminDefaultsRaw: String? = null + // The two inputs to [enabled], cached separately so a failed read of + // one keeps the last answer for that half without discarding the other. + // Both change only under [stateLock]. + private var serverEnabled: Boolean = true + private var profileEnabled: Boolean? = 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. @@ -150,18 +160,19 @@ class DefaultOverlayPrefsStore( } /** - * Re-fetch both the admin config and the canonical profile value, then recompute - * [prefs]. + * Re-fetch both the admin config and the canonical profile values, then + * recompute [prefs] and [enabled]. * * Failure semantics mirror iOS: * - 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 - * but the user setting resolves, we MUST NOT mark hydrated, or - * [enabled] is stuck at `true` and the admin's "disable globally" - * toggle is silently ignored for the session. + * critical for the server-wide overlay default: if `/overlay-config` + * errors but the user settings resolve, we MUST NOT mark hydrated, or + * [enabled] is stuck at `true` for a profile that has not chosen and + * the admin's "off for everyone" default is silently ignored for the + * session. */ override suspend fun refresh() = refreshLock.withLock { val refreshState = synchronized(stateLock) { @@ -194,9 +205,14 @@ class DefaultOverlayPrefsStore( } var userValue: JsonElement? = null + var userEnabled: Boolean? = null var userFetchFailed = false var userError: String? = null - when (val result = repository.getEffectiveValues(listOf(OVERLAY_SETTING_KEY))) { + when ( + val result = repository.getEffectiveValues( + listOf(OVERLAY_SETTING_KEY, OVERLAYS_ENABLED_SETTING_KEY), + ) + ) { is ApiResult.Success -> { val entry = result.data[OVERLAY_SETTING_KEY] if (entry == null) { @@ -208,6 +224,13 @@ class DefaultOverlayPrefsStore( ) { userValue = entry.value } + // A server that predates the key omits it, which reads + // the same as a profile that has not chosen. + userEnabled = result.data[OVERLAYS_ENABLED_SETTING_KEY] + ?.takeIf { it.source == SettingScope.PROFILE.wire } + ?.value + ?.let { it as? JsonPrimitive } + ?.booleanOrNull } is ApiResult.Error -> { userError = result.message @@ -238,7 +261,7 @@ class DefaultOverlayPrefsStore( 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 + // user-derived half: the independent server-wide default and // baseline remain valid and must still update. if (sessionStateEpoch == refreshState.sessionStateEpoch) { val userResponseIsCurrent = @@ -247,7 +270,7 @@ class DefaultOverlayPrefsStore( // sentinel `resolvedEnabled = true` is only valid when the // fetch actually succeeded. if (!configFetchFailed) { - _enabled.value = resolvedEnabled + serverEnabled = resolvedEnabled adminDefaultsRaw = resolvedAdminDefaults // When the current confirmed state has no user value, // the admin baseline is independently authoritative. @@ -268,6 +291,14 @@ class DefaultOverlayPrefsStore( } } } + // This store never writes ui.card_overlays_enabled, so a + // local document edit cannot make this answer stale. + if (!userFetchFailed) { + profileEnabled = userEnabled + } + // An explicit profile choice overrides the server-wide + // default in either direction, as on web. + _enabled.value = profileEnabled ?: serverEnabled if (userResponseIsCurrent) { _lastError.value = userError ?: configError @@ -511,6 +542,8 @@ class DefaultOverlayPrefsStore( pendingSnapshot = null val activeWrite = pendingWrite pendingWrite = null + serverEnabled = true + profileEnabled = null _enabled.value = true _prefs.value = OverlaySchema.buildDefaults() confirmedPrefs = _prefs.value @@ -541,5 +574,6 @@ class DefaultOverlayPrefsStore( companion object { const val OVERLAY_SETTING_KEY = SettingKeys.UI_CARD_OVERLAYS + const val OVERLAYS_ENABLED_SETTING_KEY = SettingKeys.UI_CARD_OVERLAYS_ENABLED } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStoreTest.kt index 58ddb0de6b..550ad7f9c1 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/OverlayPrefsStoreTest.kt @@ -24,6 +24,7 @@ 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 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -45,7 +46,73 @@ class OverlayPrefsStoreTest { assertEquals(expected, store.prefs.value) assertTrue(store.hasUserOverride) - assertEquals(listOf(SettingKeys.UI_CARD_OVERLAYS), api.effectiveRequests.single()) + assertEquals( + listOf(SettingKeys.UI_CARD_OVERLAYS, SettingKeys.UI_CARD_OVERLAYS_ENABLED), + api.effectiveRequests.single(), + ) + } + + @Test + fun `profile that turned overlays off hides them although the server default is on`() = runTest { + val api = RecordingOverlaySettingsApi(profileOverlaysEnabled = false) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertFalse(store.enabled.value) + } + + @Test + fun `profile that turned overlays on shows them although the server default is off`() = runTest { + val api = RecordingOverlaySettingsApi(profileOverlaysEnabled = true) + api.overlayEnabled = false + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertTrue(store.enabled.value) + } + + @Test + fun `profile without a choice follows the server default, including on servers without the key`() = runTest { + for (servesKey in listOf(true, false)) { + val api = RecordingOverlaySettingsApi(servesOverlaysEnabled = servesKey) + api.overlayEnabled = false + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertFalse(store.enabled.value, "servesKey=$servesKey") + assertEquals(null, store.lastError.value, "servesKey=$servesKey") + } + } + + @Test + fun `profile choice made elsewhere applies on the next refresh`() = runTest { + val api = RecordingOverlaySettingsApi() + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + assertTrue(store.enabled.value) + + api.profileOverlaysEnabled = false + store.refresh() + assertFalse(store.enabled.value) + + api.profileOverlaysEnabled = null + store.refresh() + assertTrue(store.enabled.value) + } + + @Test + fun `clear forgets the previous profile's overlay choice`() = runTest { + val api = RecordingOverlaySettingsApi(profileOverlaysEnabled = false) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + assertFalse(store.enabled.value) + + store.clear() + + assertTrue(store.enabled.value) } @Test @@ -295,6 +362,10 @@ class OverlayPrefsStoreTest { private class RecordingOverlaySettingsApi( var storedValue: JsonElement? = null, var adminDefaults: String? = null, + var profileOverlaysEnabled: Boolean? = null, + // A server below the revision that introduced ui.card_overlays_enabled + // omits the key from the response. + val servesOverlaysEnabled: Boolean = true, ) : SettingsApi(org.siloserver.silo.network.apiv2.SettingsV2Api(HttpClient(), org.siloserver.silo.network.TokenManagerImpl(), org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted)) { data class CallGate( @@ -345,30 +416,37 @@ private class RecordingOverlaySettingsApi( ): ApiResult { effectiveRequests += keys val value = storedValue + val enabled = profileOverlaysEnabled nextEffectiveReadGate?.also { gate -> nextEffectiveReadGate = null gate.started.complete(Unit) gate.release.await() } + val settings = buildList { + add(effective(SettingKeys.UI_CARD_OVERLAYS, value)) + if (servesOverlaysEnabled && SettingKeys.UI_CARD_OVERLAYS_ENABLED in keys) { + add(effective(SettingKeys.UI_CARD_OVERLAYS_ENABLED, enabled?.let(::JsonPrimitive))) + } + } 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 }, - ), - ), + settings = settings, revision = SettingKeys.REVISION, ), ) } + private fun effective(key: String, value: JsonElement?) = EffectiveSettingValue( + key = key, + value = value ?: JsonNull, + source = if (value == null) { + EffectiveSettingValue.SOURCE_DEFAULT + } else { + SettingScope.PROFILE.wire + }, + scope = SettingScope.PROFILE.wire.takeIf { value != null }, + ) + override suspend fun putValue( key: String, scope: SettingScopeIdentity, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SettingsApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SettingsApi.kt index 3129a4aacb..868d839292 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SettingsApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SettingsApi.kt @@ -11,9 +11,10 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement /** - * Admin-configured card-overlay baseline. `enabled` is the global - * kill-switch (admins can disable overlays for everyone); `defaults` is - * the serialized [CardOverlayPrefs] JSON used when a user has no override. + * Admin-configured card-overlay baseline. `enabled` is the server-wide + * default for profiles that have not set `ui.card_overlays_enabled`; + * `defaults` is the serialized [CardOverlayPrefs] JSON used when a user + * has no override. * Mirrors iOS `OverlayConfigResponse` and the server's * `GET /api/v2/settings/overlay-config` shape. */ From fa0f83ab18e49606b6c75a3f35fccaab74ac32fa Mon Sep 17 00:00:00 2001 From: Suspense Date: Fri, 2 Oct 2026 14:44:04 +0200 Subject: [PATCH 11/87] feat(settings): let profiles turn off title art on Android phone and TV (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): add the title art preference on Android phone and TV Read and write ui.title_art (settings manifest revision 16) through one app-wide store. When title art is off, detail heroes, the TV marquee and logo prefetch use the text title instead of logo art. Settings → Interface on the phone and General on TV get "Show title art" and "Apply to all devices", with a note when the profile-wide value is in force. Phone heroes put the title type and studio or network on one eyebrow line. Vendors the revision 16 manifest and conformance fixture. * fix(settings): pin one profile across a title art change Every request in a title art change (the PUT, the profile DELETE and the re-read) and every refresh read now carries one captured auth scope, the way the settings flusher pins its writes. A profile switch part-way through makes the remaining requests fail as identity_changed instead of changing the new profile's value. * fix(settings): drop title art changes queued behind a failed write When a title art write fails, changes queued behind it are dropped instead of sent with the failed change's unsaved scope, the store returns to the confirmed state and re-reads the server, and only server-confirmed values are cached. A refresh now captures its generation before reading the profile and auth scope, so a profile switch during those reads can no longer paint or cache the previous profile's value. * fix(settings): stop a stalled title art write blocking the next server Each server or profile now serializes its own title art writes. A write still on the wire for the previous identity keeps its own lock, and its completion is discarded by the generation check, so the new identity's saves go out at once. * fix(settings): show failed title art saves on phone and TV The store now exposes saveError, set when a title art write fails and cleared by the next successful save, a fresh read when Settings opens, or an identity change. Both settings surfaces show it under the switches, the same way the skip-interval controls show their save errors. * refactor(detail): keep the phone hero eyebrow and marquee layout unchanged Move the MOVIE · STUDIO eyebrow line and the marquee synopsis rule to their own change so this pull request covers only the title art setting. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): keep title art toggles working through cast sessions and cold starts - A remote-playback overlay moves the identity generation without changing the profile, so the write guard now compares server, credential epoch and profile instead of rejecting the toggle as a profile change. - The switches stay disabled until this session's read lands, so a toggle never picks its scope from a stale cached 'Apply to all devices'. - Concurrent hydrate and refresh calls share one load instead of each sending capabilities and effective-values requests. - Settings refreshes once when it opens, not each time the LazyColumn item scrolls back into view (which also cleared a shown save error). - Drop isSaving and lastError, which no screen read. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor(settings): gate title logos through one helper Logo surfaces call titleLogoUrl instead of each reading LocalShowTitleArt, and the local is no longer static, so the cold-start flip from the default recomposes only its readers instead of the whole app tree. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(startup): stop TV first-row warming from waiting on title art The warm read the store's current answer (cached, else logos on) instead of awaiting two settings requests before any backdrop was fetched. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor(settings): share the hashed cache key between settings stores Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): keep title art saves working across cast overlays The write guard compared credential epochs between any two snapshots, but a remote-playback overlay's snapshot always carries epoch 0 while the saved account's carries its sign-in epoch. A read on one side of an overlay start or end therefore failed every toggle on the other side with "The acting account or profile changed", on the same profile. The epoch is now compared only between two saved-account snapshots, and server ids are matched the way the overlay handoff matches them. A load also took its cache key from separate profile and server reads and its request authority from a third read, so an overlay starting or ending between them could cache one profile's answer under another's key. The key now comes from the same snapshot the load is pinned to. --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/common/di/PlayerInfraModule.kt | 22 + .../common/settings/CardPresentationStore.kt | 7 +- .../silo/common/settings/SeekIntervalStore.kt | 6 +- .../settings/ServerDrivenConfigRefresher.kt | 2 + .../silo/common/settings/SettingsCacheKeys.kt | 14 + .../silo/common/settings/TitleArtLocals.kt | 54 ++ .../silo/common/settings/TitleArtStore.kt | 561 +++++++++++++++ .../silo/common/startup/StartupWarmup.kt | 19 +- .../silo/common/settings/TitleArtStoreTest.kt | 673 ++++++++++++++++++ .../android/ui/navigation/AppNavigation.kt | 7 + .../silo/android/ui/screens/MainScreen.kt | 3 + .../screens/detail/DetailSharedComponents.kt | 5 +- .../ui/screens/settings/SettingsScreen.kt | 7 + .../ui/screens/settings/TitleArtSettings.kt | 55 ++ .../org/siloserver/silo/tv/MainTvActivity.kt | 4 + .../silo/tv/ui/components/TvFocusMarquee.kt | 9 +- .../tv/ui/components/TvSkylineSectionFeed.kt | 7 +- .../silo/tv/ui/navigation/TvAppNavigation.kt | 12 +- .../silo/tv/ui/screens/detail/TvDetailHero.kt | 9 +- .../ui/screens/settings/TvSettingsScreen.kt | 48 ++ .../domain/settings/TitleArtController.kt | 172 +++++ .../silo/model/settings/SettingKeys.kt | 10 +- .../silo/repository/SettingsRepository.kt | 28 +- .../domain/settings/TitleArtControllerTest.kt | 259 +++++++ .../commonTest/resources/settings/v1/SOURCE | 8 +- .../resources/settings/v1/conformance.json | 24 +- .../resources/settings/v1/manifest.json | 33 +- 27 files changed, 2020 insertions(+), 38 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsCacheKeys.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtLocals.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtStore.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/TitleArtStoreTest.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/TitleArtController.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/TitleArtControllerTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt index 19aa761636..ba59f4d411 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt @@ -22,9 +22,12 @@ import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.common.settings.ServerDrivenConfigRefresher import org.siloserver.silo.common.settings.SeekIntervalStore import org.siloserver.silo.common.settings.SettingsContractRevision +import org.siloserver.silo.common.settings.DefaultTitleArtStore +import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.common.settings.ServerSettingsFlusher import org.siloserver.silo.domain.player.IntroAutoSkipController import org.siloserver.silo.domain.settings.SeekIntervalController +import org.siloserver.silo.domain.settings.TitleArtController import org.siloserver.silo.network.DeviceMetadataProvider import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager @@ -198,6 +201,24 @@ val playerInfraModule = module { ) } + // "Show title art" (settings revision 16) for this device. Every title + // surface and both settings screens read this one instance, so a change + // reaches an open detail page without a restart. + single { + val registry = get() + val identityChanges = registry.activeEntry + .map { it?.url to it?.profileId } + .distinctUntilChanged() + .map { Unit } + DefaultTitleArtStore( + context = androidContext(), + controller = TitleArtController(get()), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + getAuthScope = { get().snapshotCurrentScope() }, + identityChanges = identityChanges, + ) + } + single { ServerDrivenConfigRefresher( overlayPrefsStore = get(), @@ -205,6 +226,7 @@ val playerInfraModule = module { libraryPlaybackPrefsStore = get(), playerSettingsStore = get(), seekIntervalStore = get(), + titleArtStore = get(), hasAuthenticatedProfile = { !get().getActiveProfileId().isNullOrBlank() }, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/CardPresentationStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/CardPresentationStore.kt index 4f727ebabf..dcd545e948 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/CardPresentationStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/CardPresentationStore.kt @@ -460,7 +460,7 @@ class DefaultCardPresentationStore( val deviceId: String, ) { val cacheKeyPrefix: String = - "cp_" + sha256Hex("$serverUrl|$profileId|$clientFamily|$deviceId").take(24) + settingsCachePrefix("cp_", "$serverUrl|$profileId|$clientFamily|$deviceId") } private suspend fun currentIdentity(): Identity? { @@ -530,10 +530,5 @@ class DefaultCardPresentationStore( CardPresentationSource.Default -> EffectiveSettingValue.SOURCE_DEFAULT CardPresentationSource.Unknown -> null } - - fun sha256Hex(s: String): String = - java.security.MessageDigest.getInstance("SHA-256") - .digest(s.toByteArray(Charsets.UTF_8)) - .joinToString(separator = "") { "%02x".format(it) } } } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SeekIntervalStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SeekIntervalStore.kt index 31428affdc..71e85d0d45 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SeekIntervalStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SeekIntervalStore.kt @@ -394,11 +394,7 @@ private class SharedPreferencesSeekIntervalCache( } } - private fun prefix(identity: String): String = - "si_" + java.security.MessageDigest.getInstance("SHA-256") - .digest(identity.toByteArray(Charsets.UTF_8)) - .joinToString(separator = "") { "%02x".format(it) } - .take(24) + private fun prefix(identity: String): String = settingsCachePrefix("si_", identity) private companion object { const val SUPPORTED = "supported" diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt index ee7581ba48..108f5da4dd 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt @@ -17,6 +17,7 @@ class ServerDrivenConfigRefresher( private val playerSettingsStore: PlayerSettingsStore, private val hasAuthenticatedProfile: suspend () -> Boolean, private val seekIntervalStore: SeekIntervalStore? = null, + private val titleArtStore: TitleArtStore? = null, private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) { @Volatile @@ -40,6 +41,7 @@ class ServerDrivenConfigRefresher( libraryPlaybackPrefsStore.refresh() playerSettingsStore.refreshFromServer() seekIntervalStore?.refresh() + titleArtStore?.refresh() lastRefreshAtMs = now return true } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsCacheKeys.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsCacheKeys.kt new file mode 100644 index 0000000000..db26b3899b --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/SettingsCacheKeys.kt @@ -0,0 +1,14 @@ +package org.siloserver.silo.common.settings + +/** + * SharedPreferences key prefix for one identity's cached settings answer: + * [tag] plus the first 24 hex digits of the identity's SHA-256, so server URLs + * and profile ids never appear in key names. + */ +internal fun settingsCachePrefix(tag: String, identity: String): String = + tag + sha256Hex(identity).take(24) + +private fun sha256Hex(s: String): String = + java.security.MessageDigest.getInstance("SHA-256") + .digest(s.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { "%02x".format(it) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtLocals.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtLocals.kt new file mode 100644 index 0000000000..dc86e05c74 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtLocals.kt @@ -0,0 +1,54 @@ +package org.siloserver.silo.common.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import org.siloserver.silo.domain.settings.TitleArtController + +/** + * Whether title surfaces may name a title with its logo artwork + * (`ui.title_art`). When false they always show the text title. + * + * Published once near each app shell by [ProvideTitleArt]; anything rendered + * outside it keeps the contract default (logos on). Not static: the value + * flips once on a cold start (default, then the cached or server answer), and + * only the title surfaces that read it should recompose. + */ +val LocalShowTitleArt: ProvidableCompositionLocal = + compositionLocalOf { TitleArtController.DEFAULT_SHOW_TITLE_ART } + +/** + * The logo a title surface may draw for [logoUrl]: null when "Show title art" + * is off or there is no logo, so the caller falls back to the text title. + * Every surface that draws a logo goes through here; prefetch reads + * [LocalShowTitleArt] directly. + */ +@Composable +@ReadOnlyComposable +fun titleLogoUrl(logoUrl: String?): String? { + val show = LocalShowTitleArt.current + return logoUrl?.takeIf { show && it.isNotBlank() } +} + +/** + * Hydrates [store] for [sessionKey] (the active profile id; null before sign-in) + * and publishes its value via [LocalShowTitleArt] for [content]. Same shape as + * `ProvideCardPresentation`. + */ +@Composable +fun ProvideTitleArt( + store: TitleArtStore, + sessionKey: Any? = null, + content: @Composable () -> Unit, +) { + LaunchedEffect(store, sessionKey) { + if (sessionKey != null) store.hydrateIfNeeded() + } + val state by store.state.collectAsState() + CompositionLocalProvider(LocalShowTitleArt provides state.showTitleArt, content = content) +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtStore.kt new file mode 100644 index 0000000000..6c0f80d571 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/TitleArtStore.kt @@ -0,0 +1,561 @@ +package org.siloserver.silo.common.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import org.siloserver.silo.domain.settings.TitleArtController +import org.siloserver.silo.domain.settings.TitleArtPreference +import org.siloserver.silo.network.AndroidServerRegistry +import org.siloserver.silo.network.AuthScopeSnapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Whether the connected server knows `ui.title_art` (settings revision 16). */ +enum class TitleArtSupport { + /** Not answered yet (cold start before the probe, nothing cached). */ + Unknown, + Supported, + + /** Definitive: the server predates the key. Nothing is written. */ + Unsupported, + + /** The probe failed and nothing earlier is known. */ + Unavailable, +} + +data class TitleArtState( + val support: TitleArtSupport = TitleArtSupport.Unknown, + val preference: TitleArtPreference = TitleArtPreference(), + /** + * The server answered for this profile since the last identity change. A + * cached answer alone may be stale, and the main switch's scope comes from + * [appliesToAllDevices], so the switches wait for this. + */ + val isConfirmed: Boolean = false, +) { + /** + * What title surfaces render. Anything short of a supporting server keeps + * the logo behaviour every client had before the setting existed. + */ + val showTitleArt: Boolean + get() = if (support == TitleArtSupport.Supported) { + preference.showTitleArt + } else { + TitleArtController.DEFAULT_SHOW_TITLE_ART + } + + val appliesToAllDevices: Boolean get() = preference.appliesToAllDevices + + /** Only a server that answered for the key gets the settings controls. */ + val isSupported: Boolean get() = support == TitleArtSupport.Supported + + /** The switches accept changes only once the server has confirmed [preference]. */ + val canEdit: Boolean get() = isSupported && isConfirmed +} + +/** + * The signed-in profile's "Show title art" choice for this device — the one + * source every title surface (phone and TV detail heroes, the TV marquee, logo + * prefetch) and both settings screens read, so a change made in settings + * reaches an open detail page without a restart. + * + * Refresh edges: profile or server change ([identityChanges]), the shell's + * session key, and foreground/reconnect via [ServerDrivenConfigRefresher]. + */ +interface TitleArtStore { + val state: StateFlow + + /** + * What the settings screens show after a failed save, e.g. "Couldn't save + * title art: Server error". Cleared by the next successful save, a fresh + * read when Settings opens, and an identity change. + */ + val saveError: StateFlow + + /** Idempotent first load; seeds from the last-known answer first. */ + suspend fun hydrateIfNeeded() + + /** + * Re-probe capabilities and re-resolve the effective value. Joins a load + * already in flight for this identity instead of starting another. + */ + suspend fun refresh() + + /** + * The main switch: applied locally at once, then written at `profile` + * while the value applies to all devices, else at `profile_device`. + * Restores the last confirmed state if the write fails. Ignored until + * [TitleArtState.canEdit]. + */ + fun setShowTitleArt(show: Boolean) + + /** + * "Apply to all devices": on writes the current value at `profile`; off + * pins it at `profile_device` and then clears the `profile` value. + */ + fun setAppliesToAllDevices(enabled: Boolean) + + /** Session boundary (sign-out, profile switch): drop state. */ + fun clear() +} + +class DefaultTitleArtStore private constructor( + private val controller: TitleArtController, + private val scope: CoroutineScope, + private val getAuthScope: suspend () -> AuthScopeSnapshot?, + private val cache: TitleArtCache, + identityChanges: Flow, +) : TitleArtStore { + + constructor( + context: Context, + controller: TitleArtController, + scope: CoroutineScope, + getAuthScope: suspend () -> AuthScopeSnapshot?, + identityChanges: Flow = emptyFlow(), + ) : this( + controller = controller, + scope = scope, + getAuthScope = getAuthScope, + cache = SharedPreferencesTitleArtCache( + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), + ), + identityChanges = identityChanges, + ) + + private val _state = MutableStateFlow(TitleArtState()) + private val _saveError = MutableStateFlow(null) + override val state: StateFlow = _state.asStateFlow() + override val saveError: StateFlow = _saveError.asStateFlow() + + private val lock = Any() + + /** Bumped at every identity boundary; stale work compares and bails. */ + @Volatile + private var generation = 0 + + /** Bumped by every local mutation, so a refresh that started earlier + * cannot paint its older answer over a newer choice. */ + private var mutationEpoch = 0L + + @Volatile + private var hasHydrated = false + + /** The last state the server confirmed; a failed write restores it. */ + private var confirmed = TitleArtState() + + /** The profile the committed state was read for; writes must match it. */ + private var confirmedAuthority: AuthScopeSnapshot? = null + + /** The cache key the committed state belongs to. */ + private var confirmedIdentity: String? = null + + /** + * Every request numbered at or below this was queued behind a write that + * failed. Each one was built on the failed write's unsaved state, so none + * is sent; the store returns to the confirmed state and re-reads. + */ + private var droppedThrough = 0L + + /** Only the latest local request may decide what is shown when it settles. */ + private var requestCounter = 0L + private var pendingWrites = 0 + + /** + * Refreshes run without a lock held across the network, so a stalled + * request for one server never delays another server's answer. Each takes + * a sequence number and only a newer answer than the last committed one + * may land. + */ + private var refreshSequence = 0L + private var committedRefresh = 0L + + /** A refresh answered while a write was queued; re-read once they drain. */ + private var refreshAfterWrites = false + + /** + * The load in flight for the current identity. Startup asks from several + * places at once (the identity collector, the shell, the foreground + * refresher); they share one capabilities and effective-values round trip. + */ + private var inFlightLoad: Deferred? = null + + /** + * Keeps one identity's writes in the order the user made them. Replaced at + * every identity boundary, so a write still on the wire for the previous + * server or profile never holds back the next one's saves. + */ + private var writeLock = Mutex() + + init { + scope.launch { + // collectLatest: a new identity invalidates the old state at once + // and cancels the previous identity's hydration instead of queueing + // behind it. + identityChanges.collectLatest { + synchronized(lock) { resetLocked() } + hydrateIfNeeded() + } + } + } + + override suspend fun hydrateIfNeeded() { + if (hasHydrated) return + load() + } + + override suspend fun refresh() = load() + + private suspend fun load() { + // Lazy, so the load starts outside the lock (Main.immediate would + // otherwise run it inline while the lock is held). + val load = synchronized(lock) { + inFlightLoad?.takeUnless { it.isCompleted } + ?: scope.async(start = CoroutineStart.LAZY) { refresh(keepError = false) } + .also { inFlightLoad = it } + } + load.await() + } + + /** [keepError]: a re-read after writes keeps their outcome (a failure stays reported). */ + private suspend fun refresh(keepError: Boolean) { + // Capture the generation before the suspending authority read: a + // reset while it runs must not pair the previous profile's authority + // with the new generation. The cache key comes from the same snapshot + // the load is pinned to, so the answer is always cached for the + // profile it was read for. + val startGeneration = synchronized(lock) { generation } + val authority = getAuthScope() ?: return + val identity = authority.cacheIdentity() ?: return + val startEpoch: Long + val sequence: Long + synchronized(lock) { + if (generation != startGeneration) return + startEpoch = mutationEpoch + sequence = ++refreshSequence + } + seedFromCache(identity, startGeneration) + val resolved = when (val result = controller.load(authority)) { + is TitleArtController.LoadResult.Supported -> + TitleArtState(TitleArtSupport.Supported, result.preference, isConfirmed = true) + TitleArtController.LoadResult.Unsupported -> + TitleArtState(TitleArtSupport.Unsupported, isConfirmed = true) + is TitleArtController.LoadResult.Failed -> { + synchronized(lock) { + if (generation != startGeneration) return@synchronized + if (_state.value.support == TitleArtSupport.Unknown) { + _state.value = TitleArtState(TitleArtSupport.Unavailable) + } + } + return + } + } + val committed = synchronized(lock) { + if (generation != startGeneration || sequence <= committedRefresh) return@synchronized false + // A queued or in-flight write makes this answer stale by the time + // it lands: it was read before the write, and committing it would + // repaint the old value and route the next toggle to the old + // scope. Drop it and re-read once the writes drain. + if (pendingWrites > 0) { + refreshAfterWrites = true + return@synchronized false + } + if (mutationEpoch != startEpoch) return@synchronized false + committedRefresh = sequence + confirmedAuthority = authority + confirmedIdentity = identity + _state.value = resolved + confirmed = resolved + if (!keepError) _saveError.value = null + hasHydrated = true + true + } + if (committed) cache.write(identity, resolved) + } + + /** Apply the last-known answer while nothing better is on screen. */ + private fun seedFromCache(identity: String, startGeneration: Int) { + if (hasHydrated) return + val cached = cache.read(identity) ?: return + synchronized(lock) { + val current = _state.value.support + val waiting = current == TitleArtSupport.Unknown || current == TitleArtSupport.Unavailable + if (!hasHydrated && generation == startGeneration && waiting) { + _state.value = cached + confirmed = cached + confirmedIdentity = identity + } + } + } + + override fun setShowTitleArt(show: Boolean) { + mutate( + next = { it.copy(showTitleArt = show) }, + write = { pref, authority -> controller.setShowTitleArt(show, pref.appliesToAllDevices, authority) }, + ) + } + + override fun setAppliesToAllDevices(enabled: Boolean) { + mutate( + next = { it.copy(appliesToAllDevices = enabled) }, + write = { pref, authority -> controller.setAppliesToAllDevices(enabled, pref.showTitleArt, authority) }, + ) + } + + /** + * Paints [next] of the preference on screen, then sends [write] with the + * preference as it was *before* this change (which scope the main switch + * addresses, and which value the companion switch carries, both come from + * what the user was looking at when they chose). + */ + private fun mutate( + next: (TitleArtPreference) -> TitleArtPreference, + write: suspend (TitleArtPreference, AuthScopeSnapshot) -> TitleArtController.WriteResult, + ) { + val startGeneration: Int + val request: Long + val before: TitleArtPreference + val optimistic: TitleArtPreference + val writes: Mutex + synchronized(lock) { + val current = _state.value + // Never write to a server that has not confirmed the key, nor + // pick a scope from a cached answer the server has not re-read. + if (!current.canEdit) return + before = current.preference + optimistic = next(before) + if (optimistic == before) return + startGeneration = generation + writes = writeLock + request = ++requestCounter + mutationEpoch += 1 + pendingWrites += 1 + _state.value = current.copy(preference = optimistic) + } + // The store owns the write, so leaving the screen cannot strand an + // unsaved value on screen. + scope.launch { + // One profile for the whole change: every request it makes (up to + // a PUT, a DELETE and a re-read) is pinned to this snapshot, so a + // profile switch part-way through fails the rest instead of + // landing them on the new profile. + val authority = getAuthScope() + val result = writes.withLock { + val readFor = synchronized(lock) { + if (generation != startGeneration || request <= droppedThrough) return@withLock null + confirmedAuthority + } + if (authority == null || readFor == null || !authority.isSameProfileAs(readFor)) { + TitleArtController.WriteResult.Failed(IDENTITY_CHANGED) + } else { + write(before, authority) + } + } + var refreshNow = false + val persisted = synchronized(lock) { + if (generation != startGeneration) return@synchronized false + pendingWrites = (pendingWrites - 1).coerceAtLeast(0) + if (pendingWrites == 0 && refreshAfterWrites) { + refreshAfterWrites = false + refreshNow = true + } + when (result) { + // Dropped: queued behind a failed write. + null -> false + is TitleArtController.WriteResult.Saved -> { + confirmed = TitleArtState( + TitleArtSupport.Supported, + result.resolved ?: optimistic, + isConfirmed = true, + ) + _saveError.value = null + if (requestCounter == request) { + mutationEpoch += 1 + _state.value = confirmed + } + // Cache only what the server confirmed by re-reading. + result.resolved != null + } + is TitleArtController.WriteResult.Failed -> { + _saveError.value = saveErrorText(result.message) + // Every change queued behind this one was built on its + // unsaved state (which scope to write, which value to + // carry). Drop them all, show the confirmed state, and + // re-read the server once nothing is in flight. + droppedThrough = requestCounter + mutationEpoch += 1 + _state.value = confirmed + if (pendingWrites == 0) { + refreshNow = true + } else { + refreshAfterWrites = true + } + false + } + } + } + if (persisted) persistConfirmed(startGeneration) + if (refreshNow) refresh(keepError = true) + } + } + + /** Caches under the identity the confirmed state was read for, never a re-read one. */ + private fun persistConfirmed(startGeneration: Int) { + val (identity, snapshot) = synchronized(lock) { + if (generation != startGeneration) return + (confirmedIdentity ?: return) to confirmed + } + cache.write(identity, snapshot) + } + + override fun clear() { + synchronized(lock) { resetLocked() } + } + + private fun resetLocked() { + generation += 1 + mutationEpoch += 1 + hasHydrated = false + _state.value = TitleArtState() + confirmed = TitleArtState() + confirmedAuthority = null + confirmedIdentity = null + pendingWrites = 0 + refreshAfterWrites = false + // A load still running for the previous identity bails on the + // generation check; the next caller starts a fresh one. + inFlightLoad = null + // The previous identity's write may still be on the wire; it keeps its + // own lock and its completion bails on the generation check. + writeLock = Mutex() + _saveError.value = null + } + + internal companion object { + const val PREFS_NAME = "silo_title_art" + const val IDENTITY_CHANGED = "The acting account or profile changed." + + /** Same wording as the skip-interval save error. */ + fun saveErrorText(message: String?): String = + "Couldn't save title art" + (message?.takeIf { it.isNotBlank() }?.let { ": $it" } ?: ".") + + /** The cache key for this snapshot's server and profile; null without a profile. */ + private fun AuthScopeSnapshot.cacheIdentity(): String? = + profileId?.takeIf { it.isNotBlank() }?.let { "$serverUrl|$it" } + + /** + * Same server and profile, and the same sign-in when both are the + * saved account. Not [AuthScopeSnapshot.isSameIdentityAs]: its + * identity generation also moves when a remote-playback overlay starts + * or ends, which leaves the profile a toggle addresses unchanged. An + * overlay's snapshot carries no credential epoch (it is always 0), so + * the epoch is compared only between two saved-account snapshots, and + * the server ids are matched the way the overlay handoff matches them. + * A real profile or server switch resets the store before the write + * runs. + */ + private fun AuthScopeSnapshot.isSameProfileAs(other: AuthScopeSnapshot): Boolean { + if (profileId != other.profileId) return false + if (!AndroidServerRegistry.serverIdsMatch(serverId, other.serverId)) return false + val bothSavedAccount = credentialGenerationId == null && other.credentialGenerationId == null + return !bothSavedAccount || credentialEpoch == other.credentialEpoch + } + + /** Test seam: no Android Context, in-memory cache. */ + internal fun forTest( + controller: TitleArtController, + scope: CoroutineScope, + getActiveProfileId: suspend () -> String? = { "profile-1" }, + getServerUrl: suspend () -> String? = { "https://server.test" }, + getAuthScope: (suspend () -> AuthScopeSnapshot?)? = null, + cache: TitleArtCache = InMemoryTitleArtCache(), + identityChanges: Flow = emptyFlow(), + ): DefaultTitleArtStore = DefaultTitleArtStore( + controller = controller, + scope = scope, + getAuthScope = getAuthScope ?: { + val url = getServerUrl().orEmpty() + AuthScopeSnapshot( + serverId = url, + profileId = getActiveProfileId(), + serverUrl = url, + profileToken = "token-${getActiveProfileId()}", + ) + }, + cache = cache, + identityChanges = identityChanges, + ) + } +} + +/** + * Last-known answer per server and profile, so a cold start paints the + * profile's choice (the TV marquee is on screen immediately) instead of + * flashing logos until the network answers. + */ +internal interface TitleArtCache { + fun read(identity: String): TitleArtState? + fun write(identity: String, state: TitleArtState) +} + +internal class InMemoryTitleArtCache : TitleArtCache { + private val entries = mutableMapOf() + override fun read(identity: String): TitleArtState? = synchronized(entries) { entries[identity] } + override fun write(identity: String, state: TitleArtState) { + if (state.support != TitleArtSupport.Supported && state.support != TitleArtSupport.Unsupported) return + synchronized(entries) { entries[identity] = state.copy(isConfirmed = false) } + } +} + +private class SharedPreferencesTitleArtCache( + private val prefs: SharedPreferences, +) : TitleArtCache { + + override fun read(identity: String): TitleArtState? { + val prefix = prefix(identity) + val support = when (prefs.getString("$prefix.support", null)) { + SUPPORTED -> TitleArtSupport.Supported + UNSUPPORTED -> TitleArtSupport.Unsupported + else -> return null + } + return TitleArtState( + support = support, + preference = TitleArtPreference( + showTitleArt = prefs.getBoolean("$prefix.show", TitleArtController.DEFAULT_SHOW_TITLE_ART), + appliesToAllDevices = prefs.getBoolean("$prefix.all_devices", false), + ), + ) + } + + override fun write(identity: String, state: TitleArtState) { + val support = when (state.support) { + TitleArtSupport.Supported -> SUPPORTED + TitleArtSupport.Unsupported -> UNSUPPORTED + TitleArtSupport.Unknown, TitleArtSupport.Unavailable -> return + } + val prefix = prefix(identity) + prefs.edit { + putString("$prefix.support", support) + putBoolean("$prefix.show", state.preference.showTitleArt) + putBoolean("$prefix.all_devices", state.preference.appliesToAllDevices) + } + } + + private fun prefix(identity: String): String = settingsCachePrefix("ta_", identity) + + private companion object { + const val SUPPORTED = "supported" + const val UNSUPPORTED = "unsupported" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/startup/StartupWarmup.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/startup/StartupWarmup.kt index 9bf9190bbf..54a37e5509 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/startup/StartupWarmup.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/startup/StartupWarmup.kt @@ -45,6 +45,8 @@ data class StartupArtworkPlan( * immediately. */ val warmFirstRowHeroArt: Boolean = false, + /** With [warmFirstRowHeroArt]: also warm the first item's logo. */ + val warmFirstRowLogo: Boolean = warmFirstRowHeroArt, ) { companion object { /** @@ -90,6 +92,12 @@ suspend fun warmAuthenticatedStartup( identityTransitions: IdentityTransitionBarrier, serverUrl: String?, artworkPlan: StartupArtworkPlan, + /** + * Whether Home will draw title logos (`ui.title_art`). Asked only when the + * artwork is warmed, after Home has loaded; false skips the logo fetch. + * Must not suspend on the network: it gates every first-row warm. + */ + showTitleArt: () -> Boolean = { true }, ) { val homeGeneration = identityTransitions.generation.value val homeOwner = sectionRepository.captureHomeAuthority() @@ -117,7 +125,14 @@ suspend fun warmAuthenticatedStartup( if (homeOwner != null) warmStartupHomeSections( sectionRepository, homeCache, homeOwner, stillCurrent = { homeGeneration == identityTransitions.generation.value }, - ) { sections, mayWarm -> warmHomeArtwork(context, sections, artworkPlan, mayWarm) } + ) { sections, mayWarm -> + val plan = if (artworkPlan.warmFirstRowHeroArt && !showTitleArt()) { + artworkPlan.copy(warmFirstRowLogo = false) + } else { + artworkPlan + } + warmHomeArtwork(context, sections, plan, mayWarm) + } } Unit }, @@ -197,7 +212,7 @@ private suspend fun warmHomeArtwork( val contentSections = sections.filter { it.items.isNotEmpty() } contentSections.firstOrNull()?.let { firstRow -> val episodeRow = firstRow.rendersEpisodeStills() - if (plan.warmFirstRowHeroArt) { + if (plan.warmFirstRowHeroArt && plan.warmFirstRowLogo) { append(firstRow.items.firstOrNull()?.logoUrl, plan.logoWidthPx, plan.logoHeightPx) } for (item in firstRow.items) { diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/TitleArtStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/TitleArtStoreTest.kt new file mode 100644 index 0000000000..ba8597f5e0 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/TitleArtStoreTest.kt @@ -0,0 +1,673 @@ +package org.siloserver.silo.common.settings + +import org.siloserver.silo.domain.settings.TitleArtController +import org.siloserver.silo.domain.settings.TitleArtPreference +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.EffectiveSettingValuesResponse +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.model.settings.SettingScope +import org.siloserver.silo.model.settings.SettingScopeIdentity +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.model.settings.StoredSettingValue +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.api.SettingsApi +import org.siloserver.silo.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TitleArtStoreTest { + + @Test + fun `before any answer title surfaces keep logos`() { + assertTrue(TitleArtState().showTitleArt) + assertTrue(TitleArtState(TitleArtSupport.Unavailable).showTitleArt) + // An older server's answer never turns logos off, whatever is stored. + assertTrue( + TitleArtState(TitleArtSupport.Unsupported, TitleArtPreference(showTitleArt = false)).showTitleArt, + ) + } + + @Test + fun `refresh adopts the device's effective value and source`() = runTest { + val server = FakeTitleArtServer(profileValue = false) + val store = storeFor(server) + + store.refresh() + + assertEquals( + TitleArtState( + TitleArtSupport.Supported, + TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), + isConfirmed = true, + ), + store.state.value, + ) + assertFalse(store.state.value.showTitleArt) + } + + @Test + fun `an older server hides the setting and is never written`() = runTest { + val server = FakeTitleArtServer( + capabilities = ApiResult.Success(SUPPORTED.copy(manifestRevision = 15)), + ) + val store = storeFor(server) + + store.refresh() + store.setShowTitleArt(false) + store.setAppliesToAllDevices(true) + runCurrent() + + assertEquals(TitleArtSupport.Unsupported, store.state.value.support) + assertFalse(store.state.value.isSupported) + assertTrue(store.state.value.showTitleArt) + assertTrue(server.calls.isEmpty()) + } + + @Test + fun `the main switch writes this device and shows the change at once`() = runTest { + val server = FakeTitleArtServer() + server.putGate = CompletableDeferred() + val store = storeFor(server) + store.refresh() + + store.setShowTitleArt(false) + // Optimistic: surfaces switch to text before the write lands. + assertFalse(store.state.value.showTitleArt) + + server.putGate!!.complete(Unit) + runCurrent() + + assertEquals(listOf("put profile_device false"), server.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + } + + @Test + fun `while applied to all devices the main switch writes the profile`() = runTest { + val server = FakeTitleArtServer(profileValue = true) + val store = storeFor(server) + store.refresh() + + store.setShowTitleArt(false) + runCurrent() + + assertEquals(listOf("put profile false"), server.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), store.state.value.preference) + } + + @Test + fun `apply to all devices carries the value on screen both ways`() = runTest { + val server = FakeTitleArtServer(deviceValue = false) + val store = storeFor(server) + store.refresh() + + store.setAppliesToAllDevices(true) + runCurrent() + assertEquals(listOf("put profile false"), server.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), store.state.value.preference) + + store.setAppliesToAllDevices(false) + runCurrent() + assertEquals( + listOf("put profile false", "put profile_device false", "delete profile"), + server.calls, + ) + assertNull(server.profileValue) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + } + + @Test + fun `a failed write restores the last confirmed state`() = runTest { + val server = FakeTitleArtServer() + val store = storeFor(server) + store.refresh() + server.failPuts = true + + store.setShowTitleArt(false) + runCurrent() + + assertTrue(store.state.value.showTitleArt) + // Settings shows the failure under the switches until a save succeeds. + assertEquals("Couldn't save title art: Server error", store.saveError.value) + + server.failPuts = false + store.setShowTitleArt(false) + runCurrent() + assertFalse(store.state.value.showTitleArt) + assertNull(store.saveError.value) + } + + @Test + fun `the cached answer paints before the network does`() = runTest { + val cache = InMemoryTitleArtCache() + storeFor(FakeTitleArtServer(deviceValue = false), cache).refresh() + + val offline = FakeTitleArtServer(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val coldStart = storeFor(offline, cache) + coldStart.hydrateIfNeeded() + + assertEquals(TitleArtSupport.Supported, coldStart.state.value.support) + assertFalse(coldStart.state.value.showTitleArt) + } + + @Test + fun `a refresh during a pending write cannot reroute the next toggle`() = runTest { + // Title art on, this device only. + val server = FakeTitleArtServer(deviceValue = true) + val store = storeFor(server) + store.refresh() + + // Apply to all devices; hold its PUT profile=true in flight. + server.putGate = CompletableDeferred() + store.setAppliesToAllDevices(true) + runCurrent() + assertEquals(listOf("put profile true"), server.calls) + + // Settings reopens / the app foregrounds: the server still answers + // with the pre-write value (device scope, not all devices). + store.refresh() + assertTrue(store.state.value.appliesToAllDevices) + + // Turning title art off must address the profile value, not this device. + store.setShowTitleArt(false) + server.putGate!!.complete(Unit) + runCurrent() + + assertEquals(listOf("put profile true", "put profile false"), server.calls) + assertEquals(false, server.profileValue) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true), store.state.value.preference) + } + + @Test + fun `a stalled hydration never holds back the next server`() = runTest { + var server = "https://a.test" + val stalled = CompletableDeferred() + val api = FakeTitleArtServer() + api.capabilitiesFor = { if (server == "https://a.test") stalled.await() } + val cache = InMemoryTitleArtCache().apply { + write("https://a.test|profile-1", TitleArtState(TitleArtSupport.Supported, TitleArtPreference(showTitleArt = true))) + } + val identities = MutableSharedFlow(extraBufferCapacity = 8) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(api)), + scope = backgroundScope, + getServerUrl = { server }, + cache = cache, + identityChanges = identities, + ) + runCurrent() + + // Server A: the identity collector and another caller both hydrate + // against a capabilities request that never answers. + identities.tryEmit(Unit) + backgroundScope.launch { store.refresh() } + runCurrent() + assertEquals(TitleArtSupport.Supported, store.state.value.support) + assertTrue(store.state.value.showTitleArt) + + // Switch to server B, where this device has title art off. + server = "https://b.test" + api.deviceValue = false + identities.tryEmit(Unit) + runCurrent() + + assertEquals(TitleArtSupport.Supported, store.state.value.support) + assertFalse(store.state.value.showTitleArt) + + // A's answer arriving late must not repaint B. + api.deviceValue = true + stalled.complete(Unit) + runCurrent() + assertFalse(store.state.value.showTitleArt) + } + + @Test + fun `a write stalled on the previous server never holds back the next server's save`() = runTest { + var server = "https://a.test" + val api = FakeTitleArtServer() + val identities = MutableSharedFlow(extraBufferCapacity = 8) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(api)), + scope = backgroundScope, + getServerUrl = { server }, + identityChanges = identities, + ) + runCurrent() + store.refresh() + + // Server A: apply to all devices, with its PUT held on the wire. + val heldOnA = CompletableDeferred() + api.putGate = heldOnA + store.setAppliesToAllDevices(true) + runCurrent() + assertEquals(listOf("put profile true"), api.calls) + + // Switch to server B and let it hydrate. + api.putGate = null + server = "https://b.test" + identities.tryEmit(Unit) + runCurrent() + assertTrue(store.state.value.isSupported) + + // B's change goes out while A's write is still held. + store.setShowTitleArt(false) + runCurrent() + assertEquals(listOf("put profile true", "put profile_device false"), api.calls) + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + + // A's write finishing late leaves B's state alone. + heldOnA.complete(Unit) + runCurrent() + assertEquals(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false), store.state.value.preference) + assertNull(store.saveError.value) + } + + @Test + fun `one title art change stays on the profile it started on`() = runTest { + var activeProfile = "profile-1" + val server = FakeTitleArtServer(profileValue = true) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getActiveProfileId = { activeProfile }, + ) + store.refresh() + server.authorities.clear() + // The profile switches while the device PUT is on the wire. + server.afterPut = { activeProfile = "profile-2" } + + store.setAppliesToAllDevices(false) + runCurrent() + + assertEquals(listOf("put profile_device true", "delete profile"), server.calls) + assertEquals( + listOf>( + "put" to "profile-1", + "delete" to "profile-1", + "effective" to "profile-1", + ), + server.authorities, + ) + } + + @Test + fun `a change queued behind a failed write is dropped, not sent at the wrong scope`() = runTest { + // Title art on, this device only. + val server = FakeTitleArtServer(deviceValue = true) + val store = storeFor(server) + store.refresh() + + // Apply to all devices (held in flight), then turn title art off + // while the first write is still pending. + server.putGate = CompletableDeferred() + store.setAppliesToAllDevices(true) + store.setShowTitleArt(false) + runCurrent() + + // The first PUT fails. + server.failPuts = true + server.putGate!!.complete(Unit) + runCurrent() + + // The queued change was built on the failed one's unsaved state; it + // must not create a profile-wide value. + assertEquals(listOf("put profile true"), server.calls) + assertNull(server.profileValue) + assertEquals(TitleArtPreference(showTitleArt = true, appliesToAllDevices = false), store.state.value.preference) + assertEquals("Couldn't save title art: Server error", store.saveError.value) + + // A new change after the failure goes through normally. + server.failPuts = false + store.setShowTitleArt(false) + runCurrent() + assertEquals(listOf("put profile true", "put profile_device false"), server.calls) + assertFalse(store.state.value.showTitleArt) + } + + @Test + fun `a reset during the identity read never pairs the old profile with the new generation`() = runTest { + var activeProfile = "profile-a" + val identityRead = CompletableDeferred() + var holdNextIdentityRead = true + val cache = InMemoryTitleArtCache().apply { + // Profile A had title art off. + write( + "https://server.test|profile-a", + TitleArtState(TitleArtSupport.Supported, TitleArtPreference(showTitleArt = false)), + ) + } + val server = FakeTitleArtServer(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getActiveProfileId = { + val profile = activeProfile + if (holdNextIdentityRead) { + holdNextIdentityRead = false + identityRead.await() + } + profile + }, + cache = cache, + ) + + // A refresh reads profile A's identity and stalls there. + backgroundScope.launch { store.refresh() } + runCurrent() + + // The profile switches to B (which has nothing cached) before it returns. + activeProfile = "profile-b" + store.clear() + identityRead.complete(Unit) + runCurrent() + + // Profile A's cached "off" must not be painted for profile B. + assertTrue(store.state.value.showTitleArt) + assertFalse(store.state.value.isSupported) + } + + @Test + fun `a cached answer is shown but not written until the server re-reads it`() = runTest { + // Cached: this device only. Since then another device applied "off" to all devices. + val cache = InMemoryTitleArtCache().apply { + write("https://server.test|profile-1", TitleArtState(TitleArtSupport.Supported, TitleArtPreference())) + } + val server = FakeTitleArtServer(profileValue = false) + val held = CompletableDeferred() + server.capabilitiesFor = { held.await() } + val store = storeFor(server, cache) + backgroundScope.launch { store.hydrateIfNeeded() } + runCurrent() + assertTrue(store.state.value.isSupported) + assertFalse(store.state.value.canEdit) + + // A toggle before the read lands would pick profile_device from the stale cache. + store.setShowTitleArt(false) + runCurrent() + assertTrue(server.calls.isEmpty()) + assertNull(store.saveError.value) + + held.complete(Unit) + runCurrent() + assertTrue(store.state.value.canEdit) + store.setShowTitleArt(true) + runCurrent() + assertEquals(listOf("put profile true"), server.calls) + } + + @Test + fun `a remote playback overlay on the same profile does not block a change`() = runTest { + // Shaped like the token manager's snapshots: the saved account carries + // a credential epoch, an overlay carries a generation id and epoch 0. + val saved = AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "profile-1", + serverUrl = "https://server.test", + profileToken = "token-profile-1", + identityGeneration = 1L, + isIdentityGenerationStamped = true, + credentialEpoch = 3L, + ) + val overlay = saved.copy( + profileToken = null, + credentialGenerationId = "overlay-1", + identityGeneration = 2L, + credentialEpoch = 0L, + ) + var current = saved + val server = FakeTitleArtServer() + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getAuthScope = { current }, + ) + store.refresh() + + // A cast session for the same profile begins after the read. + current = overlay + store.setShowTitleArt(false) + runCurrent() + // It ends again before the next change. + current = saved.copy(identityGeneration = 3L) + store.setShowTitleArt(true) + runCurrent() + + assertEquals(listOf("put profile_device false", "put profile_device true"), server.calls) + assertTrue(store.state.value.showTitleArt) + assertNull(store.saveError.value) + } + + @Test + fun `a change read before the overlay ends still saves after it`() = runTest { + val saved = AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "profile-1", + serverUrl = "https://server.test", + profileToken = "token-profile-1", + identityGeneration = 2L, + isIdentityGenerationStamped = true, + credentialEpoch = 3L, + ) + var current = saved.copy( + profileToken = null, + credentialGenerationId = "overlay-1", + credentialEpoch = 0L, + ) + val server = FakeTitleArtServer() + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getAuthScope = { current }, + ) + // The read lands while the overlay is active. + store.refresh() + + current = saved.copy(identityGeneration = 3L) + store.setShowTitleArt(false) + runCurrent() + + assertEquals(listOf("put profile_device false"), server.calls) + assertNull(store.saveError.value) + } + + @Test + fun `a new sign-in on the same profile blocks a change read before it`() = runTest { + val before = AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "profile-1", + serverUrl = "https://server.test", + profileToken = "token-profile-1", + identityGeneration = 1L, + isIdentityGenerationStamped = true, + credentialEpoch = 3L, + ) + var current = before + val server = FakeTitleArtServer() + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + getAuthScope = { current }, + ) + store.refresh() + + current = before.copy(identityGeneration = 3L, credentialEpoch = 5L) + store.setShowTitleArt(false) + runCurrent() + + assertTrue(server.calls.isEmpty()) + assertEquals( + DefaultTitleArtStore.saveErrorText(DefaultTitleArtStore.IDENTITY_CHANGED), + store.saveError.value, + ) + } + + @Test + fun `the answer is cached for the profile the load was pinned to`() = runTest { + val cache = InMemoryTitleArtCache() + val server = FakeTitleArtServer(deviceValue = false) + val store = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + // The active scope is a cast overlay for another profile than the + // saved account's profile-1. + getAuthScope = { + AuthScopeSnapshot( + serverId = "https://server.test", + profileId = "phone-profile", + serverUrl = "https://server.test", + profileToken = null, + credentialGenerationId = "overlay-1", + ) + }, + cache = cache, + ) + store.refresh() + + assertEquals(listOf>("effective" to "phone-profile"), server.authorities) + assertNull(cache.read("https://server.test|profile-1")) + assertFalse(cache.read("https://server.test|phone-profile")!!.preference.showTitleArt) + } + + @Test + fun `concurrent startup loads share one round trip`() = runTest { + val server = FakeTitleArtServer() + val held = CompletableDeferred() + server.capabilitiesFor = { held.await() } + val store = storeFor(server) + + repeat(3) { backgroundScope.launch { store.hydrateIfNeeded() } } + backgroundScope.launch { store.refresh() } + runCurrent() + held.complete(Unit) + runCurrent() + + assertEquals(1, server.capabilityRequests) + assertTrue(store.state.value.canEdit) + } + + @Test + fun `clear drops the previous profile's answer`() = runTest { + val store = storeFor(FakeTitleArtServer(deviceValue = false)) + store.refresh() + + store.clear() + + assertEquals(TitleArtState(), store.state.value) + } + + private fun TestScope.storeFor( + server: FakeTitleArtServer, + cache: TitleArtCache = InMemoryTitleArtCache(), + ) = DefaultTitleArtStore.forTest( + controller = TitleArtController(SettingsRepository(server)), + scope = backgroundScope, + cache = cache, + ) + + private companion object { + val SUPPORTED = SettingsContractCapabilities( + apiVersion = 1, + manifestRevision = 16, + supportsBatchedEffective = true, + ) + } + + /** Stores `ui.title_art` per scope and resolves profile → profile_device → default. */ + private class FakeTitleArtServer( + var capabilities: ApiResult = ApiResult.Success(SUPPORTED), + var profileValue: Boolean? = null, + var deviceValue: Boolean? = null, + ) : SettingsApi( + org.siloserver.silo.network.apiv2.SettingsV2Api( + HttpClient(), + org.siloserver.silo.network.TokenManagerImpl(), + org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted, + ), + ) { + val calls = mutableListOf() + var failPuts = false + var putGate: CompletableDeferred? = null + + /** The profile each request was pinned to, in order. */ + val authorities = mutableListOf>() + var afterPut: () -> Unit = {} + + /** Runs before each capabilities answer; a test can suspend it. */ + var capabilitiesFor: suspend () -> Unit = {} + var capabilityRequests = 0 + + override suspend fun getContractCapabilities(): ApiResult { + capabilityRequests += 1 + capabilitiesFor() + return capabilities + } + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult { + authorities += "effective" to authority?.profileId + val (value, source) = profileValue?.let { it to "profile" } + ?: deviceValue?.let { it to "profile_device" } + ?: (true to EffectiveSettingValue.SOURCE_DEFAULT) + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue(SettingKeys.UI_TITLE_ART, JsonPrimitive(value), source), + ), + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + profileId: String?, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult { + val bool = (value as JsonPrimitive).content.toBooleanStrict() + calls += "put ${scope.scope.wire} $bool" + authorities += "put" to authority?.profileId + putGate?.await() + afterPut() + if (failPuts) return ApiResult.Error(500, "boom", "Server error") + when (scope.scope) { + SettingScope.PROFILE -> profileValue = bool + SettingScope.PROFILE_DEVICE -> deviceValue = bool + else -> Unit + } + return ApiResult.Success(StoredSettingValue(key = key, scope = scope.scope.wire)) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult { + calls += "delete ${scope.scope.wire}" + authorities += "delete" to authority?.profileId + if (scope.scope == SettingScope.PROFILE) { + val existed = profileValue != null + profileValue = null + if (!existed) return ApiResult.Error(404, "not_found", "Not Found") + } + return ApiResult.Success(Unit) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 4ae580dccb..1f11dcd3d9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -99,6 +99,8 @@ import org.siloserver.silo.android.ui.screens.settings.diagnostics.DiagnosticsSe import org.siloserver.silo.android.ui.screens.settings.diagnostics.DiagnosticsViewModel import org.siloserver.silo.cast.SiloCastPlaybackRequest import org.siloserver.silo.common.cards.ProvideCardPresentation +import org.siloserver.silo.common.settings.ProvideTitleArt +import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.common.overlays.ProvideCardOverlays import org.siloserver.silo.common.player.video.VideoPlayerRouteArgs import org.siloserver.silo.common.settings.CardPresentationStore @@ -152,6 +154,7 @@ fun AppNavigation( val activeProfileStore: ActiveProfileStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore = koinInject() + val titleArtStore: TitleArtStore = koinInject() val siloCastController: SiloCastController = koinInject() // Lives as long as the nav host, so work started from a destination that is // popped in the same gesture (re-hydrating after a profile switch) is not @@ -305,6 +308,7 @@ fun AppNavigation( ProvideCardOverlays(store = overlayPrefsStore, sessionKey = overlaySessionKey) { ProvideCardPresentation(store = cardPresentationStore, sessionKey = overlaySessionKey) { + ProvideTitleArt(store = titleArtStore, sessionKey = overlaySessionKey) { // Shared-element host: lets a tapped poster morph into the item-detail // backdrop. The scope is published via CompositionLocal so deep descendants // (a poster card, the detail hero) can opt in without threading it through @@ -617,6 +621,7 @@ fun AppNavigation( overlayPrefsStore.hydrateIfNeeded() cardPresentationStore.hydrateIfNeeded() seekIntervalStore.hydrateIfNeeded() + titleArtStore.hydrateIfNeeded() } // Route through the tour gate: OnboardingTourScreen checks // server-side state and immediately hands off to Home when @@ -744,6 +749,7 @@ fun AppNavigation( activeProfileStore.reset() cardPresentationStore.clear() seekIntervalStore.clear() + titleArtStore.clear() }, onNavigateToWatchlist = { navController.navigate(Route.Watchlist.route) }, onNavigateToFavorites = { navController.navigate(Route.Favorites.route) }, @@ -1371,6 +1377,7 @@ fun AppNavigation( } } } + } } /** diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt index 7179c9ff88..35eada2685 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt @@ -161,6 +161,7 @@ fun MainScreen( val activeProfileStore: ActiveProfileStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore = koinInject() + val titleArtStore: org.siloserver.silo.common.settings.TitleArtStore = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() val reachabilityScope = rememberCoroutineScope() @@ -290,6 +291,7 @@ fun MainScreen( activeProfileStore.reset() cardPresentationStore.clear() seekIntervalStore.clear() + titleArtStore.clear() navController.navigate(Route.Login.route) { popUpTo(0) { inclusive = true } launchSingleTop = true @@ -311,6 +313,7 @@ fun MainScreen( activeProfileStore.reset() cardPresentationStore.clear() seekIntervalStore.clear() + titleArtStore.clear() } val requestsMenuAction: (() -> Unit)? = if (requestsEnabled) { { navController.navigate(Route.Requests.route) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt index d7145f9c56..b6a6da327d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt @@ -82,6 +82,7 @@ import org.siloserver.silo.android.ui.theme.SiloOpaqueControlBorder import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated import org.siloserver.silo.android.ui.theme.PillShape +import org.siloserver.silo.common.settings.titleLogoUrl import org.siloserver.silo.common.ui.RatingEntry import org.siloserver.silo.common.ui.WholeTokenRow import org.siloserver.silo.common.ui.components.ThumbhashImage @@ -439,7 +440,7 @@ private fun ExpandedHeroTitle(detail: ItemDetail) { return } - val logoUrl = detail.logoUrl + val logoUrl = titleLogoUrl(detail.logoUrl) if (!logoUrl.isNullOrBlank()) { ThumbhashImage( url = logoUrl, @@ -823,7 +824,7 @@ private fun HeroTitle(detail: ItemDetail) { return } - val logoUrl = detail.logoUrl + val logoUrl = titleLogoUrl(detail.logoUrl) if (!logoUrl.isNullOrBlank()) { // iOS hero logo height — 160pt on compact phones, full width. ThumbhashImage( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index 3cbf441eeb..e52509093c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -73,6 +73,7 @@ import org.siloserver.silo.android.ui.util.formatBytes import org.siloserver.silo.model.download.DownloadQuality import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.metadata.MetadataAiOnView @@ -117,6 +118,11 @@ fun SettingsScreen( val diagnosticsState by diagnosticsViewModel.state.collectAsState() var showRemoveAllDownloadsConfirm by remember { mutableStateOf(false) } var showHomeSectionsEditor by remember { mutableStateOf(false) } + val titleArtStore: TitleArtStore = koinInject() + // Opening Settings is a refresh edge, so a title art choice made on + // another device shows here without waiting for the next foreground. Kept + // out of the LazyColumn item, which re-enters composition on scroll. + LaunchedEffect(titleArtStore) { titleArtStore.refresh() } LaunchedEffect(state.loggedOut) { if (state.loggedOut) { @@ -312,6 +318,7 @@ fun SettingsScreen( description = "Choose which Home rows are visible and the order they appear in.", onClick = { showHomeSectionsEditor = true }, ) + TitleArtSettingsRows(store = titleArtStore) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt new file mode 100644 index 0000000000..35dc484205 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt @@ -0,0 +1,55 @@ +package org.siloserver.silo.android.ui.screens.settings + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalConfiguration +import org.siloserver.silo.common.settings.TitleArtState +import org.siloserver.silo.common.settings.TitleArtStore + +/** + * "Show title art" and its "Apply to all devices" companion, as rows of the + * Interface card. Renders nothing until the server has confirmed the key + * (settings revision 16); older servers keep logos on, as before. The switches + * stay disabled until this session's read lands. [SettingsScreen] refreshes + * the store when it opens. + */ +@Composable +internal fun ColumnScope.TitleArtSettingsRows(store: TitleArtStore) { + val state by store.state.collectAsState() + val saveError by store.saveError.collectAsState() + if (!state.isSupported) return + + val device = if (LocalConfiguration.current.smallestScreenWidthDp >= 600) "tablet" else "phone" + SettingsSwitchRow( + label = "Show title art", + description = "Use logo artwork as the title when available.", + checked = state.showTitleArt, + onCheckedChange = store::setShowTitleArt, + enabled = state.canEdit, + ) + SettingsSwitchRow( + label = "Apply to all devices", + description = titleArtScopeDescription(state, device), + checked = state.appliesToAllDevices, + onCheckedChange = store::setAppliesToAllDevices, + enabled = state.canEdit, + ) + if (state.appliesToAllDevices) { + SettingsProse(body = titleArtAllDevicesNote(state, device)) + } + saveError?.let { SettingsProse(body = it) } +} + +internal fun titleArtScopeDescription(state: TitleArtState, device: String): String = + if (state.appliesToAllDevices) { + "On: every device on this profile uses this choice." + } else { + "Off: only affects this $device. Your other devices keep their own setting." + } + +internal fun titleArtAllDevicesNote(state: TitleArtState, device: String): String = + "Title art is ${if (state.showTitleArt) "on" else "off"} on every device signed into " + + "this profile. Changing it here changes it everywhere. Turn off “Apply to all " + + "devices” to choose for this $device only." diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt index 04d3942e3b..29ba028dab 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt @@ -33,6 +33,7 @@ import org.siloserver.silo.common.diagnostics.DiagnosticsLifecycleLogger import org.siloserver.silo.common.network.ServerReachabilityMonitor import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.common.settings.ServerDrivenConfigRefresher +import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.common.startup.StartupArtworkPlan import org.siloserver.silo.common.startup.warmAuthenticatedStartup import org.siloserver.silo.common.startup.warmProfileSelectionStartup @@ -378,6 +379,9 @@ class MainTvActivity : ComponentActivity() { ), serverUrl = get(ServerRegistry::class.java).activeEntry.value?.url, artworkPlan = StartupArtworkPlan.tv(), + // What Home will draw right now (cached answer, else logos on); + // never waits on the title art request. + showTitleArt = { get(TitleArtStore::class.java).state.value.showTitleArt }, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt index 3f9743e0e0..d028977e29 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.Text +import org.siloserver.silo.common.settings.titleLogoUrl import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.tv.ui.theme.SiloOnSurface import org.siloserver.silo.tv.ui.theme.SiloSecondaryText @@ -124,7 +125,9 @@ private fun TvMarqueeBlock( // lines the synopsis drops to one, keeping the bottom-anchored block's // height bounded so it never climbs into the top-menu-bar zone. var titleLineCount by remember(content.id) { mutableStateOf(1) } - var logoLoaded by remember(content.logoUrl) { mutableStateOf(false) } + // "Show title art" off: the marquee always names the title in text. + val logoUrl = titleLogoUrl(content.logoUrl) + var logoLoaded by remember(logoUrl) { mutableStateOf(false) } val logoAlpha by animateFloatAsState( targetValue = if (logoLoaded) 1f else 0f, animationSpec = tween(TvMarqueeCrossfadeMs, easing = TvMarqueeEasing), @@ -140,7 +143,7 @@ private fun TvMarqueeBlock( // Keep the semantic text title visible until transparent logo artwork // has actually decoded. A bad/slow URL therefore never creates a blank // title slot; successful artwork fades over the fixed-height fallback. - if (!content.logoUrl.isNullOrBlank()) { + if (!logoUrl.isNullOrBlank()) { Box( modifier = Modifier .height(MarqueeLogoMaxHeight) @@ -161,7 +164,7 @@ private fun TvMarqueeBlock( ) } ThumbhashImage( - url = content.logoUrl, + url = logoUrl, thumbhash = null, contentDescription = content.title, contentScale = ContentScale.Fit, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt index c9bc9b8be2..9481cd0eda 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt @@ -47,6 +47,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.MaterialTheme import coil3.SingletonImageLoader import coil3.request.ImageRequest +import org.siloserver.silo.common.settings.LocalShowTitleArt import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.section.ResolvedSection import org.siloserver.silo.tv.ui.focus.TvReturnResolution @@ -428,7 +429,8 @@ fun TvSkylineSectionFeed( ?.let { row -> focusedContentId?.let { contentId -> "${row.id}#$contentId" } }, settledMarqueeId = marquee.content?.id, ) - LaunchedEffect(rows, settledFocus, fetchDetail) { + val prefetchLogos = LocalShowTitleArt.current + LaunchedEffect(rows, settledFocus, fetchDetail, prefetchLogos) { val focus = settledFocus ?: return@LaunchedEffect val row = rows.getOrNull(focus.rowIndex) ?: return@LaunchedEffect val window = settledPrefetchItems( @@ -453,7 +455,8 @@ fun TvSkylineSectionFeed( .build(), ) } - item.logoUrl?.takeIf { it.isNotBlank() }?.let { url -> + // No logo is drawn with title art off; don't fetch one. + item.logoUrl?.takeIf { prefetchLogos && it.isNotBlank() }?.let { url -> add( ImageRequest.Builder(context) .data(url) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index d2c2fd1411..69f2b7d921 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -58,6 +58,8 @@ import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.watchtogether.WatchTogetherEntryTarget import org.siloserver.silo.watchtogether.watchTogetherEntryTarget import org.siloserver.silo.common.cards.ProvideCardPresentation +import org.siloserver.silo.common.settings.ProvideTitleArt +import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.common.overlays.ProvideCardOverlays import org.siloserver.silo.common.diagnostics.DiagnosticsLifecycleLogger import org.siloserver.silo.common.settings.CardPresentationStore @@ -350,6 +352,7 @@ fun TvAppNavigation( val overlayPrefsStore: OverlayPrefsStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore = koinInject() + val titleArtStore: TitleArtStore = koinInject() val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore = koinInject() val watchNextSeeder: WatchNextSeeder = koinInject() val siloCastReceiver: TvSiloCastReceiver = koinInject() @@ -564,6 +567,7 @@ fun TvAppNavigation( ProvideCardOverlays(store = overlayPrefsStore, sessionKey = overlaySessionKey) { ProvideCardPresentation(store = cardPresentationStore, sessionKey = overlaySessionKey) { + ProvideTitleArt(store = titleArtStore, sessionKey = overlaySessionKey) { Box(modifier = Modifier.fillMaxSize()) { NavHost( navController = navController, @@ -717,7 +721,10 @@ fun TvAppNavigation( // The switch-profile paths cleared the seek intervals; the // identity flow only reloads when the profile id changes, so // re-selecting the same profile needs this. - scope.launch { seekIntervalStore.hydrateIfNeeded() } + scope.launch { + seekIntervalStore.hydrateIfNeeded() + titleArtStore.hydrateIfNeeded() + } }, onAddProfile = { navController.navigate(TvRoute.CreateProfile.route) { launchSingleTop = true } @@ -829,6 +836,7 @@ fun TvAppNavigation( overlayPrefsStore.clear() cardPresentationStore.clear() seekIntervalStore.clear() + titleArtStore.clear() // Drop our Watch Next rows + cancel the periodic refresh so // the launcher doesn't keep showing the signed-out user's // progress. @@ -856,6 +864,7 @@ fun TvAppNavigation( overlayPrefsStore.clear() cardPresentationStore.clear() seekIntervalStore.clear() + titleArtStore.clear() // Clear the previous profile's Watch Next rows before // landing on the picker; the new profile will re-seed // via [onProfileSelected]. @@ -1393,4 +1402,5 @@ fun TvAppNavigation( } } } + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt index 61a45486b3..20df8e6dfe 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt @@ -43,6 +43,7 @@ import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage +import org.siloserver.silo.common.settings.titleLogoUrl import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.tv.R import org.siloserver.silo.tv.ui.components.TvFactsRow @@ -319,15 +320,17 @@ private fun TitleBlock( logoUrl: String?, ) { val seriesContext = seriesTitle?.trim()?.takeIf { it.isNotEmpty() } + // "Show title art" off: every title here is text, episodes included. + val shownLogoUrl = titleLogoUrl(logoUrl) when { seriesContext != null -> EpisodeHierarchyTitle( seriesTitle = seriesContext, episodeTitle = title, - logoUrl = logoUrl, + logoUrl = shownLogoUrl, ) - !logoUrl.isNullOrBlank() -> AsyncImage( - model = logoUrl, + !shownLogoUrl.isNullOrBlank() -> AsyncImage( + model = shownLogoUrl, contentDescription = title, contentScale = ContentScale.Fit, alignment = Alignment.BottomStart, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 27d79aeeec..d962ff3903 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -97,6 +97,7 @@ import androidx.tv.material3.Text import org.siloserver.silo.common.network.clientVersionLabel import org.siloserver.silo.common.settings.CardPresentationSource import org.siloserver.silo.common.settings.CardPresentationSupport +import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.model.settings.CardCaption import org.siloserver.silo.model.settings.CardPosterSize import org.siloserver.silo.model.settings.CardPresentation @@ -120,6 +121,7 @@ import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewM import org.siloserver.silo.tv.ui.theme.FocusedContainer import org.siloserver.silo.tv.ui.theme.FocusedContent import org.siloserver.silo.tv.ui.theme.Spacing +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.viewmodel.HomeViewModel import kotlinx.coroutines.delay @@ -904,6 +906,11 @@ private fun TvGeneralSettingsPane( ) { var activeCardPicker by remember { mutableStateOf(null) } var showHomeSectionsEditor by remember { mutableStateOf(false) } + val titleArtStore: TitleArtStore = koinInject() + // Opening General is a refresh edge for a title art choice made on another + // device. Kept out of the LazyColumn item, which re-enters composition on + // scroll. + LaunchedEffect(titleArtStore) { titleArtStore.refresh() } LazyColumn( modifier = Modifier.fillMaxSize(), @@ -970,6 +977,9 @@ private fun TvGeneralSettingsPane( } } } + item { + TvTitleArtSettingsGroup(store = titleArtStore) + } item { // tvOS TVGeneralSettingsPane TOP MENU parity: the Audiobooks tab // is opt-in (hidden by default) even when the server has an @@ -2670,3 +2680,41 @@ private fun nextUpPromptLabel(seconds: Int): String = when { seconds == 60 -> "1 minute before end" else -> "${seconds / 60} minutes before end" } + +/** + * "Show title art" and "Apply to all devices" (settings revision 16). Hidden + * until the server confirms the key; older servers keep logos on. The + * switches stay disabled until this session's read lands. + */ +@Composable +private fun TvTitleArtSettingsGroup(store: TitleArtStore) { + val state by store.state.collectAsState() + val saveError by store.saveError.collectAsState() + if (!state.isSupported) return + SettingsGroup(title = "Title Pages") { + SettingsToggleRow( + label = "Show title art", + checked = state.showTitleArt, + onCheckedChange = store::setShowTitleArt, + enabled = state.canEdit, + ) + SettingsFooterText(text = "Use logo artwork as the title when available.") + SettingsToggleRow( + label = "Apply to all devices", + checked = state.appliesToAllDevices, + onCheckedChange = store::setAppliesToAllDevices, + enabled = state.canEdit, + ) + SettingsFooterText( + text = if (state.appliesToAllDevices) { + "On: every device on this profile uses this choice. Title art is " + + "${if (state.showTitleArt) "on" else "off"} on every device signed into " + + "this profile. Changing it here changes it everywhere. Turn off " + + "\u201CApply to all devices\u201D to choose for this TV only." + } else { + "Off: only affects this TV. Your other devices keep their own setting." + }, + ) + saveError?.let { SettingsFooterText(text = it) } + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/TitleArtController.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/TitleArtController.kt new file mode 100644 index 0000000000..0ed943a931 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/TitleArtController.kt @@ -0,0 +1,172 @@ +package org.siloserver.silo.domain.settings + +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.model.settings.SettingScope +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.repository.SettingsRepository +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * `ui.title_art` as resolved for this device. + * + * [appliesToAllDevices] is the "Apply to all devices" switch: the contract + * resolves `profile` ahead of `profile_device` for this key, so a profile value + * is the profile's all-devices choice and its presence is the switch's state. + */ +data class TitleArtPreference( + val showTitleArt: Boolean = TitleArtController.DEFAULT_SHOW_TITLE_ART, + val appliesToAllDevices: Boolean = false, +) + +/** + * Stateless wire half of the "Show title art" setting, shared by the phone and + * TV apps. The client never reimplements precedence: reads take the server's + * effective value for this device, and writes follow the `ui.title_art` + * manifest notes. + * + * Every call takes the caller's captured `authority` and passes it to each + * request it makes, so one change (up to a PUT, a DELETE and a re-read) is + * bound to one profile: after a profile switch the remaining requests fail as + * `identity_changed` instead of addressing the new profile. + */ +class TitleArtController( + private val repository: SettingsRepository, +) { + + sealed interface LoadResult { + data class Supported(val preference: TitleArtPreference) : LoadResult + + /** Definitive: the server predates the key. Never write. */ + data object Unsupported : LoadResult + + /** Transient failure (offline, 5xx). Keep whatever state was known. */ + data class Failed(val message: String?) : LoadResult + } + + sealed interface WriteResult { + /** + * Every request landed. [resolved] is what the server resolves now, or + * null when that re-read failed; the caller then keeps its optimistic + * value, because the write did take effect. + */ + data class Saved(val resolved: TitleArtPreference?) : WriteResult + + data class Failed(val message: String?) : WriteResult + } + + suspend fun load(authority: AuthScopeSnapshot? = null): LoadResult { + when (val caps = repository.contractCapabilities()) { + is ApiResult.Success -> if (!isSupported(caps.data)) return LoadResult.Unsupported + // No contract routes: an older server, not a transient failure. + is ApiResult.Error -> + return if (caps.code == 404) LoadResult.Unsupported else LoadResult.Failed(caps.message) + is ApiResult.NetworkError -> return LoadResult.Failed(caps.exception.message) + } + return when (val result = resolve(authority)) { + is ApiResult.Success -> LoadResult.Supported(result.data) + is ApiResult.Error -> LoadResult.Failed(result.message) + is ApiResult.NetworkError -> LoadResult.Failed(result.exception.message) + } + } + + /** + * The main switch. While the value applies to all devices it is the + * profile's value; otherwise it is this device's own. + */ + suspend fun setShowTitleArt( + show: Boolean, + appliesToAllDevices: Boolean, + authority: AuthScopeSnapshot? = null, + ): WriteResult { + val scope = if (appliesToAllDevices) SettingScope.PROFILE else SettingScope.PROFILE_DEVICE + return put(scope, show, authority) ?: saved(authority) + } + + /** + * The "Apply to all devices" switch. [showTitleArt] is the value on screen, + * which becomes the profile's value (on) or this device's value (off). + * + * Turning it off pins this device first, so it keeps its look once the + * profile value stops winning, and then clears the profile value so every + * other device returns to its own value or the default. A missing profile + * value already is the state asked for (the repository reports that 404 as + * success). + */ + suspend fun setAppliesToAllDevices( + enabled: Boolean, + showTitleArt: Boolean, + authority: AuthScopeSnapshot? = null, + ): WriteResult { + if (enabled) return put(SettingScope.PROFILE, showTitleArt, authority) ?: saved(authority) + put(SettingScope.PROFILE_DEVICE, showTitleArt, authority)?.let { return it } + return when (val cleared = repository.clearProfileValue(KEY, authority)) { + is ApiResult.Success -> saved(authority) + is ApiResult.Error -> WriteResult.Failed(cleared.message) + is ApiResult.NetworkError -> WriteResult.Failed(cleared.exception.message) + } + } + + /** Null when the write landed, else the failure to report. */ + private suspend fun put( + scope: SettingScope, + value: Boolean, + authority: AuthScopeSnapshot?, + ): WriteResult.Failed? { + val result = when (scope) { + SettingScope.PROFILE -> repository.setProfileValue(KEY, JsonPrimitive(value), authority) + SettingScope.PROFILE_DEVICE -> repository.setProfileDeviceValue(KEY, JsonPrimitive(value), authority) + else -> error("ui.title_art allows only profile and profile_device") + } + return when (result) { + is ApiResult.Success -> null + is ApiResult.Error -> WriteResult.Failed(result.message) + is ApiResult.NetworkError -> WriteResult.Failed(result.exception.message) + } + } + + private suspend fun saved(authority: AuthScopeSnapshot?): WriteResult = + WriteResult.Saved((resolve(authority) as? ApiResult.Success)?.data) + + private suspend fun resolve(authority: AuthScopeSnapshot?): ApiResult = + when (val result = repository.getEffectiveValues(listOf(KEY), authority = authority)) { + is ApiResult.Success -> { + val entry = result.data[KEY] + if (entry == null) { + ApiResult.Error(0, "missing_key", "The server did not resolve $KEY") + } else { + ApiResult.Success(preferenceOf(entry)) + } + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + + companion object { + const val KEY = SettingKeys.UI_TITLE_ART + + /** Settings manifest revision that introduced `ui.title_art`. */ + const val MIN_CONTRACT_REVISION = 16 + + const val DEFAULT_SHOW_TITLE_ART = true + + private const val SETTINGS_API_VERSION = 1 + + fun isSupported(capabilities: SettingsContractCapabilities): Boolean = + capabilities.apiVersion == SETTINGS_API_VERSION && + capabilities.manifestRevision >= MIN_CONTRACT_REVISION && + capabilities.supportsBatchedEffective + + /** A non-boolean value is not a choice; it resolves to the default. */ + fun preferenceOf(entry: EffectiveSettingValue): TitleArtPreference = + TitleArtPreference( + showTitleArt = runCatching { entry.value.jsonPrimitive.booleanOrNull }.getOrNull() + ?: DEFAULT_SHOW_TITLE_ART, + appliesToAllDevices = entry.source == SettingScope.PROFILE.wire, + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt index 487f75b21a..843aae8d49 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt @@ -24,7 +24,7 @@ data class SettingPresentation( ) object SettingKeys { - const val REVISION = 14 + const val REVISION = 16 /** Metadata language */ const val CATALOG_METADATA_LANGUAGE = "catalog.metadata_language" @@ -110,6 +110,8 @@ object SettingKeys { const val PLAYER_VIDEO_SKIP_BACK_SECONDS = "player.video_skip_back_seconds" /** Video fast-forward interval */ const val PLAYER_VIDEO_SKIP_FORWARD_SECONDS = "player.video_skip_forward_seconds" + /** Request titles I add to my watchlist */ + const val REQUESTS_WATCHLIST_AUTO_REQUEST = "requests.watchlist_auto_request" /** Search scope */ const val SEARCH_MEDIA_SCOPE = "search.media_scope" /** Match device caption settings */ @@ -156,6 +158,8 @@ object SettingKeys { const val UI_THEME_MUSIC_LOOP = "ui.theme_music_loop" /** Time format */ const val UI_TIME_FORMAT = "ui.time_format" + /** Show title art */ + const val UI_TITLE_ART = "ui.title_art" /** Every key the server stores. Safe to flush. */ val REMOTE: List = listOf( @@ -194,6 +198,7 @@ object SettingKeys { PLAYER_VIDEO_GRAVITY, PLAYER_VIDEO_SKIP_BACK_SECONDS, PLAYER_VIDEO_SKIP_FORWARD_SECONDS, + REQUESTS_WATCHLIST_AUTO_REQUEST, SEARCH_MEDIA_SCOPE, UI_CARD_OVERLAYS, UI_CARD_OVERLAYS_ENABLED, @@ -216,6 +221,7 @@ object SettingKeys { UI_THEME_MUSIC_ENABLED, UI_THEME_MUSIC_LOOP, UI_TIME_FORMAT, + UI_TITLE_ART, ) /** Contract-known keys that never leave the device. */ @@ -254,12 +260,14 @@ object SettingKeys { PLAYER_HDR_ENABLED, PLAYER_MATCH_FRAME_RATE, PLAYER_SEEK_CACHE_ENABLED, + REQUESTS_WATCHLIST_AUTO_REQUEST, UI_CARD_OVERLAYS_ENABLED, UI_CARD_QUICK_ACTIONS_ENABLED, UI_HIGH_CONTRAST, UI_REMEMBER_LIBRARY_PAGE_STATE, UI_THEME_MUSIC_ENABLED, UI_THEME_MUSIC_LOOP, + UI_TITLE_ART, ) val INT_KEYS: Set = setOf( diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SettingsRepository.kt index eb41d442e9..b8d531a46c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SettingsRepository.kt @@ -40,13 +40,25 @@ class SettingsRepository( * preference that applies on every device until a device overrides it. * * Picker callers surface failure and let the user make a new decision. + * + * [authority] pins the request to one captured profile (as the settings + * flusher does); a caller making several requests for one change passes + * the same snapshot to each so none can land on a profile switched to + * in between. */ - suspend fun setProfileValue(key: String, value: JsonElement): ApiResult = - settingsApi.putValue(key, SettingScopeIdentity.profile(), value) + suspend fun setProfileValue( + key: String, + value: JsonElement, + authority: org.siloserver.silo.network.AuthScopeSnapshot? = null, + ): ApiResult = + settingsApi.putValue(key, SettingScopeIdentity.profile(), value, authority = authority) /** Clear the profile-scoped value so the setting inherits again. */ - suspend fun clearProfileValue(key: String): ApiResult = - treatMissingAsCleared(settingsApi.deleteValue(key, SettingScopeIdentity.profile())) + suspend fun clearProfileValue( + key: String, + authority: org.siloserver.silo.network.AuthScopeSnapshot? = null, + ): ApiResult = + treatMissingAsCleared(settingsApi.deleteValue(key, SettingScopeIdentity.profile(), authority = authority)) /** * Write one like-client value (`scope=profile_client`) — the preference @@ -65,8 +77,12 @@ class SettingsRepository( * on this device only. The device half of the identity rides the * `X-Silo-Device-Id` header. */ - suspend fun setProfileDeviceValue(key: String, value: JsonElement): ApiResult = - settingsApi.putValue(key, SettingScopeIdentity.profileDevice(), value) + suspend fun setProfileDeviceValue( + key: String, + value: JsonElement, + authority: org.siloserver.silo.network.AuthScopeSnapshot? = null, + ): ApiResult = + settingsApi.putValue(key, SettingScopeIdentity.profileDevice(), value, authority = authority) suspend fun setMigrationDeviceValue(key: String, value: JsonElement, authority: org.siloserver.silo.network.AuthScopeSnapshot): ApiResult = settingsApi.putValue(key, SettingScopeIdentity.profileDevice(), value, authority.profileId, authority) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/TitleArtControllerTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/TitleArtControllerTest.kt new file mode 100644 index 0000000000..086585766f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/TitleArtControllerTest.kt @@ -0,0 +1,259 @@ +package org.siloserver.silo.domain.settings + +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.EffectiveSettingValuesResponse +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.model.settings.SettingScope +import org.siloserver.silo.model.settings.SettingScopeIdentity +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.model.settings.StoredSettingValue +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.api.SettingsApi +import org.siloserver.silo.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TitleArtControllerTest { + + private sealed interface Call { + data class Put(val scope: SettingScope, val value: Boolean) : Call + data class Delete(val scope: SettingScope) : Call + } + + /** + * A settings server that stores `ui.title_art` at profile and + * profile_device scope and resolves it in the contract's order + * (profile → profile_device → default). + */ + private class FakeTitleArtServer( + var capabilities: ApiResult = ApiResult.Success(SUPPORTED), + var profileValue: Boolean? = null, + var deviceValue: Boolean? = null, + ) : SettingsApi( + org.siloserver.silo.network.apiv2.SettingsV2Api( + HttpClient(), + org.siloserver.silo.network.TokenManagerImpl(), + org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted, + ), + ) { + val calls = mutableListOf() + var failPutAt: SettingScope? = null + var deleteResult: ApiResult? = null + var effectiveFails = false + + override suspend fun getContractCapabilities(): ApiResult = capabilities + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult { + if (effectiveFails) return ApiResult.Error(500, "boom", "Boom") + val (value, source) = when { + profileValue != null -> profileValue!! to "profile" + deviceValue != null -> deviceValue!! to "profile_device" + else -> true to EffectiveSettingValue.SOURCE_DEFAULT + } + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.UI_TITLE_ART, + value = JsonPrimitive(value), + source = source, + ), + ), + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + profileId: String?, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult { + val bool = (value as JsonPrimitive).content.toBooleanStrict() + calls += Call.Put(scope.scope, bool) + if (failPutAt == scope.scope) return ApiResult.Error(500, "boom", "Server error") + when (scope.scope) { + SettingScope.PROFILE -> profileValue = bool + SettingScope.PROFILE_DEVICE -> deviceValue = bool + else -> return ApiResult.Error(400, "invalid_scope", "Scope not allowed") + } + return ApiResult.Success(StoredSettingValue(key = key, scope = scope.scope.wire)) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult { + calls += Call.Delete(scope.scope) + deleteResult?.let { return it } + val existed = when (scope.scope) { + SettingScope.PROFILE -> (profileValue != null).also { profileValue = null } + SettingScope.PROFILE_DEVICE -> (deviceValue != null).also { deviceValue = null } + else -> false + } + return if (existed) ApiResult.Success(Unit) else ApiResult.Error(404, "not_found", "Not Found") + } + } + + private fun controller(server: FakeTitleArtServer) = TitleArtController(SettingsRepository(server)) + + @Test + fun loadReadsTheServerResolvedValueAndItsSource() = runTest { + assertEquals( + TitleArtController.LoadResult.Supported(TitleArtPreference(showTitleArt = true, appliesToAllDevices = false)), + controller(FakeTitleArtServer()).load(), + ) + assertEquals( + TitleArtController.LoadResult.Supported(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false)), + controller(FakeTitleArtServer(deviceValue = false)).load(), + ) + // Profile wins over this device's own value, and marks "all devices". + assertEquals( + TitleArtController.LoadResult.Supported(TitleArtPreference(showTitleArt = true, appliesToAllDevices = true)), + controller(FakeTitleArtServer(profileValue = true, deviceValue = false)).load(), + ) + } + + @Test + fun serversBeforeRevision16AreUnsupportedAndNotRead() = runTest { + val older = FakeTitleArtServer(capabilities = ApiResult.Success(SUPPORTED.copy(manifestRevision = 15))) + assertEquals(TitleArtController.LoadResult.Unsupported, controller(older).load()) + + val noContract = FakeTitleArtServer(capabilities = ApiResult.Error(404, "not_found", "Not Found")) + assertEquals(TitleArtController.LoadResult.Unsupported, controller(noContract).load()) + + val down = FakeTitleArtServer(capabilities = ApiResult.Error(503, "unavailable", "Down")) + assertIs(controller(down).load()) + + val offline = FakeTitleArtServer(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + assertIs(controller(offline).load()) + } + + @Test + fun mainSwitchWritesThisDeviceUnlessTheValueAppliesToAllDevices() = runTest { + val server = FakeTitleArtServer() + val result = controller(server).setShowTitleArt(show = false, appliesToAllDevices = false) + + assertEquals(listOf(Call.Put(SettingScope.PROFILE_DEVICE, false)), server.calls) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false)), + result, + ) + } + + @Test + fun mainSwitchWritesTheProfileWhileTheValueAppliesToAllDevices() = runTest { + val server = FakeTitleArtServer(profileValue = true) + val result = controller(server).setShowTitleArt(show = false, appliesToAllDevices = true) + + assertEquals(listOf(Call.Put(SettingScope.PROFILE, false)), server.calls) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true)), + result, + ) + } + + @Test + fun applyToAllDevicesOnWritesTheCurrentValueAtProfileScope() = runTest { + val server = FakeTitleArtServer(deviceValue = false) + val result = controller(server).setAppliesToAllDevices(enabled = true, showTitleArt = false) + + assertEquals(listOf(Call.Put(SettingScope.PROFILE, false)), server.calls) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = true)), + result, + ) + } + + @Test + fun applyToAllDevicesOffPinsThisDeviceBeforeClearingTheProfile() = runTest { + val server = FakeTitleArtServer(profileValue = false, deviceValue = true) + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = false) + + assertEquals( + listOf(Call.Put(SettingScope.PROFILE_DEVICE, false), Call.Delete(SettingScope.PROFILE)), + server.calls, + ) + // This device keeps the look it had; the profile value is gone. + assertNull(server.profileValue) + assertEquals(false, server.deviceValue) + assertEquals( + TitleArtController.WriteResult.Saved(TitleArtPreference(showTitleArt = false, appliesToAllDevices = false)), + result, + ) + } + + @Test + fun aMissingProfileValueOnDeleteCountsAsCleared() = runTest { + val server = FakeTitleArtServer() + server.deleteResult = ApiResult.Error(404, "not_found", "Not Found") + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = true) + + assertEquals( + listOf(Call.Put(SettingScope.PROFILE_DEVICE, true), Call.Delete(SettingScope.PROFILE)), + server.calls, + ) + assertIs(result) + } + + @Test + fun aFailedDevicePinNeverClearsTheProfileValue() = runTest { + val server = FakeTitleArtServer(profileValue = false) + server.failPutAt = SettingScope.PROFILE_DEVICE + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = false) + + assertEquals(listOf(Call.Put(SettingScope.PROFILE_DEVICE, false)), server.calls) + assertEquals(false, server.profileValue) + assertIs(result) + } + + @Test + fun aFailedProfileDeleteOtherThan404IsReported() = runTest { + val server = FakeTitleArtServer(profileValue = false) + server.deleteResult = ApiResult.Error(500, "boom", "Server error") + val result = controller(server).setAppliesToAllDevices(enabled = false, showTitleArt = false) + + assertEquals(TitleArtController.WriteResult.Failed("Server error"), result) + } + + @Test + fun aWriteThatLandedStillSucceedsWhenTheReReadFails() = runTest { + val server = FakeTitleArtServer() + server.effectiveFails = true + val result = controller(server).setShowTitleArt(show = false, appliesToAllDevices = false) + + assertEquals(TitleArtController.WriteResult.Saved(resolved = null), result) + assertTrue(server.calls.isNotEmpty()) + } + + @Test + fun aNonBooleanValueResolvesToTheDefault() { + val preference = TitleArtController.preferenceOf( + EffectiveSettingValue(key = SettingKeys.UI_TITLE_ART, value = JsonPrimitive("nope"), source = "profile"), + ) + assertEquals(TitleArtPreference(showTitleArt = true, appliesToAllDevices = true), preference) + } + + private companion object { + val SUPPORTED = SettingsContractCapabilities( + apiVersion = 1, + manifestRevision = 16, + supportsBatchedEffective = true, + ) + } +} diff --git a/shared/src/commonTest/resources/settings/v1/SOURCE b/shared/src/commonTest/resources/settings/v1/SOURCE index 215b078000..afac60f7cd 100644 --- a/shared/src/commonTest/resources/settings/v1/SOURCE +++ b/shared/src/commonTest/resources/settings/v1/SOURCE @@ -1,6 +1,6 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/settings/v1 -manifest_revision=14 +manifest_revision=16 fixture_version=1 Both files are byte-identical copies of the server's canonical contract; do not @@ -10,9 +10,9 @@ 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=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9 -manifest.json commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9 -copied from commit=86ea1a1baf9bae2ce02b17f915817b2c76aae2b9 +conformance.json commit=ff4a3878684d730a42fb87a3a6631e4367008157 +manifest.json commit=ff4a3878684d730a42fb87a3a6631e4367008157 +copied from commit=ff4a3878684d730a42fb87a3a6631e4367008157 manifest.json is vendored whole, including the maintainer `notes` the server strips before serving /api/v1/settings/contract. Keeping it byte-identical is diff --git a/shared/src/commonTest/resources/settings/v1/conformance.json b/shared/src/commonTest/resources/settings/v1/conformance.json index f63d46fa00..524d16e320 100644 --- a/shared/src/commonTest/resources/settings/v1/conformance.json +++ b/shared/src/commonTest/resources/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 14, + "manifest_revision": 16, "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": [ { @@ -1065,6 +1065,28 @@ {"key": "ui.theme_music_enabled", "scope": "profile_device", "profile_id": "p1", "device_id": "browser", "value": false} ], "expected": [{"key": "ui.theme_music_enabled", "value": false, "source": "profile_device"}] + }, + { + "name": "title_art_follows_each_device", + "description": "With no profile value, every device follows its own choice; a device that never chose shows title art.", + "keys": ["ui.title_art"], + "context": {"profile_id": "p1", "device_id": "phone"}, + "stored": [ + {"key": "ui.title_art", "scope": "profile_device", "profile_id": "p1", "device_id": "phone", "value": false}, + {"key": "ui.title_art", "scope": "profile_device", "profile_id": "p1", "device_id": "living-room", "value": true} + ], + "expected": [{"key": "ui.title_art", "value": false, "source": "profile_device"}] + }, + { + "name": "title_art_profile_value_applies_to_all_devices", + "description": "A profile value is the apply-to-all-devices choice, so it wins over the device's own value instead of being the fallback.", + "keys": ["ui.title_art"], + "context": {"profile_id": "p1", "device_id": "phone"}, + "stored": [ + {"key": "ui.title_art", "scope": "profile", "profile_id": "p1", "value": true}, + {"key": "ui.title_art", "scope": "profile_device", "profile_id": "p1", "device_id": "phone", "value": false} + ], + "expected": [{"key": "ui.title_art", "value": true, "source": "profile"}] } ] } diff --git a/shared/src/commonTest/resources/settings/v1/manifest.json b/shared/src/commonTest/resources/settings/v1/manifest.json index 427ab69649..a4fd686af2 100644 --- a/shared/src/commonTest/resources/settings/v1/manifest.json +++ b/shared/src/commonTest/resources/settings/v1/manifest.json @@ -1,6 +1,6 @@ { "api_version": 1, - "revision": 14, + "revision": 16, "option_sets": { "playback_audio_languages": { "type": "language_tag", @@ -1075,7 +1075,7 @@ "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 server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled. Revision 13 adds the advisory_age overlay id; a server below revision 13 rejects a value that contains it, so a client offers that badge only when manifest_revision is at least 13." + "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 server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled. Revision 13 adds the advisory_age overlay id; a server below revision 13 rejects a value that contains it, so a client offers that badge only when manifest_revision is at least 13. Revision 15 adds the request_status overlay id (a watchlist title's request status on its poster) under the same rule: a client offers it only when manifest_revision is at least 15." }, { "key": "ui.card_overlays_enabled", @@ -1437,6 +1437,35 @@ "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." + }, + { + "key": "requests.watchlist_auto_request", + "introduced_in": 15, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "requests", + "label": "Request titles I add to my watchlist", + "description": "When you add a title that is not in the library to your watchlist, also request it.", + "recommended_control": "switch", + "notes": "Profile scope because each household member decides whether their own watchlist adds become requests. It applies only while the server-wide watchlist_requests request setting is on and the profile may request at all; a client shows it only then (GET /api/v2/requests/status reports the effective value as watchlist_requests). Request limits, approval and routing apply exactly as for the Request button. Turning it off never cancels an existing request." + }, + { + "key": "ui.title_art", + "introduced_in": 16, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile", "profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "appearance", + "label": "Show title art", + "description": "Use a title's logo artwork as its name on detail pages when one is available. When off, the name is always shown as text.", + "recommended_control": "switch", + "notes": "The profile scope deliberately resolves before profile_device: a profile value is the profile's \"apply to all devices\" choice and wins on every device, while with no profile value each device follows its own profile_device value. Clients show a second \"Apply to all devices\" switch whose state is the effective source being profile. Turning it on writes the current value at profile scope; while on, the main switch writes profile scope. Turning it off writes the current value at profile_device for the device doing it and then clears the profile value, so that device keeps its look and every other device returns to its own value or the default. Device values written earlier are kept while the profile value is set and apply again once it is cleared. Covers every surface that names a title with its logo (detail heroes, TV marquees); a client that turns it off also has no reason to prefetch logos." } ] } From 1e903dd2cf467d9e7abcb32a8feb0a0bc039cda5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:33:36 -0400 Subject: [PATCH 12/87] feat(settings): lay out Android and Android TV Settings like the Apple apps (#445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): lay out Android and Android TV Settings like the Apple apps Settings on the phone was one long scrolling form of web-style cards, and the TV settings had drifted from tvOS: mono section headers, an eyebrow above each pane, a narrow rail without descriptions, and different labels and grouping. Phone, following the iOS Settings (silo-apple #546): - The overview is an inset-grouped list on black: large title, account card (avatar, "username · host", Admin badge), search, then one-line rows with graphite icon tiles and the current value. Sections: Preferences, Playback, Library, Support, Connection, Experimental, About, Sign Out. - Interface, Playback, Subtitles, Downloads, and Notifications are pushed sub-pages (new settings/* routes sharing the overview's view model), with Apple's labels, group order, and footers. Subtitle appearance is edited inline in Text, Background, and Layout groups under a live preview. - Diagnostics follows the Apple page order with menu pickers. - Rows use Apple's grouped palette: #1C1C1E cells, #3A3A3C tiles, green switches, title-case headers, and popup pickers with a checkmark. TV, following tvOS Settings: - Black ground, a wider rail with tvOS rail descriptions and outlined glyphs, and a pane header with the category glyph on a graphite tile. - Sans section headers, tvOS-proportioned rows, a card-style option picker, and the tvOS confirmation card. Text keeps the 14sp TV floor. - Playback moves Audio Language into Streaming; Subtitles orders Language, Behavior, Forced, splits out Metadata, and says Use Device Settings; Server shows Address, App Version, and Privacy Policy. Android-only settings stay, placed in the matching group. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): keep group dividers right when rows change, and restore Diagnostics privacy link Row dividers were assigned by first-composition order, so a group whose first row changed while it stayed on screen (search filtering, a row swapping in place in Diagnostics) drew a hairline on its top edge. Every row now draws its hairline and the group paints over its own top edge. The phone Diagnostics page lost its Privacy Policy link in the restyle; it is back as the last Capture row. The subtitle preview parses colours with toColorInt, as lint asks. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): preview the device caption style when Use Device Settings is on The phone subtitle preview drew the saved custom style even with Use Device Settings on, while playback and the TV preview use the device caption style. The preview now reads effectiveSubtitleAppearanceFlow; the Text, Background, and Layout groups still edit the saved style. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): keep Settings rail text on one line at large font sizes Rail rows are a fixed 50dp. At font scale 1.3, "Language and appearance" wrapped and its second line was cut off mid-glyph. Title and description now ellipsize on one line. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/android/di/AndroidModule.kt | 2 +- .../silo/android/ui/components/SiloMenu.kt | 2 +- .../silo/android/ui/components/SiloTopBar.kt | 64 +- .../android/ui/navigation/AppNavigation.kt | 57 +- .../silo/android/ui/navigation/Routes.kt | 8 + .../ui/screens/settings/AccountSection.kt | 154 --- .../ui/screens/settings/MediaCardsSettings.kt | 35 +- .../ui/screens/settings/PlaybackSettings.kt | 154 ++- .../screens/settings/SeekIntervalSettings.kt | 21 +- .../ui/screens/settings/ServerInfoSection.kt | 38 - .../screens/settings/SettingsAccountCard.kt | 139 +++ .../ui/screens/settings/SettingsComponents.kt | 692 ++++++++++ .../ui/screens/settings/SettingsPages.kt | 368 ++++++ .../ui/screens/settings/SettingsScreen.kt | 1109 +++++------------ .../ui/screens/settings/SettingsViewModel.kt | 29 + .../ui/screens/settings/SubtitleSettings.kt | 433 ++++++- .../ui/screens/settings/TitleArtSettings.kt | 60 +- .../diagnostics/DiagnosticsSettingsScreen.kt | 377 +++--- .../siloserver/silo/android/ui/theme/Color.kt | 30 +- .../silo/android/ui/theme/Spacing.kt | 145 ++- .../pip/MobilePictureInPictureSourceTest.kt | 8 +- .../ui/screens/servers/TvServerListScreen.kt | 4 +- .../ui/screens/settings/TvSettingsScreen.kt | 571 +++++---- .../screens/settings/TvSettingsViewModel.kt | 6 +- .../diagnostics/TvDiagnosticsSettingsPane.kt | 2 +- .../settings/TvSettingsCategoryTest.kt | 1 - 26 files changed, 2850 insertions(+), 1659 deletions(-) delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/AccountSection.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/ServerInfoSection.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsAccountCard.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 014172bc9e..9127cb574c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -467,7 +467,7 @@ val androidModule = module { featureStore = get(), ) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { org.siloserver.silo.android.ui.screens.pairing.CompanionPairingViewModel(get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt index 42672686ef..7e80cebdff 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt @@ -104,7 +104,7 @@ fun SiloMenuItem( ) { Text( text = label, - style = SettingsTextStyles.rowLabel, + style = SettingsTextStyles.menuLabel, color = labelColor, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloTopBar.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloTopBar.kt index d0b408f264..2b50eefb48 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloTopBar.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloTopBar.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.android.ui.components import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -12,6 +13,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight /** * Reusable top app bar for Silo screens. @@ -22,6 +24,8 @@ import androidx.compose.ui.graphics.Color * @param containerColor Bar background. Defaults to the app's `surface`; the * grouped-settings screens pass their own lifted page ground so the bar and * the list below it are one continuous surface instead of two tones. + * @param centerTitle Centres the title, as Apple's inline navigation titles + * are. Settings sub-pages use it to match the Apple apps. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -30,30 +34,44 @@ fun SiloTopBar( onBackClick: (() -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {}, containerColor: Color = MaterialTheme.colorScheme.surface, + centerTitle: Boolean = false, ) { - TopAppBar( - title = { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - ) - }, - navigationIcon = { - if (onBackClick != null) { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Navigate back", - ) - } + val titleSlot: @Composable () -> Unit = { + Text( + text = title, + style = if (centerTitle) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = if (centerTitle) FontWeight.SemiBold else null, + ) + } + val navigationSlot: @Composable () -> Unit = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Navigate back", + ) } - }, - actions = actions, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = containerColor, - titleContentColor = MaterialTheme.colorScheme.onSurface, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), + } + } + val colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (centerTitle) { + CenterAlignedTopAppBar( + title = titleSlot, + navigationIcon = navigationSlot, + actions = actions, + colors = colors, + ) + } else { + TopAppBar( + title = titleSlot, + navigationIcon = navigationSlot, + actions = actions, + colors = colors, + ) + } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 1f11dcd3d9..f60d5eb13a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -34,6 +34,8 @@ import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelStoreOwner +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.lifecycle.DEFAULT_ARGS_KEY import androidx.lifecycle.viewmodel.MutableCreationExtras @@ -91,7 +93,12 @@ import org.siloserver.silo.android.ui.screens.search.SearchScreen import org.siloserver.silo.android.ui.screens.search.SearchViewModel import org.siloserver.silo.android.ui.screens.servers.ServerListScreen import org.siloserver.silo.android.ui.screens.servers.ServerSwitchDestination +import org.siloserver.silo.android.ui.screens.settings.SettingsDownloadsScreen +import org.siloserver.silo.android.ui.screens.settings.SettingsInterfaceScreen +import org.siloserver.silo.android.ui.screens.settings.SettingsNotificationsScreen +import org.siloserver.silo.android.ui.screens.settings.SettingsPlaybackScreen import org.siloserver.silo.android.ui.screens.settings.SettingsScreen +import org.siloserver.silo.android.ui.screens.settings.SettingsSubtitlesScreen import org.siloserver.silo.android.ui.screens.settings.diagnostics.DiagnosticsPromptDialog import org.siloserver.silo.common.diagnostics.DiagnosticsLifecycleLogger import org.siloserver.silo.android.ui.screens.settings.diagnostics.DiagnosticsReportScreen @@ -735,6 +742,11 @@ fun AppNavigation( } composable(Route.Settings.route) { SettingsScreen( + onOpenInterface = { navController.navigate(Route.SettingsInterface.route) }, + onOpenPlayback = { navController.navigate(Route.SettingsPlayback.route) }, + onOpenSubtitles = { navController.navigate(Route.SettingsSubtitles.route) }, + onOpenDownloads = { navController.navigate(Route.SettingsDownloads.route) }, + onOpenNotifications = { navController.navigate(Route.SettingsNotifications.route) }, onNavigateToServers = { navController.navigate(Route.ServerList.route) }, @@ -763,7 +775,39 @@ fun AppNavigation( popUpTo(0) { inclusive = true } } }, - showTopBar = true, + onBackClick = { navController.popBackStack() }, + ) + } + // Settings sub-pages share the overview's view model, so the value a + // page changes is the value its overview row shows on the way back. + composable(Route.SettingsInterface.route) { entry -> + SettingsInterfaceScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsPlayback.route) { entry -> + SettingsPlaybackScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsSubtitles.route) { entry -> + SettingsSubtitlesScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsDownloads.route) { entry -> + SettingsDownloadsScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), + downloadsViewModel = koinViewModel(), + onBackClick = { navController.popBackStack() }, + ) + } + composable(Route.SettingsNotifications.route) { entry -> + SettingsNotificationsScreen( + viewModel = koinViewModel(viewModelStoreOwner = settingsOwner(navController, entry)), onBackClick = { navController.popBackStack() }, ) } @@ -1417,3 +1461,14 @@ private suspend fun pairingAuthRouteOrNull( val profileId = activeEntryProfileId ?: tokenManager.getProfileId() return if (profileId.isNullOrBlank()) Route.ProfileSelection.route else null } + +/** + * The Settings overview's back-stack entry, which owns the SettingsViewModel + * its sub-pages share. Falls back to the page's own entry should the overview + * ever be missing from the stack, so the page still renders. + */ +@Composable +private fun settingsOwner(navController: NavHostController, entry: NavBackStackEntry): ViewModelStoreOwner = + remember(entry) { + runCatching { navController.getBackStackEntry(Route.Settings.route) }.getOrNull() ?: entry + } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt index bd1462ceb5..8c3e3f60ef 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt @@ -91,6 +91,14 @@ sealed class Route(val route: String) { } } data object Settings : Route("settings") + + // Settings sub-pages, pushed from the overview as on the Apple apps. They + // share the overview's SettingsViewModel through its back-stack entry. + data object SettingsInterface : Route("settings/interface") + data object SettingsPlayback : Route("settings/playback") + data object SettingsSubtitles : Route("settings/subtitles") + data object SettingsDownloads : Route("settings/downloads") + data object SettingsNotifications : Route("settings/notifications") data object Diagnostics : Route("settings/diagnostics") data class DiagnosticsReport(val reportId: String) : Route("settings/diagnostics/report/${Uri.encode(reportId)}") { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/AccountSection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/AccountSection.kt deleted file mode 100644 index 3a9d3bff8c..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/AccountSection.kt +++ /dev/null @@ -1,154 +0,0 @@ -package org.siloserver.silo.android.ui.screens.settings - -import androidx.compose.foundation.background -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -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.unit.dp -import org.siloserver.silo.android.ui.components.SignOutConfirmDialog -import org.siloserver.silo.android.ui.theme.SettingsDimens -import org.siloserver.silo.android.ui.theme.SettingsTextStyles -import org.siloserver.silo.android.ui.theme.SiloForeground -import org.siloserver.silo.android.ui.theme.SiloMutedText -import org.siloserver.silo.android.ui.theme.Spacing -import org.siloserver.silo.model.auth.User - -/** - * Settings section showing user account info, device pairing, and sign out. - * - * Session management and the admin surface are deliberately absent: both were - * removed from the Android clients outright — phone, TV, and the shared code - * that served them — not merely hidden behind a gate. - */ -@Composable -fun AccountSection( - user: User?, - isLoadingUser: Boolean, - onPairDevice: () -> Unit, - onSignOut: () -> Unit, - modifier: Modifier = Modifier, - // iOS parity: the account header is a button that opens profile - // selection ("Tap to switch profile") — the chevron was previously dead. - onSwitchProfile: () -> Unit = {}, -) { - var confirmSignOut by rememberSaveable { mutableStateOf(false) } - - SettingsSectionCard(modifier = modifier) { - if (isLoadingUser) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.xxl), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(Spacing.xxl), - strokeWidth = 2.dp, - ) - } - } else if (user != null) { - // Claims the card's first row slot, so the row below it still - // draws its hairline. - val headerDivider = settingsRowDividerVisible() - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = SettingsDimens.rowMinHeight) - .settingsRowDivider(headerDivider) - .clickable(onClick = onSwitchProfile) - .padding( - horizontal = SettingsDimens.rowHorizontalPadding, - vertical = SettingsDimens.rowVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - // Avatar — iOS ProfileAvatarView size 56. - Box( - modifier = Modifier - .size(SettingsDimens.avatarSize) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(SettingsDimens.avatarIconSize), - ) - } - - Spacer(modifier = Modifier.width(SettingsDimens.avatarGap)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), - ) { - Text( - text = user.username, - style = SettingsTextStyles.accountName, - color = SiloForeground, - maxLines = 1, - ) - Text( - text = user.email, - style = SettingsTextStyles.rowDescription, - color = SiloMutedText, - maxLines = 1, - ) - } - - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - - SettingsRowChevron() - } - - SettingsNavigationRow( - label = "Pair device", - description = "Link a TV or another device to this account.", - onClick = onPairDevice, - ) - - SettingsDestructiveRow( - label = "Sign out", - description = "Sign this device out of ${user.username}'s account.", - onClick = { confirmSignOut = true }, - ) - - // Same dialog the profile menu raises, so the answer to "are you - // sure?" does not depend on which of the two routes was taken. - SignOutConfirmDialog( - visible = confirmSignOut, - accountName = user.username, - onConfirm = { - confirmSignOut = false - onSignOut() - }, - onDismiss = { confirmSignOut = false }, - ) - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/MediaCardsSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/MediaCardsSettings.kt index 91a706ada1..276fc5703e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/MediaCardsSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/MediaCardsSettings.kt @@ -12,8 +12,9 @@ import org.siloserver.silo.model.settings.CardPresentationPreset private const val CustomPresetLabel = "Custom" /** - * "Media Cards" section — the cross-client `ui.card_presentation` preference - * (poster size + caption style, plus the client-side presets over the pair). + * Interface → "Cards & Posters" (Apple `InterfaceCustomizationView`) — the + * cross-client `ui.card_presentation` preference (poster size + caption style, + * plus the client-side presets over the pair). * * Writes go to `profile_client` so the choice roams among this profile's * phones/tablets, unless "Only this device" pins a `profile_device` override. @@ -29,8 +30,18 @@ fun MediaCardsSettings( onUseProfileDefault: () -> Unit, modifier: Modifier = Modifier, ) { - SettingsSection(title = "Media Cards", modifier = modifier) { - if (state.support == CardPresentationSupport.Unsupported) { + val unsupported = state.support == CardPresentationSupport.Unsupported + SettingsSection( + title = "Cards & Posters", + footer = if (unsupported) { + null + } else { + "Start with Balanced, Compact, Cinema, or Artwork Only, then fine-tune size and captions. These " + + "choices sync with other phones and tablets on this profile unless Only This Device is on." + }, + modifier = modifier, + ) { + if (unsupported) { SettingsProse(body = "Update your Silo server to customize media cards.") return@SettingsSection } @@ -43,7 +54,6 @@ fun MediaCardsSettings( listOfNotNull(CustomPresetLabel.takeIf { activePreset == null }) SettingsDropdownRow( label = "Preset", - description = "A starting point for poster size and captions.", value = activePreset?.displayName ?: CustomPresetLabel, options = presetOptions, onOptionSelected = { label -> @@ -54,8 +64,7 @@ fun MediaCardsSettings( ) SettingsDropdownRow( - label = "Poster size", - description = "How large posters render in rows and grids.", + label = "Poster Size", value = presentation.posterSize.displayName, options = CardPosterSize.entries.map { it.displayName }, onOptionSelected = { label -> @@ -67,7 +76,6 @@ fun MediaCardsSettings( SettingsDropdownRow( label = "Captions", - description = "What shows beneath each poster.", value = presentation.caption.displayName, options = CardCaption.entries.map { it.displayName }, onOptionSelected = { label -> @@ -79,24 +87,17 @@ fun MediaCardsSettings( val deviceOnly = state.source == CardPresentationSource.DeviceOverride SettingsSwitchRow( - label = "Only this device", - description = "Keep these choices on this device instead of syncing them.", + label = "Only This Device", checked = deviceOnly, onCheckedChange = onDeviceOnlyChanged, ) if (!deviceOnly && state.source == CardPresentationSource.ClientFamily) { SettingsNavigationRow( - label = "Use profile default", - description = "Clear this device family's choice and follow the profile.", + label = "Use Profile Default", onClick = onUseProfileDefault, showChevron = false, ) } - - SettingsProse( - body = "Choices sync with other phones (or tablets) signed into this " + - "profile unless 'Only this device' is on.", - ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt index f112851e0c..825f27ecc9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt @@ -16,8 +16,8 @@ import org.siloserver.silo.model.settings.SettingKeys // 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. +// Discrete choices for the two behavior settings (0 = off). The label↔value +// maps below convert. private val resumeRewindOptions = listOf(0, 3, 5, 7, 10, 15, 20, 30) private val passOutThresholdOptions = listOf(0, 2, 3, 4, 5) // Up-Next prompt timing (seconds before end; 0 = at end). Mirrors TV/tvOS. @@ -32,37 +32,26 @@ private fun nextUpPromptLabel(seconds: Int): String = when { } /** - * Playback settings section with quality preference, audio language, - * and auto-skip toggles. + * Playback → Streaming (Apple `PlaybackSettingsView` "Streaming"): quality, + * audio language, Dolby Vision, and — where the Apple list ends with + * Background Playback — Android's picture-in-picture. The footer describes the + * chosen quality preset. */ @Composable -fun PlaybackSettings( +fun PlaybackStreamingSection( qualityResolution: String, maxBitrateKbps: Int?, audioLanguage: String, - audioLanguageSuggestions: List = emptyList(), - introSkipMode: IntroSkipMode, - autoSkipCredits: Boolean, - pictureInPictureEnabled: Boolean, + audioLanguageSuggestions: List, dolbyVisionEnabled: Boolean, dvProfile7HDR10Fallback: Boolean, - autoPlayNext: Boolean, - nextUpPromptSeconds: Int, - resumeRewindSeconds: Int, - passOutThreshold: Int, + pictureInPictureEnabled: Boolean, /** Receives a [QualityPresets] preset id. */ onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, - onIntroSkipModeChanged: (IntroSkipMode) -> Unit, - onAutoSkipCreditsChanged: (Boolean) -> Unit, - onPictureInPictureEnabledChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, - onAutoPlayNextChanged: (Boolean) -> Unit, - onNextUpPromptSecondsChanged: (Int) -> Unit, - onResumeRewindSecondsChanged: (Int) -> Unit, - onPassOutThresholdChanged: (Int) -> Unit, - onResetPlaybackOverrides: () -> Unit, + onPictureInPictureEnabledChanged: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val audioLanguageOptions = remember(audioLanguage, audioLanguageSuggestions) { @@ -72,15 +61,16 @@ fun PlaybackSettings( runtimeValues = audioLanguageSuggestions, ) } - val introSkipOptions = IntroSkipMode.entries.map { it to stringResource(introSkipModeLabel(it)) } - SettingsSection(title = "Playback", modifier = modifier) { - // 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. + // 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. + val qualityLabel = QualityPresets.describe(qualityResolution, maxBitrateKbps) + val qualityFooter = QualityPresets.presetFor(qualityResolution, maxBitrateKbps)?.description + ?: "$qualityLabel." + SettingsSection(title = "Streaming", footer = qualityFooter, modifier = modifier) { SettingsDropdownRow( - label = "Preferred quality", - description = "The quality Silo requests when playback starts.", - value = QualityPresets.describe(qualityResolution, maxBitrateKbps), + label = "Quality", + value = qualityLabel, options = QualityPresets.ALL.map { it.label }, onOptionSelected = { label -> QualityPresets.ALL.firstOrNull { it.label == label } @@ -89,8 +79,7 @@ fun PlaybackSettings( ) SettingsDropdownRow( - label = "Audio language", - description = "Choose which spoken language Silo should prefer first.", + label = "Audio Language", value = LanguageOptions.label(audioLanguage, SettingKeys.PLAYBACK_AUDIO_LANGUAGE), options = audioLanguageOptions.map { it.second }, onOptionSelected = { label -> @@ -98,61 +87,64 @@ fun PlaybackSettings( }, ) - // Three-way, not a switch: the boolean this replaced could not say - // "never". Labels and semantics are fixed by the contract. - SettingsDropdownRow( - label = stringResource(R.string.settings_intro_skip_title), - description = "What happens when a detected intro starts: leave it alone, " + - "offer a Skip Intro button, or skip it and offer an undo.", - value = stringResource(introSkipModeLabel(introSkipMode)), - options = introSkipOptions.map { it.second }, - onOptionSelected = { label -> - introSkipOptions.firstOrNull { it.second == label }?.let { onIntroSkipModeChanged(it.first) } - }, - ) - - SettingsSwitchRow( - label = "Auto-skip credits", - description = "Move through end credits automatically when a skip is available.", - checked = autoSkipCredits, - onCheckedChange = onAutoSkipCreditsChanged, - ) - - // iOS PlaybackSettingsView parity: Dolby Vision (off plays the HDR10 - // base layer) with the Profile 7 fallback nested under it — the P7 row - // only shows while Dolby Vision is on. + // Dolby Vision (off plays the HDR10 base layer) with the Profile 7 + // fallback under it — the P7 row only shows while Dolby Vision is on. SettingsSwitchRow( label = "Dolby Vision", - description = "Allow Dolby Vision output on this device.", checked = dolbyVisionEnabled, onCheckedChange = onDolbyVisionEnabledChanged, ) if (dolbyVisionEnabled) { SettingsSwitchRow( - label = "Profile 7 HDR10 fallback", - description = "Play Profile 7 sources as HDR10 when this device cannot decode them natively.", + label = "Profile 7 HDR10 Fallback", checked = dvProfile7HDR10Fallback, onCheckedChange = onDvProfile7HDR10FallbackChanged, ) } SettingsSwitchRow( - label = "Picture-in-picture", - description = "Keep playing in a floating window when you leave the player.", + label = "Picture-in-Picture", checked = pictureInPictureEnabled, onCheckedChange = onPictureInPictureEnabledChanged, ) + } +} +/** + * Playback → Episodes (Apple "Episodes"), plus Android's two resume and + * auto-play limits at the end, explained in the footer. + */ +@Composable +fun PlaybackEpisodesSection( + autoPlayNext: Boolean, + nextUpPromptSeconds: Int, + introSkipMode: IntroSkipMode, + autoSkipCredits: Boolean, + resumeRewindSeconds: Int, + passOutThreshold: Int, + onAutoPlayNextChanged: (Boolean) -> Unit, + onNextUpPromptSecondsChanged: (Int) -> Unit, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, + onAutoSkipCreditsChanged: (Boolean) -> Unit, + onResumeRewindSecondsChanged: (Int) -> Unit, + onPassOutThresholdChanged: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val introSkipOptions = IntroSkipMode.entries.map { it to stringResource(introSkipModeLabel(it)) } + SettingsSection( + title = "Episodes", + footer = "Rewind on Resume skips back when you return to a partly watched title. Still Watching " + + "Prompt sets how many episodes play in a row before Silo asks whether you're still watching.", + modifier = modifier, + ) { SettingsSwitchRow( - label = "Auto-play next episode", - description = "Continue to the next episode automatically.", + label = "Auto-Play Next Episode", checked = autoPlayNext, onCheckedChange = onAutoPlayNextChanged, ) SettingsDropdownRow( - label = "Next up prompt", - description = "How long before the end of an episode the next-up prompt appears.", + label = "Show Next Up", value = nextUpPromptLabel(nextUpPromptSeconds), options = nextUpPromptOptions.map(::nextUpPromptLabel), onOptionSelected = { label -> @@ -160,9 +152,25 @@ fun PlaybackSettings( }, ) + // Three-way, not a switch: the boolean this replaced could not say + // "never". Option labels and semantics are fixed by the contract. SettingsDropdownRow( - label = "Rewind on resume", - description = "Skip back this far when resuming a partly watched item.", + label = "Skip Intros", + value = stringResource(introSkipModeLabel(introSkipMode)), + options = introSkipOptions.map { it.second }, + onOptionSelected = { label -> + introSkipOptions.firstOrNull { it.second == label }?.let { onIntroSkipModeChanged(it.first) } + }, + ) + + SettingsSwitchRow( + label = "Skip Credits", + checked = autoSkipCredits, + onCheckedChange = onAutoSkipCreditsChanged, + ) + + SettingsDropdownRow( + label = "Rewind on Resume", value = resumeRewindLabel(resumeRewindSeconds), options = resumeRewindOptions.map(::resumeRewindLabel), onOptionSelected = { label -> @@ -171,18 +179,26 @@ fun PlaybackSettings( ) SettingsDropdownRow( - label = "Still watching prompt", - description = "How many episodes auto-play before Silo asks whether you are still watching.", + label = "Still Watching Prompt", value = passOutThresholdLabel(passOutThreshold), options = passOutThresholdOptions.map(::passOutThresholdLabel), onOptionSelected = { label -> onPassOutThresholdChanged(passOutThresholdOptions.first { passOutThresholdLabel(it) == label }) }, ) + } +} +/** Playback → reset, the Apple page's last group. */ +@Composable +fun PlaybackResetSection(onResetPlaybackOverrides: () -> Unit, modifier: Modifier = Modifier) { + SettingsSection( + title = null, + footer = "Resets playback choices for this device and profile back to the server fallback.", + modifier = modifier, + ) { SettingsDestructiveRow( - label = "Reset playback settings", - description = "Return this device's playback settings to their defaults.", + label = "Reset Playback Overrides", onClick = onResetPlaybackOverrides, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SeekIntervalSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SeekIntervalSettings.kt index 6e5c173cde..ef995e420b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SeekIntervalSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SeekIntervalSettings.kt @@ -12,8 +12,9 @@ private const val CHECKING_SERVER = "Checking whether this server stores skip in /** * One "Video" or "Audiobooks" group of the profile-wide skip intervals - * (settings revision 9). On an older server the group explains the legacy - * behavior instead of offering pickers that could not be saved. + * (settings revision 9), with the Apple pages' labels and footers. On an older + * server the group explains the legacy behavior instead of offering pickers + * that could not be saved. */ @Composable fun SeekIntervalSettings( @@ -28,7 +29,14 @@ fun SeekIntervalSettings( SeekMedia.Video -> "Video" SeekMedia.Audiobook -> "Audiobooks" } - SettingsSection(title = title, modifier = modifier) { + val supported = state.support != SeekIntervalSupport.Unsupported && !state.checking && !state.checkFailed + val footer = when { + !supported -> null + media == SeekMedia.Video -> + "Used by the on-screen skip buttons and double-tap. Applies to every device signed in to this profile." + else -> "Used by the audiobook player's skip buttons. Applies to every device signed in to this profile." + } + SettingsSection(title = title, footer = footer, modifier = modifier) { if (state.support == SeekIntervalSupport.Unsupported) { SettingsProse( body = when (media) { @@ -59,7 +67,7 @@ fun SeekIntervalSettings( "Until it answers, intervals set in the audiobook player stay on this device." }, ) - SettingsNavigationRow(label = "Try again", onClick = onRetry) + SettingsNavigationRow(label = "Try Again", onClick = onRetry) return@SettingsSection } @@ -70,8 +78,7 @@ fun SeekIntervalSettings( val options = state.choices.map(SeekIntervals::label) SeekDirection.entries.forEach { direction -> SettingsDropdownRow( - label = if (direction == SeekDirection.Back) "Skip back" else "Skip forward", - description = if (direction == SeekDirection.Back) "Applies to every device on this profile." else null, + label = if (direction == SeekDirection.Back) "Skip Back" else "Skip Forward", value = SeekIntervals.label(pair.seconds(direction)), options = options, onOptionSelected = { label -> @@ -84,7 +91,7 @@ fun SeekIntervalSettings( if (media == SeekMedia.Audiobook) { if (state.showLegacyImport) { SettingsNavigationRow( - label = "Use this device's audiobook intervals", + label = "Use This Device's Audiobook Intervals", description = "${state.legacyAudiobookSummary}. Uploads them to this profile.", value = if (state.importInProgress) "Importing" else null, onClick = onImportLegacyAudiobook, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/ServerInfoSection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/ServerInfoSection.kt deleted file mode 100644 index d6c587ddeb..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/ServerInfoSection.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.siloserver.silo.android.ui.screens.settings - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.siloserver.silo.android.BuildConfig -import org.siloserver.silo.common.network.clientVersionLabel - -/** - * Connection section. Mirrors the iOS phone Settings `Server` row: a - * single teal-badged `server.rack` row whose trailing value is the - * active server label, with a disclosure chevron that opens the server - * list. - */ -@Composable -fun ServerInfoSection( - serverUrl: String, - onManageServersClick: () -> Unit = {}, - modifier: Modifier = Modifier, -) { - SettingsSectionCard(modifier = modifier) { - SettingsNavigationRow( - label = "Server", - description = "The Silo server this device is signed in to.", - value = serverUrl.ifBlank { "Not connected" }, - onClick = onManageServersClick, - ) - SettingsNavigationRow( - label = "Version", - description = "The app build running on this device.", - // Includes the build number so a support report and the server's - // admin Activity page name the exact same build, in the "1.0.0 (5)" - // form Play, TestFlight and the server's own diagnostics page all - // use. Unstamped local builds show the bare version rather than a - // meaningless "(0)", matching what those builds report. - value = clientVersionLabel(BuildConfig.VERSION_NAME, BuildConfig.BUILD_NUMBER), - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsAccountCard.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsAccountCard.kt new file mode 100644 index 0000000000..5ab01dc9a9 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsAccountCard.kt @@ -0,0 +1,139 @@ +package org.siloserver.silo.android.ui.screens.settings + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.siloserver.silo.android.ui.screens.profiles.ProfileAvatar +import org.siloserver.silo.android.ui.theme.SettingsDimens +import org.siloserver.silo.android.ui.theme.SettingsTextStyles +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.android.ui.theme.SiloSurfaceVariant +import org.siloserver.silo.common.ui.components.avatarRef +import org.siloserver.silo.model.auth.User +import org.siloserver.silo.model.profile.Profile + +/** + * The account card at the top of Settings (Apple `SettingsAccountCard`): the + * active profile's avatar and name, the account and server underneath, an + * "Admin" capsule for administrators, and a chevron. Tapping it switches + * profile. + * + * Sign Out sits at the foot of the overview and Pair Device under Connection, + * as on the Apple apps. Session management and the admin surface stay absent: + * both were removed from the Android clients outright. + */ +@Composable +fun SettingsAccountCard( + profile: Profile?, + user: User?, + serverHost: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val name = profile?.name?.takeIf { it.isNotBlank() } + ?: user?.username?.takeIf { it.isNotBlank() } + ?: "Switch Profile" + val username = user?.username?.takeIf { it.isNotBlank() } + val subtitle = when { + username != null && username != name && serverHost != null -> "$username · $serverHost" + serverHost != null -> serverHost + username != null && username != name -> username + else -> "Tap to switch profile" + } + val isAdministrator = user?.role.equals("admin", ignoreCase = true) + + SettingsSectionCard(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = SettingsDimens.overviewRowMinHeight) + .clickable(role = Role.Button, onClickLabel = "Switch profile", onClick = onClick) + .padding(horizontal = SettingsDimens.rowHorizontalPadding, vertical = SettingsDimens.rowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + if (profile != null) { + ProfileAvatar( + avatar = profile.avatarRef(), + name = profile.name, + size = SettingsDimens.avatarSize, + ) + } else { + Box( + modifier = Modifier + .size(SettingsDimens.avatarSize) + .clip(CircleShape) + .background(SiloSurfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = null, + tint = SiloSecondaryText, + modifier = Modifier.size(SettingsDimens.avatarSize / 2), + ) + } + } + + Spacer(modifier = Modifier.width(SettingsDimens.avatarGap)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), + ) { + Text( + text = name, + style = SettingsTextStyles.accountName, + color = SiloOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = SettingsTextStyles.rowDescription, + color = SiloSecondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (isAdministrator) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Text( + text = "Admin", + style = SettingsTextStyles.badge, + color = SiloSecondaryText, + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.14f)) + .padding(horizontal = 9.dp, vertical = 4.dp), + ) + } + + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + SettingsRowChevron() + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt new file mode 100644 index 0000000000..4fb02bbc50 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt @@ -0,0 +1,692 @@ +package org.siloserver.silo.android.ui.screens.settings + +import androidx.compose.foundation.background +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.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.siloserver.silo.android.ui.components.SiloDropdownMenu +import org.siloserver.silo.android.ui.theme.MenuDimens +import org.siloserver.silo.android.ui.theme.SettingsDimens +import org.siloserver.silo.android.ui.theme.SettingsTextStyles +import org.siloserver.silo.android.ui.theme.SiloDestructive +import org.siloserver.silo.android.ui.theme.SiloForeground +import org.siloserver.silo.android.ui.theme.SiloGroupedCell +import org.siloserver.silo.android.ui.theme.SiloGroupedSeparator +import org.siloserver.silo.android.ui.theme.SiloIconTile +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.android.ui.theme.SiloSwitchOff +import org.siloserver.silo.android.ui.theme.SiloSwitchOn +import kotlin.math.ceil + +// --- Shared Settings UI Components --- +// +// The Apple apps' Settings in Compose (silo-apple #546): an inset-grouped list +// on a black ground, one-line rows with the current value trailing, a +// title-case header above each group and an optional footer below it. Overview +// rows carry a graphite icon tile; sub-page rows are text only, and anything a +// row would once have explained in a description line belongs in its group's +// footer. Metrics live in `ui.theme.SettingsDimens` / `SettingsTextStyles`. + +/** Opacity of the disclosure chevron. */ +private const val ChevronAlpha = 0.35f + +/** Opacity of a menu row's up/down indicator, a little stronger than a chevron. */ +private const val MenuIndicatorAlpha = 0.55f + +/** + * Draws the grouped-list hairline along this element's top edge, inset from + * the leading edge to where the row's label starts (past the icon tile on an + * overview row) and from the trailing edge by the row padding. + */ +fun Modifier.settingsRowDivider( + show: Boolean, + startInset: Dp = SettingsDimens.dividerStartInset, +): Modifier = + if (!show) { + this + } else { + drawBehind { + val start = startInset.toPx() + val end = SettingsDimens.dividerEndInset.toPx() + drawRect( + color = SiloGroupedSeparator, + topLeft = Offset(start, 0f), + size = Size(size.width - start - end, SettingsDimens.separatorThickness.toPx()), + ) + } + } + +/** + * A settings group: a title-case header above the grouped rows and an + * optional footer below them, as in the Apple apps' inset-grouped lists. + */ +@Composable +fun SettingsSection( + title: String?, + modifier: Modifier = Modifier, + footer: String? = null, + footerColor: Color = SiloSecondaryText, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (!title.isNullOrBlank()) { + SettingsSectionHeader(title) + } + SettingsSectionCard(content = content) + if (!footer.isNullOrBlank()) { + SettingsSectionFooter(footer, color = footerColor) + } + } +} + +/** + * Grouped rows. + * + * Every row draws a hairline along its top edge, and the card paints over its + * own top edge, so whichever row is first right now — after search filters the + * list or a row swaps in place — never shows one. Counting rows instead goes + * stale as soon as the first row changes while the card stays composed. + */ +@Composable +fun SettingsSectionCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(SettingsDimens.groupRadius)) + .background(SiloGroupedCell) + .drawWithContent { + drawContent() + drawRect( + color = SiloGroupedCell, + size = Size(size.width, ceil(SettingsDimens.separatorThickness.toPx())), + ) + }, + content = content, + ) +} + +/** Section header: title case, semibold, secondary, aligned with the row text. */ +@Composable +fun SettingsSectionHeader(title: String) { + Text( + text = title, + style = SettingsTextStyles.sectionHeader, + color = SiloSecondaryText, + modifier = Modifier.padding( + start = SettingsDimens.headerStartInset, + end = SettingsDimens.headerStartInset, + bottom = SettingsDimens.headerBottomGap, + ), + ) +} + +/** Section footer: the explanation that would otherwise ride on each row. */ +@Composable +fun SettingsSectionFooter(text: String, color: Color = SiloSecondaryText) { + Text( + text = text, + style = SettingsTextStyles.rowDescription, + color = color, + modifier = Modifier.padding( + start = SettingsDimens.headerStartInset, + end = SettingsDimens.headerStartInset, + top = SettingsDimens.footerTopGap, + ), + ) +} + +/** The graphite tile behind an overview row's glyph (Apple `siloIconTile`). */ +@Composable +fun SettingsIconTile(icon: ImageVector, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(SettingsDimens.iconTileSize) + .clip(RoundedCornerShape(SettingsDimens.iconTileRadius)) + .background(SiloIconTile), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(SettingsDimens.iconGlyphSize), + ) + } +} + +/** Disclosure chevron. */ +@Composable +fun SettingsRowChevron(enabled: Boolean = true) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = SiloOnSurface.copy(alpha = ChevronAlpha * if (enabled) 1f else SettingsDimens.disabledAlpha), + modifier = Modifier.size(SettingsDimens.chevronSize), + ) +} + +/** + * The one settings row. + * + * Every other row type in this package is this one with a different trailing + * slot: an optional icon tile, the label, the current [value] trailing on the + * label's line, then the control. A hairline sits above every row but the + * group's first. + * + * @param description A second line under the label. The Apple lists put this + * in the group footer instead; it remains for rows whose explanation is + * per-item (a diagnostics report's date and size). + * @param showDivider Draws the hairline above the row. The group hides it on + * whichever row is first, so only a row that must never have one sets it + * false. + */ +@Composable +fun SettingsRow( + label: String, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + icon: ImageVector? = null, + labelColor: Color = SiloOnSurface, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + showDivider: Boolean = true, + trailing: @Composable RowScope.() -> Unit = {}, +) { + val contentAlpha = if (enabled) 1f else SettingsDimens.disabledAlpha + val dividerStart = if (icon != null) { + SettingsDimens.rowHorizontalPadding + SettingsDimens.iconTileSize + SettingsDimens.iconTileGap + } else { + SettingsDimens.dividerStartInset + } + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = if (icon != null) SettingsDimens.overviewRowMinHeight else SettingsDimens.rowMinHeight) + .settingsRowDivider(showDivider, startInset = dividerStart) + .then( + if (onClick != null) { + Modifier.clickable(enabled = enabled, onClick = onClick) + } else { + Modifier + }, + ) + .padding( + horizontal = SettingsDimens.rowHorizontalPadding, + vertical = SettingsDimens.rowVerticalPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + SettingsIconTile(icon) + Spacer(modifier = Modifier.width(SettingsDimens.iconTileGap)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = SettingsTextStyles.rowLabel, + color = labelColor.copy(alpha = labelColor.alpha * contentAlpha), + // Fills the line so the value stays trailing-aligned, and + // yields — by wrapping — when a capped value needs room. + modifier = Modifier.weight(1f), + ) + if (value != null) { + Spacer(modifier = Modifier.width(SettingsDimens.rowLabelValueGap)) + SettingsRowValue(value = value, enabled = enabled) + } + } + if (!description.isNullOrBlank()) { + Text( + text = description, + style = SettingsTextStyles.rowDescription, + color = SiloSecondaryText.copy(alpha = SiloSecondaryText.alpha * contentAlpha), + ) + } + } + trailing() + } +} + +/** A row that navigates somewhere, optionally showing the current value. */ +@Composable +fun SettingsNavigationRow( + label: String, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + icon: ImageVector? = null, + onClick: (() -> Unit)? = null, + showChevron: Boolean = onClick != null, + enabled: Boolean = true, + labelColor: Color = SiloOnSurface, +) { + SettingsRow( + label = label, + modifier = modifier, + description = description, + value = value, + icon = icon, + labelColor = labelColor, + enabled = enabled, + onClick = onClick, + ) { + if (showChevron) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + SettingsRowChevron(enabled = enabled) + } + } +} + +/** + * Destructive row: the label in [SiloDestructive] and no chevron, like the + * Apple apps' "Reset Playback Overrides". + */ +@Composable +fun SettingsDestructiveRow( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + enabled: Boolean = true, +) { + SettingsNavigationRow( + label = label, + modifier = modifier, + description = description, + value = value, + onClick = onClick, + showChevron = false, + enabled = enabled, + labelColor = SiloDestructive, + ) +} + +/** + * A full-width centred button row, the Apple apps' "Sign Out" group. + */ +@Composable +fun SettingsButtonRow( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + labelColor: Color = SiloDestructive, + enabled: Boolean = true, +) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = SettingsDimens.rowMinHeight) + .settingsRowDivider(show = true) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .padding(horizontal = SettingsDimens.rowHorizontalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = SettingsTextStyles.rowLabel, + color = labelColor.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), + textAlign = TextAlign.Center, + ) + } +} + +/** + * Trailing value text in the secondary colour, so a picker's current choice + * does not read as a second label. + * + * Unweighted, so [SettingsRow]'s label line measures it first: it gets the + * width it asks for up to [SettingsDimens.rowValueMaxWidth], and the label + * takes what is left. + */ +@Composable +private fun SettingsRowValue(value: String, enabled: Boolean) { + Text( + text = value, + style = SettingsTextStyles.rowValue, + color = SiloSecondaryText.copy( + alpha = SiloSecondaryText.alpha * if (enabled) 1f else SettingsDimens.disabledAlpha, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = SettingsDimens.rowValueMaxWidth), + ) +} + +/** + * Settings row with a switch. The whole row toggles, not just the thumb. + */ +@Composable +fun SettingsSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, +) { + SettingsRow( + label = label, + description = description, + icon = icon, + enabled = enabled, + modifier = modifier.toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + ) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Switch( + checked = checked, + // The row owns the gesture; the switch is the indicator. + onCheckedChange = null, + enabled = enabled, + // An empty thumb slot keeps the thumb full size in both states, + // the way Apple's switch thumb never shrinks. + thumbContent = {}, + colors = settingsSwitchColors(), + ) + } +} + +/** Apple's switch: green track while on, a white thumb in both states. */ +@Composable +private fun settingsSwitchColors() = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = SiloSwitchOn, + checkedBorderColor = Color.Transparent, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = SiloSwitchOff, + uncheckedBorderColor = Color.Transparent, + disabledCheckedThumbColor = Color.White.copy(alpha = SettingsDimens.disabledAlpha), + disabledCheckedTrackColor = SiloSwitchOn.copy(alpha = SettingsDimens.disabledAlpha), + disabledCheckedBorderColor = Color.Transparent, + disabledUncheckedThumbColor = Color.White.copy(alpha = SettingsDimens.disabledAlpha), + disabledUncheckedTrackColor = SiloSwitchOff.copy(alpha = SettingsDimens.disabledAlpha), + disabledUncheckedBorderColor = Color.Transparent, +) + +/** + * One option in a mutually exclusive set, marked with a trailing checkmark + * the way Apple's selection lists are. Like the switch row, the whole row is + * the target. + */ +@Composable +fun SettingsChoiceRow( + label: String, + selected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + enabled: Boolean = true, +) { + SettingsRow( + label = label, + description = description, + enabled = enabled, + modifier = modifier.selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = onSelect, + ), + ) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = SiloOnSurface.copy(alpha = if (selected) 1f else 0f), + modifier = Modifier.size(SettingsDimens.chevronSize), + ) + } +} + +/** + * A row that opens a menu of options: the current value and Apple's up/down + * menu indicator trailing, and a popup at the trailing edge that checks the + * current choice. + */ +@Composable +fun SettingsDropdownRow( + label: String, + value: String, + options: List, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + SettingsRow( + label = label, + description = description, + value = value, + icon = icon, + enabled = enabled, + onClick = { expanded = true }, + ) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Icon( + imageVector = Icons.Filled.UnfoldMore, + contentDescription = null, + tint = SiloOnSurface.copy( + alpha = MenuIndicatorAlpha * if (enabled) 1f else SettingsDimens.disabledAlpha, + ), + modifier = Modifier.size(SettingsDimens.menuIndicatorSize), + ) + } + + // Anchored at the row's trailing edge, beside the value it changes. + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + SiloDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + SettingsMenuItem( + label = option, + selected = option == value, + onClick = { + onOptionSelected(option) + expanded = false + }, + ) + } + } + } + } +} + +/** A popup-menu option with a leading checkmark on the current choice. */ +@Composable +private fun SettingsMenuItem(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .widthIn(min = MenuDimens.minWidth) + .heightIn(min = MenuDimens.rowMinHeight) + .clickable(onClick = onClick) + .padding(horizontal = MenuDimens.rowHorizontalPadding, vertical = MenuDimens.rowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.width(SettingsDimens.menuCheckWidth)) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected", + tint = SiloForeground, + modifier = Modifier.size(SettingsDimens.menuIndicatorSize), + ) + } + } + Text( + text = label, + style = SettingsTextStyles.menuLabel, + color = SiloForeground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * A prose block inside a group, for the notices that are explanation rather + * than setting. Carries a row's hairline. + */ +@Composable +fun SettingsProse( + body: String, + modifier: Modifier = Modifier, + title: String? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .settingsRowDivider(show = true) + .padding( + horizontal = SettingsDimens.proseHorizontalPadding, + vertical = SettingsDimens.proseVerticalPadding, + ), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), + ) { + if (title != null) { + Text( + text = title, + style = SettingsTextStyles.rowLabel, + color = SiloOnSurface, + ) + } + Text( + text = body, + style = SettingsTextStyles.rowDescription, + color = SiloSecondaryText, + ) + } +} + +/** + * The overview's search capsule (Apple `SettingsSearchField`): a magnifier, a + * "Search" placeholder, and a clear button once there is text. + */ +@Composable +fun SettingsSearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(SettingsDimens.searchFieldHeight) + .clip(CircleShape) + .background(SiloGroupedCell) + // Apple's search capsule insets its glyph 14pt. + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = SiloSecondaryText, + modifier = Modifier.size(SettingsDimens.menuIndicatorSize), + ) + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text(text = "Search", style = SettingsTextStyles.rowLabel, color = SiloSecondaryText) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = SettingsTextStyles.rowLabel.copy(color = SiloOnSurface), + cursorBrush = SolidColor(SiloOnSurface), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + imeAction = ImeAction.Search, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + if (query.isNotEmpty()) { + Icon( + imageVector = Icons.Filled.Cancel, + contentDescription = "Clear search", + tint = SiloSecondaryText, + modifier = Modifier + .size(SettingsDimens.menuIndicatorSize + 4.dp) + .clip(CircleShape) + .clickable { onQueryChange("") }, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt new file mode 100644 index 0000000000..4087af8b18 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt @@ -0,0 +1,368 @@ +package org.siloserver.silo.android.ui.screens.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Scaffold +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import org.koin.compose.koinInject +import org.siloserver.silo.android.ui.components.SiloConfirmDialog +import org.siloserver.silo.android.ui.components.SiloTopBar +import org.siloserver.silo.android.ui.screens.downloads.DownloadsViewModel +import org.siloserver.silo.android.ui.screens.home.HomeSectionsEditor +import org.siloserver.silo.android.ui.theme.SettingsDimens +import org.siloserver.silo.android.ui.theme.SiloSettingsBackground +import org.siloserver.silo.android.ui.util.formatBytes +import org.siloserver.silo.common.settings.TitleArtStore +import org.siloserver.silo.model.feature.MetadataAiFeatureStore +import org.siloserver.silo.model.metadata.MetadataAiOnView +import org.siloserver.silo.model.settings.SeekMedia + +// The sub-pages the Settings overview pushes, one per Apple Settings page. +// Each shares the overview's SettingsViewModel (AppNavigation hands it the +// overview's back-stack entry), so a change made here is the value the +// overview row shows on the way back. + +/** A Settings sub-page: a centred inline title and a grouped list. */ +@Composable +internal fun SettingsPageScaffold( + title: String, + onBackClick: () -> Unit, + content: LazyListScope.() -> Unit, +) { + Scaffold( + topBar = { + SiloTopBar( + title = title, + onBackClick = onBackClick, + containerColor = SiloSettingsBackground, + centerTitle = true, + ) + }, + containerColor = SiloSettingsBackground, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + start = SettingsDimens.pageGutter, + end = SettingsDimens.pageGutter, + top = SettingsDimens.pageTopPadding, + bottom = SettingsDimens.pageBottomSpacer, + ), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), + content = content, + ) + } +} + +/** + * Interface (Apple `InterfaceCustomizationView`): Cards & Posters, Home, then + * Title Pages. + */ +@Composable +fun SettingsInterfaceScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + var showHomeSectionsEditor by rememberSaveable { mutableStateOf(false) } + val titleArtStore: TitleArtStore = koinInject() + // Opening Interface is a refresh edge, so a title art choice made on + // another device shows here without waiting for the next foreground. Kept + // out of the LazyColumn item, which re-enters composition on scroll. + LaunchedEffect(titleArtStore) { titleArtStore.refresh() } + + SettingsPageScaffold(title = "Interface", onBackClick = onBackClick) { + item(key = "cards") { + MediaCardsSettings( + state = state.cardPresentation, + onPresetSelected = viewModel::setCardPreset, + onPosterSizeSelected = viewModel::setCardPosterSize, + onCaptionSelected = viewModel::setCardCaption, + onDeviceOnlyChanged = viewModel::setCardDeviceOnly, + onUseProfileDefault = viewModel::useCardProfileDefault, + ) + } + item(key = "home") { + SettingsSection( + title = "Home", + footer = "Choose which Home rows are visible and the order they appear in.", + ) { + SettingsNavigationRow( + label = "Home Sections", + onClick = { showHomeSectionsEditor = true }, + ) + } + } + item(key = "title-art") { + TitleArtSettingsSection(store = titleArtStore) + } + } + + if (showHomeSectionsEditor) { + HomeSectionsEditor(onDismiss = { showHomeSectionsEditor = false }) + } +} + +/** + * Playback (Apple `PlaybackSettingsView`): Streaming, Episodes, the Video and + * Audiobooks skip intervals, and the reset. + */ +@Composable +fun SettingsPlaybackScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + val seekIntervals by viewModel.seekIntervals.state.collectAsState() + + SettingsPageScaffold(title = "Playback", onBackClick = onBackClick) { + item(key = "streaming") { + PlaybackStreamingSection( + qualityResolution = state.qualityResolution, + maxBitrateKbps = state.maxBitrateKbps, + audioLanguage = state.audioLanguage, + audioLanguageSuggestions = state.audioLanguageSuggestions, + dolbyVisionEnabled = state.dolbyVisionEnabled, + dvProfile7HDR10Fallback = state.dvProfile7HDR10Fallback, + pictureInPictureEnabled = state.pictureInPictureEnabled, + onQualityPresetSelected = viewModel::setQualityPreset, + onAudioLanguageChanged = viewModel::setAudioLanguage, + onDolbyVisionEnabledChanged = viewModel::setDolbyVisionEnabled, + onDvProfile7HDR10FallbackChanged = viewModel::setDvProfile7HDR10Fallback, + onPictureInPictureEnabledChanged = viewModel::setPictureInPictureEnabled, + ) + } + item(key = "episodes") { + PlaybackEpisodesSection( + autoPlayNext = state.autoPlayNext, + nextUpPromptSeconds = state.nextUpPromptSeconds, + introSkipMode = state.introSkipMode, + autoSkipCredits = state.autoSkipCredits, + resumeRewindSeconds = state.resumeRewindSeconds, + passOutThreshold = state.passOutThreshold, + onAutoPlayNextChanged = viewModel::setAutoPlayNext, + onNextUpPromptSecondsChanged = viewModel::setNextUpPromptSeconds, + onIntroSkipModeChanged = viewModel::setIntroSkipMode, + onAutoSkipCreditsChanged = viewModel::setAutoSkipCredits, + onResumeRewindSecondsChanged = viewModel::setResumeRewindSeconds, + onPassOutThresholdChanged = viewModel::setPassOutThreshold, + ) + } + SeekMedia.entries.forEach { media -> + item(key = "seek-$media") { + SeekIntervalSettings( + media = media, + state = seekIntervals, + onIntervalSelected = viewModel.seekIntervals::select, + onImportLegacyAudiobook = viewModel.seekIntervals::importLegacyAudiobook, + onRetry = viewModel.seekIntervals::refresh, + ) + } + } + item(key = "reset") { + PlaybackResetSection(onResetPlaybackOverrides = viewModel::resetPlaybackOverrides) + } + } +} + +/** + * Subtitles (Apple `SubtitleSettingsView`): Profile, Metadata, Appearance, then + * the Text, Background, and Layout groups, which dim while the device caption + * style is in use. + */ +@Composable +fun SettingsSubtitlesScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + val metadataAiStore: MetadataAiFeatureStore = koinInject() + val metadataAiStatus by metadataAiStore.status.collectAsState() + val metadataLanguageEnabled = metadataAiStatus.enabled && metadataAiStatus.onView != MetadataAiOnView.Off + val customAppearance = !state.subtitleMatchesDevice + + SettingsPageScaffold(title = "Subtitles", onBackClick = onBackClick) { + item(key = "profile") { + SubtitleProfileSection( + subtitleLanguage = state.subtitleLanguage, + subtitleLanguageSuggestions = state.subtitleLanguageSuggestions, + subtitleMode = state.subtitleMode, + showForcedSubtitles = state.showForcedSubtitles, + onLanguageChanged = viewModel::setSubtitleLanguage, + onModeChanged = viewModel::setSubtitleMode, + onForcedSubtitlesChanged = viewModel::setShowForcedSubtitles, + ) + } + if (metadataLanguageEnabled) { + item(key = "metadata") { + SubtitleMetadataSection( + metadataLanguage = state.metadataLanguage, + metadataLanguageSuggestions = state.metadataLanguageSuggestions, + onMetadataLanguageChanged = viewModel::setMetadataLanguage, + ) + } + } + item(key = "appearance") { + SubtitleAppearanceSection( + previewAppearance = state.effectiveSubtitleAppearance, + subtitleMatchesDevice = state.subtitleMatchesDevice, + onSubtitleMatchesDeviceChanged = viewModel::setSubtitleMatchesDevice, + ) + } + item(key = "text") { + SubtitleTextSection( + appearance = state.subtitleAppearance, + enabled = customAppearance, + showTextOpacity = state.subtitleTextOpacitySupported, + onEdit = viewModel::editSubtitleAppearance, + ) + } + item(key = "background") { + SubtitleBackgroundSection( + appearance = state.subtitleAppearance, + enabled = customAppearance, + onEdit = viewModel::editSubtitleAppearance, + ) + } + item(key = "layout") { + SubtitleLayoutSection( + appearance = state.subtitleAppearance, + enabled = customAppearance, + onEdit = viewModel::editSubtitleAppearance, + ) + } + } +} + +/** Downloads (Apple `DownloadsSettingsView`): Downloads, Cleanup, Storage. */ +@Composable +fun SettingsDownloadsScreen( + viewModel: SettingsViewModel, + downloadsViewModel: DownloadsViewModel, + onBackClick: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val downloadsState by downloadsViewModel.uiState.collectAsState() + var showRemoveAllDownloadsConfirm by rememberSaveable { mutableStateOf(false) } + + SettingsPageScaffold(title = "Downloads", onBackClick = onBackClick) { + item(key = "downloads") { + SettingsSection(title = "Downloads", footer = "Quality used for new downloads.") { + SettingsSwitchRow( + label = "Download over Wi-Fi Only", + checked = state.downloadsWifiOnly, + onCheckedChange = viewModel::setDownloadsWifiOnly, + ) + SettingsDropdownRow( + label = "Quality", + value = state.defaultDownloadQuality, + options = state.downloadQualityOptions, + onOptionSelected = viewModel::setDefaultDownloadQuality, + ) + } + } + item(key = "cleanup") { + SettingsSection( + title = "Cleanup", + footer = "When off, the Downloads tab suggests freeing up space by removing items " + + "you've finished watching.", + ) { + SettingsSwitchRow( + label = "Keep Watched Downloads", + checked = state.keepWatchedDownloads, + onCheckedChange = viewModel::setKeepWatchedDownloads, + ) + } + } + item(key = "storage") { + SettingsSection(title = "Storage") { + SettingsNavigationRow(label = "Used", value = formatBytes(downloadsState.totalBytesUsed)) + if (!downloadsState.isEmpty || downloadsState.totalBytesUsed > 0L) { + SettingsDestructiveRow( + label = if (downloadsState.isRemovingAllDownloads) { + "Removing Downloads…" + } else { + "Remove All Downloads" + }, + enabled = !downloadsState.isRemovingAllDownloads, + onClick = { showRemoveAllDownloadsConfirm = true }, + ) + } + } + } + } + + if (showRemoveAllDownloadsConfirm) { + SiloConfirmDialog( + title = "Remove all downloads?", + body = "This removes ${formatBytes(downloadsState.totalBytesUsed)} of downloaded files " + + "from this device. Your library and server media stay intact.", + confirmLabel = if (downloadsState.isRemovingAllDownloads) "Removing…" else "Remove all", + confirmEnabled = !downloadsState.isRemovingAllDownloads, + onConfirm = { + showRemoveAllDownloadsConfirm = false + downloadsViewModel.removeAllDownloads() + }, + onDismiss = { showRemoveAllDownloadsConfirm = false }, + ) + } +} + +/** + * Notifications. The Apple apps have no in-app notification preferences; + * Android keeps them on their own page under Preferences. + */ +@Composable +fun SettingsNotificationsScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { + val state by viewModel.uiState.collectAsState() + + SettingsPageScaffold(title = "Notifications", onBackClick = onBackClick) { + item(key = "in-app") { + SettingsSection( + title = null, + footer = "Show alerts inside Silo as new releases arrive.", + ) { + SettingsSwitchRow( + label = "In-App Notifications", + checked = state.notificationsEnabled, + onCheckedChange = viewModel::setNotificationsEnabled, + ) + } + } + if (state.notificationsEnabled) { + item(key = "topics") { + SettingsSection( + title = "Notify Me About", + footer = "Favorites and Next Up cover new episodes of series you follow. Watchlist covers " + + "titles that become available. Continue Watching covers titles you started but " + + "haven't finished.", + ) { + SettingsSwitchRow( + label = "Favorites", + checked = state.notifyFavorites, + onCheckedChange = viewModel::setNotifyFavorites, + ) + SettingsSwitchRow( + label = "Watchlist", + checked = state.notifyWatchlist, + onCheckedChange = viewModel::setNotifyWatchlist, + ) + SettingsSwitchRow( + label = "Continue Watching", + checked = state.notifyContinueWatching, + onCheckedChange = viewModel::setNotifyContinueWatching, + ) + SettingsSwitchRow( + label = "Next Up", + checked = state.notifyNextUp, + onCheckedChange = viewModel::setNotifyNextUp, + ) + } + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index e52509093c..09efd0cfc5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -1,95 +1,89 @@ package org.siloserver.silo.android.ui.screens.settings -import org.siloserver.silo.model.settings.SeekMedia -import androidx.compose.foundation.background -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.ColumnScope import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope 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.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.ArrowCircleDown +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.CollectionsBookmark +import androidx.compose.material.icons.filled.ConnectedTv +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MonitorHeart +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PanTool +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import org.siloserver.silo.android.ui.components.SiloConfirmDialog +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.BuildConfig +import org.siloserver.silo.android.ui.components.SignOutConfirmDialog import org.siloserver.silo.android.ui.components.SiloTopBar -import org.siloserver.silo.android.ui.screens.downloads.DownloadsViewModel -import org.siloserver.silo.android.ui.screens.home.HomeSectionsEditor import org.siloserver.silo.android.ui.screens.settings.diagnostics.DiagnosticsViewModel import org.siloserver.silo.android.ui.screens.settings.diagnostics.shouldShowDiagnosticsEntry import org.siloserver.silo.android.ui.theme.SettingsDimens import org.siloserver.silo.android.ui.theme.SettingsTextStyles -import org.siloserver.silo.android.ui.theme.SiloBorder -import org.siloserver.silo.android.ui.theme.SiloDestructive -import org.siloserver.silo.android.ui.theme.SiloForeground -import org.siloserver.silo.android.ui.theme.SiloMutedText +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.android.ui.theme.SiloSettingsBackground -import org.siloserver.silo.android.ui.theme.SiloSurfaceContainer -import org.siloserver.silo.android.ui.theme.SiloSurfaceContainerHigh -import org.siloserver.silo.android.ui.theme.siloRowTopDivider -import org.siloserver.silo.android.ui.util.formatBytes -import org.siloserver.silo.model.download.DownloadQuality -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.siloserver.silo.common.settings.TitleArtStore -import org.siloserver.silo.model.feature.MetadataAiFeatureStore -import org.siloserver.silo.model.metadata.MetadataAiOnView +import org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi +import org.siloserver.silo.common.network.clientVersionLabel +import org.siloserver.silo.common.player.rememberPlaybackRecoverySettings +import org.siloserver.silo.common.settings.CardPresentationSupport +import org.siloserver.silo.model.settings.LanguageOptions +import org.siloserver.silo.model.settings.QualityPresets +import org.siloserver.silo.model.settings.SettingKeys + +/** Silo's privacy policy, linked from About and from Diagnostics. */ +internal const val SILO_PRIVACY_POLICY_URL = "https://siloserver.org/privacy" /** - * Main settings screen organized in grouped sections. - * - * This screen is used as tab content within MainScreen (Settings tab) - * and does NOT have its own top bar back button since it's a tab root. + * The Settings overview, laid out like the Apple apps' (silo-apple + * `IOSSettingsOverview`): a large title, the account card, a search field, + * then grouped one-line rows with graphite icon tiles that open the sub-pages. * - * @param onLoggedOut Called after the user signs out. - * @param showTopBar Whether to show the top bar (false when inside MainScreen tab). - * @param onBackClick Back navigation handler for standalone mode. + * Android adds what the Apple apps keep elsewhere: Notifications, the Library + * shortcuts (Watchlist, Favorites, History, Collections — reachable only from + * here on the phone), Pair Device, and playback-stop recovery. Each sits in the + * group it belongs to rather than in a group of its own. */ @Composable fun SettingsScreen( onLoggedOut: () -> Unit, + onBackClick: (() -> Unit)? = null, + onOpenInterface: () -> Unit = {}, + onOpenPlayback: () -> Unit = {}, + onOpenSubtitles: () -> Unit = {}, + onOpenDownloads: () -> Unit = {}, + onOpenNotifications: () -> Unit = {}, onNavigateToServers: () -> Unit = {}, onPairDevice: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, @@ -98,31 +92,19 @@ fun SettingsScreen( onNavigateToHistory: () -> Unit = {}, onNavigateToCollections: () -> Unit = {}, onNavigateToDiagnostics: () -> Unit = {}, - showTopBar: Boolean = false, - onBackClick: (() -> Unit)? = null, viewModel: SettingsViewModel = koinViewModel(), - downloadsViewModel: DownloadsViewModel = koinViewModel(), diagnosticsViewModel: DiagnosticsViewModel = koinViewModel(), ) { - val recovery = org.siloserver.silo.common.player.rememberPlaybackRecoverySettings(org.koin.compose.koinInject()) + val recovery = rememberPlaybackRecoverySettings(koinInject()) val state by viewModel.uiState.collectAsState() - var subtitleStyleVisible by remember { mutableStateOf(false) } - org.siloserver.silo.android.ui.screens.player.SubtitleStyleSheet( - isVisible = subtitleStyleVisible, - appearance = state.subtitleAppearance, - showTextOpacity = state.subtitleTextOpacitySupported, - onUpdate = viewModel::editSubtitleAppearance, - onDismiss = { subtitleStyleVisible = false }, - ) - val downloadsState by downloadsViewModel.uiState.collectAsState() val diagnosticsState by diagnosticsViewModel.state.collectAsState() - var showRemoveAllDownloadsConfirm by remember { mutableStateOf(false) } - var showHomeSectionsEditor by remember { mutableStateOf(false) } - val titleArtStore: TitleArtStore = koinInject() - // Opening Settings is a refresh edge, so a title art choice made on - // another device shows here without waiting for the next foreground. Kept - // out of the LazyColumn item, which re-enters composition on scroll. - LaunchedEffect(titleArtStore) { titleArtStore.refresh() } + val uriHandler = LocalUriHandler.current + var query by rememberSaveable { mutableStateOf("") } + var confirmSignOut by rememberSaveable { mutableStateOf(false) } + val listState = rememberLazyListState() + // The large title scrolls away and the bar takes the title over, the way + // a large navigation title collapses to an inline one. + val titleScrolledAway by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } LaunchedEffect(state.loggedOut) { if (state.loggedOut) { @@ -131,784 +113,323 @@ fun SettingsScreen( } } + val search = SettingsSearch(query) + val versionLabel = clientVersionLabel(BuildConfig.VERSION_NAME, BuildConfig.BUILD_NUMBER) + val serverLabel = state.serverName.ifBlank { serverHost(state.serverUrl) ?: "Not connected" } + val showDiagnostics = shouldShowDiagnosticsEntry(diagnosticsState) && + search.matches("diagnostics", "support", "reports", "debug", "crash") + val showRecovery = recovery.visible && + search.matches("playback", "recovery", "retry", "stops", "support") + + val matchesInterface = search.matches( + "interface", "appearance", "cards", "posters", "captions", "home", "sections", + "title art", "logos", + ) + val matchesNotifications = state.notificationsAvailable && search.matches( + "notifications", "alerts", "favorites", "watchlist", "continue watching", "next up", + ) + val matchesPlayback = search.matches( + "playback", "quality", "audio", "dolby vision", "picture-in-picture", "episodes", "next up", + "skip", "intros", "credits", "rewind", "still watching", "skip interval", "audiobooks", + ) + val matchesSubtitles = search.matches( + "subtitles", "captions", "language", "behavior", "appearance", "forced", "metadata", + ) + val matchesDownloads = search.matches("downloads", "offline", "quality", "wi-fi", "cleanup", "storage") + val matchesWatchlist = search.matches("library", "watchlist") + val matchesFavorites = search.matches("library", "favorites") + val matchesHistory = search.matches("library", "history", "watch history") + val matchesCollections = search.matches("library", "collections") + val matchesServer = search.matches("server", "connection", serverLabel) + val matchesPairDevice = search.matches("connection", "pair", "device", "tv", "link") + val matchesExperimental = search.matches("experimental", "beta", "testing", "audiobooks", "navigation") + val matchesAbout = search.matches("about", "version", versionLabel, "privacy", "policy", "information") + val matchesSignOut = search.matches("sign out", "account") + + val hasResults = matchesInterface || matchesNotifications || matchesPlayback || matchesSubtitles || + matchesDownloads || matchesWatchlist || matchesFavorites || matchesHistory || matchesCollections || + showDiagnostics || showRecovery || matchesServer || matchesPairDevice || matchesExperimental || + matchesAbout || matchesSignOut + Scaffold( topBar = { - if (showTopBar) { - SiloTopBar( - title = "Settings", - onBackClick = onBackClick, - containerColor = SiloSettingsBackground, - ) - } + SiloTopBar( + title = if (titleScrolledAway) "Settings" else "", + onBackClick = onBackClick, + containerColor = SiloSettingsBackground, + centerTitle = true, + ) }, containerColor = SiloSettingsBackground, ) { padding -> LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() .padding(padding), - contentPadding = PaddingValues(SettingsDimens.pageGutter), + contentPadding = PaddingValues( + start = SettingsDimens.pageGutter, + end = SettingsDimens.pageGutter, + bottom = SettingsDimens.pageBottomSpacer, + ), verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), ) { - item { - if (!showTopBar) { + item(key = "title") { + Column { Text( text = "Settings", - style = MaterialTheme.typography.displayMedium, - color = SiloForeground, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding( - top = SettingsDimens.pageTopPadding, - bottom = SettingsDimens.headerStartInset, - ), + style = SettingsTextStyles.largeTitle, + color = SiloOnSurface, + ) + Spacer(modifier = Modifier.height(SettingsDimens.headerBottomGap)) + SettingsAccountCard( + profile = state.activeProfile, + user = state.user, + serverHost = serverHost(state.serverUrl), + onClick = onSwitchProfile, ) } } - item { - AccountSection( - onSwitchProfile = onSwitchProfile, - user = state.user, - isLoadingUser = state.isLoadingUser, - onPairDevice = onPairDevice, - onSignOut = viewModel::logout, - ) + item(key = "search") { + SettingsSearchField(query = query, onQueryChange = { query = it }) } - if (recovery.visible) { - item { - SettingsSectionCard { - SettingsNavigationRow( - label = if (recovery.busy) "Recovering playback…" else "Retry pending playback stops", - description = recovery.message, - onClick = recovery.retry, - enabled = !recovery.busy, - ) - } + if (!hasResults) { + item(key = "no-results") { + Text( + text = "No Results for “${query.trim()}”", + style = SettingsTextStyles.rowLabel, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 36.dp), + ) } } - if (shouldShowDiagnosticsEntry(diagnosticsState)) { - item { - SettingsSectionCard { - SettingsNavigationRow( - label = "Diagnostics", - description = "Capture and review a report when something goes wrong.", - value = when (diagnosticsState.availability) { - org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi.AVAILABLE -> "Available" - org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi.DISABLED -> "Disabled" - org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Unavailable" - org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi.OFFLINE -> "Offline" - org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi.INELIGIBLE -> null - }, - onClick = onNavigateToDiagnostics, - ) + + if (matchesInterface || matchesNotifications) { + item(key = "preferences") { + SettingsSection(title = "Preferences") { + if (matchesInterface) { + SettingsNavigationRow( + label = "Interface", + icon = Icons.Filled.Dashboard, + value = state.cardPresentation.takeIf { + it.support != CardPresentationSupport.Unsupported + }?.let { it.presentation.preset?.displayName ?: "Custom" }, + onClick = onOpenInterface, + ) + } + if (matchesNotifications) { + SettingsNavigationRow( + label = "Notifications", + icon = Icons.Filled.Notifications, + value = if (state.notificationsEnabled) "On" else "Off", + onClick = onOpenNotifications, + ) + } } } } - item { - PlaybackSettings( - qualityResolution = state.qualityResolution, - maxBitrateKbps = state.maxBitrateKbps, - audioLanguage = state.audioLanguage, - audioLanguageSuggestions = state.audioLanguageSuggestions, - introSkipMode = state.introSkipMode, - autoSkipCredits = state.autoSkipCredits, - pictureInPictureEnabled = state.pictureInPictureEnabled, - dolbyVisionEnabled = state.dolbyVisionEnabled, - dvProfile7HDR10Fallback = state.dvProfile7HDR10Fallback, - autoPlayNext = state.autoPlayNext, - nextUpPromptSeconds = state.nextUpPromptSeconds, - resumeRewindSeconds = state.resumeRewindSeconds, - passOutThreshold = state.passOutThreshold, - onQualityPresetSelected = viewModel::setQualityPreset, - onAudioLanguageChanged = viewModel::setAudioLanguage, - onIntroSkipModeChanged = viewModel::setIntroSkipMode, - onAutoSkipCreditsChanged = viewModel::setAutoSkipCredits, - onPictureInPictureEnabledChanged = viewModel::setPictureInPictureEnabled, - onDolbyVisionEnabledChanged = viewModel::setDolbyVisionEnabled, - onDvProfile7HDR10FallbackChanged = viewModel::setDvProfile7HDR10Fallback, - onAutoPlayNextChanged = viewModel::setAutoPlayNext, - onNextUpPromptSecondsChanged = viewModel::setNextUpPromptSeconds, - onResumeRewindSecondsChanged = viewModel::setResumeRewindSeconds, - onPassOutThresholdChanged = viewModel::setPassOutThreshold, - onResetPlaybackOverrides = viewModel::resetPlaybackOverrides, - ) - } - - item { - val seekIntervals by viewModel.seekIntervals.state.collectAsState() - SeekIntervalSettings( - media = SeekMedia.Video, - state = seekIntervals, - onIntervalSelected = viewModel.seekIntervals::select, - onImportLegacyAudiobook = viewModel.seekIntervals::importLegacyAudiobook, - onRetry = viewModel.seekIntervals::refresh, - ) - } - - item { - val seekIntervals by viewModel.seekIntervals.state.collectAsState() - SeekIntervalSettings( - media = SeekMedia.Audiobook, - state = seekIntervals, - onIntervalSelected = viewModel.seekIntervals::select, - onImportLegacyAudiobook = viewModel.seekIntervals::importLegacyAudiobook, - onRetry = viewModel.seekIntervals::refresh, - ) - } - - item { - val metadataAiStore: MetadataAiFeatureStore = koinInject() - val metadataAiStatus by metadataAiStore.status.collectAsState() - SubtitleSettings( - subtitleLanguage = state.subtitleLanguage, - subtitleLanguageSuggestions = state.subtitleLanguageSuggestions, - subtitleMode = state.subtitleMode, - showForcedSubtitles = state.showForcedSubtitles, - onLanguageChanged = viewModel::setSubtitleLanguage, - onModeChanged = viewModel::setSubtitleMode, - onForcedSubtitlesChanged = viewModel::setShowForcedSubtitles, - subtitleMatchesDevice = state.subtitleMatchesDevice, - onSubtitleMatchesDeviceChanged = viewModel::setSubtitleMatchesDevice, - onOpenSubtitleAppearance = { subtitleStyleVisible = true }, - metadataLanguageEnabled = metadataAiStatus.enabled && - metadataAiStatus.onView != MetadataAiOnView.Off, - metadataLanguage = state.metadataLanguage, - metadataLanguageSuggestions = state.metadataLanguageSuggestions, - onMetadataLanguageChanged = viewModel::setMetadataLanguage, - ) - } - - item { - SettingsSection(title = "Library") { - SettingsNavigationRow( - label = "Watchlist", - description = "Titles you saved to watch later.", - onClick = onNavigateToWatchlist, - ) - SettingsNavigationRow( - label = "Favorites", - description = "Titles you marked as favorites.", - onClick = onNavigateToFavorites, - ) - SettingsNavigationRow( - label = "Watch history", - description = "Everything you have played, most recent first.", - onClick = onNavigateToHistory, - ) - SettingsNavigationRow( - label = "Collections", - description = "Curated groups of titles from your libraries.", - onClick = onNavigateToCollections, - ) - SettingsSwitchRow( - label = "Show audiobooks", - description = "Show the Audiobooks section in navigation.", - checked = state.showAudiobooks, - onCheckedChange = viewModel::setShowAudiobooks, - ) + if (matchesPlayback || matchesSubtitles || matchesDownloads) { + item(key = "playback") { + SettingsSection(title = "Playback") { + if (matchesPlayback) { + SettingsNavigationRow( + label = "Playback", + icon = Icons.Filled.PlayArrow, + value = QualityPresets.describe(state.qualityResolution, state.maxBitrateKbps), + onClick = onOpenPlayback, + ) + } + if (matchesSubtitles) { + SettingsNavigationRow( + label = "Subtitles", + icon = Icons.Filled.Subtitles, + value = LanguageOptions.label(state.subtitleLanguage, SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + onClick = onOpenSubtitles, + ) + } + if (matchesDownloads) { + SettingsNavigationRow( + label = "Downloads", + icon = Icons.Filled.ArrowCircleDown, + onClick = onOpenDownloads, + ) + } + } } } - item { - SettingsSection(title = "Interface") { - SettingsNavigationRow( - label = "Home Sections", - description = "Choose which Home rows are visible and the order they appear in.", - onClick = { showHomeSectionsEditor = true }, - ) - TitleArtSettingsRows(store = titleArtStore) + if (matchesWatchlist || matchesFavorites || matchesHistory || matchesCollections) { + item(key = "library") { + SettingsSection(title = "Library") { + if (matchesWatchlist) { + SettingsNavigationRow( + label = "Watchlist", + icon = Icons.Filled.Bookmark, + onClick = onNavigateToWatchlist, + ) + } + if (matchesFavorites) { + SettingsNavigationRow( + label = "Favorites", + icon = Icons.Filled.Favorite, + onClick = onNavigateToFavorites, + ) + } + if (matchesHistory) { + SettingsNavigationRow( + label = "Watch History", + icon = Icons.Filled.History, + onClick = onNavigateToHistory, + ) + } + if (matchesCollections) { + SettingsNavigationRow( + label = "Collections", + icon = Icons.Filled.CollectionsBookmark, + onClick = onNavigateToCollections, + ) + } + } } } - item { - MediaCardsSettings( - state = state.cardPresentation, - onPresetSelected = viewModel::setCardPreset, - onPosterSizeSelected = viewModel::setCardPosterSize, - onCaptionSelected = viewModel::setCardCaption, - onDeviceOnlyChanged = viewModel::setCardDeviceOnly, - onUseProfileDefault = viewModel::useCardProfileDefault, - ) - } - - if (state.notificationsAvailable) { - item { - SettingsSection(title = "Notifications") { - SettingsSwitchRow( - label = "In-app notifications", - description = "Show alerts inside Silo as new releases arrive.", - checked = state.notificationsEnabled, - onCheckedChange = viewModel::setNotificationsEnabled, - ) - if (state.notificationsEnabled) { - SettingsSwitchRow( - label = "Favorites", - description = "Notify when something you favorited has a new episode.", - checked = state.notifyFavorites, - onCheckedChange = viewModel::setNotifyFavorites, + if (showDiagnostics || showRecovery) { + item(key = "support") { + SettingsSection( + title = "Support", + footer = recovery.message.takeIf { showRecovery }, + ) { + if (showDiagnostics) { + SettingsNavigationRow( + label = "Diagnostics", + icon = Icons.Filled.MonitorHeart, + value = diagnosticsAvailabilityLabel(diagnosticsState.availability), + onClick = onNavigateToDiagnostics, ) - SettingsSwitchRow( - label = "Watchlist", - description = "Notify when something on your watchlist becomes available.", - checked = state.notifyWatchlist, - onCheckedChange = viewModel::setNotifyWatchlist, + } + if (showRecovery) { + SettingsNavigationRow( + label = if (recovery.busy) "Recovering Playback…" else "Retry Pending Playback Stops", + icon = Icons.Filled.Restore, + onClick = recovery.retry, + enabled = !recovery.busy, ) - SettingsSwitchRow( - label = "Continue watching", - description = "Notify about titles you started but have not finished.", - checked = state.notifyContinueWatching, - onCheckedChange = viewModel::setNotifyContinueWatching, + } + } + } + } + + if (matchesServer || matchesPairDevice) { + item(key = "connection") { + SettingsSection(title = "Connection") { + if (matchesServer) { + SettingsNavigationRow( + label = "Server", + icon = Icons.Filled.Dns, + value = serverLabel, + onClick = onNavigateToServers, ) - SettingsSwitchRow( - label = "Next up", - description = "Notify when the next episode of a series you watch arrives.", - checked = state.notifyNextUp, - onCheckedChange = viewModel::setNotifyNextUp, + } + if (matchesPairDevice) { + SettingsNavigationRow( + label = "Pair Device", + icon = Icons.Filled.ConnectedTv, + onClick = onPairDevice, ) } } } } - item { - SettingsSection(title = "Downloads") { - SettingsDropdownRow( - label = "Download quality", - description = "Quality preset used for new downloads.", - value = state.defaultDownloadQuality, - options = state.downloadQualityOptions, - onOptionSelected = viewModel::setDefaultDownloadQuality, - ) - SettingsSwitchRow( - label = "Download over Wi-Fi only", - description = "Only download while connected to Wi-Fi.", - checked = state.downloadsWifiOnly, - onCheckedChange = viewModel::setDownloadsWifiOnly, - ) - SettingsSwitchRow( - label = "Keep watched downloads", - description = "Do not suggest reclaiming space from downloads you have finished.", - checked = state.keepWatchedDownloads, - onCheckedChange = viewModel::setKeepWatchedDownloads, - ) - if (!downloadsState.isEmpty || downloadsState.totalBytesUsed > 0L) { - SettingsDestructiveRow( - label = if (downloadsState.isRemovingAllDownloads) { - "Removing downloads…" - } else { - "Remove all downloads" - }, - description = "Delete every downloaded file from this device.", - value = formatBytes(downloadsState.totalBytesUsed), - enabled = !downloadsState.isRemovingAllDownloads, - onClick = { showRemoveAllDownloadsConfirm = true }, + if (matchesExperimental) { + item(key = "experimental") { + SettingsSection(title = "Experimental") { + SettingsSwitchRow( + label = "Show Audiobooks", + icon = Icons.AutoMirrored.Filled.MenuBook, + checked = state.showAudiobooks, + onCheckedChange = viewModel::setShowAudiobooks, ) } } } - item { - ServerInfoSection( - serverUrl = state.serverUrl, - onManageServersClick = onNavigateToServers, - ) + if (matchesAbout) { + item(key = "about") { + SettingsSection(title = "About") { + // Includes the build number so a support report and the + // server's admin Activity page name the exact same build, + // in the "1.0.0 (5)" form Play, TestFlight and the server's + // diagnostics page all use. + SettingsNavigationRow( + label = "Version", + icon = Icons.Filled.Info, + value = versionLabel, + ) + SettingsNavigationRow( + label = "Privacy Policy", + icon = Icons.Filled.PanTool, + onClick = { uriHandler.openUri(SILO_PRIVACY_POLICY_URL) }, + ) + } + } } - // Bottom spacing - item { Spacer(modifier = Modifier.height(SettingsDimens.pageBottomSpacer)) } - } - } - - if (showRemoveAllDownloadsConfirm) { - SiloConfirmDialog( - title = "Remove all downloads?", - body = "This removes ${formatBytes(downloadsState.totalBytesUsed)} of downloaded files " + - "from this device. Your library and server media stay intact.", - confirmLabel = if (downloadsState.isRemovingAllDownloads) "Removing…" else "Remove all", - confirmEnabled = !downloadsState.isRemovingAllDownloads, - onConfirm = { - showRemoveAllDownloadsConfirm = false - downloadsViewModel.removeAllDownloads() - }, - onDismiss = { showRemoveAllDownloadsConfirm = false }, - ) - } - - if (showHomeSectionsEditor) { - HomeSectionsEditor(onDismiss = { showHomeSectionsEditor = false }) - } -} - -// --- Shared Settings UI Components --- -// -// The grouped surface these build is the Silo web client's, adapted to Android -// row mechanics: an opaque card on a lifted page ground, a lettered heading -// above rather than inside it, a label over a muted description, and the -// control kept trailing rather than stacked underneath the way the web layout -// stacks it. Metrics live in `ui.theme.SettingsDimens` / `SettingsTextStyles`. -// -// There is deliberately no leading icon on any row. The web client puts icons -// only in its settings *sidebar*, never on a row; Android has no sidebar, so -// its destination rows sit inline among the value rows and an icon on half of -// them is exactly the "iOS Settings at the ends, unstyled form in the middle" -// split this pass removed. The description line is the scanning aid now, and -// the trailing affordance (chevron / value / switch) is what separates a -// destination from a setting. - -/** - * Per-card row counter backing the "no divider above the first row" rule. - * - * Rows claim a slot on first composition and remember it, so the index is - * stable across recomposition and follows source order within the card. A card - * whose *first* row is conditional would need [SettingsRow]'s `showDivider` - * override — no section does that today, since every card's opening row is - * unconditional. - */ -@Stable -internal class SettingsSectionSlots { - private var next = 0 - - fun claim(): Int = next++ -} - -internal val LocalSettingsSectionSlots = staticCompositionLocalOf { null } - -/** True for the first row composed into the enclosing [SettingsSectionCard]. */ -@Composable -private fun isFirstSettingsRow(): Boolean { - val slots = LocalSettingsSectionSlots.current ?: return true - return remember(slots) { slots.claim() } == 0 -} - -/** - * Claims a row slot for a card child that is not a [SettingsRow] — the account - * header, a prose pane — and reports whether it should draw a hairline above - * itself. A custom child that skips this is invisible to the divider rule, and - * the row after it would wrongly believe it is the card's first. - * - * Pair with [settingsRowDivider]. - */ -@Composable -fun settingsRowDividerVisible(): Boolean = !isFirstSettingsRow() - -/** Draws the standard inter-row hairline along this element's top edge. */ -fun Modifier.settingsRowDivider(show: Boolean): Modifier = settingsRowTopDivider(show) - -// The hairline itself lives in `ui.theme` beside the tokens it draws with: -// the popup menus rule their rows the same way, and one line rendered by two -// implementations is how two lines end up different. -private fun Modifier.settingsRowTopDivider(show: Boolean): Modifier = siloRowTopDivider(show) - -/** - * A settings group: a lettered heading sitting above its card. - * - * The heading used to render *inside* the card as its first child, which is - * what made every section start with a stray caps line on the same surface as - * the rows. Its own KDoc always described the intended placement. - */ -@Composable -fun SettingsSection( - title: String?, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - Column(modifier = modifier.fillMaxWidth()) { - if (!title.isNullOrBlank()) { - SettingsSectionHeader(title) - } - SettingsSectionCard(content = content) - } -} - -/** - * Card container for a settings section, and the owner of the row dividers. - */ -@Composable -fun SettingsSectionCard( - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - val slots = remember { SettingsSectionSlots() } - CompositionLocalProvider(LocalSettingsSectionSlots provides slots) { - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(SettingsDimens.cardRadius)) - .background(SiloSurfaceContainer), - content = content, - ) - } -} - -/** - * Section heading — uppercased, letter-spaced, muted, sitting above the card - * with a small inset. - */ -@Composable -fun SettingsSectionHeader(title: String) { - Text( - text = title.uppercase(), - style = SettingsTextStyles.sectionHeader, - color = SiloMutedText, - modifier = Modifier.padding( - start = SettingsDimens.headerStartInset, - end = SettingsDimens.headerStartInset, - bottom = SettingsDimens.headerBottomGap, - ), - ) -} - -/** - * Disclosure chevron. - */ -@Composable -fun SettingsRowChevron(enabled: Boolean = true) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = SiloMutedText.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), - modifier = Modifier.size(SettingsDimens.chevronSize), - ) -} - -/** - * The one settings row. - * - * Every other row type in this package is this one with a different trailing - * slot: label over an optional description, control trailing, a 60dp floor so - * a described row and a bare row still read as the same list, and a hairline - * above every row but the card's first. - * - * A trailing [value] shares the *label's* line rather than sitting beside the - * whole text block. That is the structural half of a real defect: with the - * value beside the block, a long one ("30 seconds before end") squeezed the - * description into a narrow column whose last line then ended a few dp from - * the value, and the two read as touching. On the label's line the value can - * never abut the description — the description runs the full width beneath it - * — and the wider column costs a line rather than adding one, so the page gets - * shorter, not taller. Only [trailing] controls (chevron, switch, radio) sit - * beside the block now, and an icon at [SettingsDimens.rowTrailingGap] does - * not read as a collision the way text does. - * - * @param showDivider Overrides the automatic first-row rule. Only needed in a - * card whose opening row is conditional. - */ -@Composable -fun SettingsRow( - label: String, - modifier: Modifier = Modifier, - description: String? = null, - value: String? = null, - labelColor: Color = SiloForeground, - enabled: Boolean = true, - onClick: (() -> Unit)? = null, - showDivider: Boolean? = null, - trailing: @Composable RowScope.() -> Unit = {}, -) { - val contentAlpha = if (enabled) 1f else SettingsDimens.disabledAlpha - val divider = showDivider ?: !isFirstSettingsRow() - Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = SettingsDimens.rowMinHeight) - .settingsRowTopDivider(divider) - .then( - if (onClick != null) { - Modifier.clickable(enabled = enabled, onClick = onClick) - } else { - Modifier - }, - ) - .padding( - horizontal = SettingsDimens.rowHorizontalPadding, - vertical = SettingsDimens.rowVerticalPadding, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = SettingsTextStyles.rowLabel, - color = labelColor.copy(alpha = contentAlpha), - // Fills the line so the value stays trailing-aligned, and - // yields — by wrapping — when a capped value needs room. - modifier = Modifier.weight(1f), - ) - if (value != null) { - Spacer(modifier = Modifier.width(SettingsDimens.rowLabelValueGap)) - SettingsRowValue(value = value, enabled = enabled) + if (matchesSignOut) { + item(key = "sign-out") { + SettingsSectionCard { + SettingsButtonRow(label = "Sign Out", onClick = { confirmSignOut = true }) + } } } - if (!description.isNullOrBlank()) { - Text( - text = description, - style = SettingsTextStyles.rowDescription, - color = SiloMutedText.copy(alpha = contentAlpha), - ) - } } - trailing() } -} -/** - * A row that navigates somewhere, optionally showing the current value. - * - * Replaces the old `SettingsRowLabel` (iOS coloured badge) and - * `SettingsClickableRow` (bare 20dp icon), which differed only in their - * leading treatment. - */ -@Composable -fun SettingsNavigationRow( - label: String, - modifier: Modifier = Modifier, - description: String? = null, - value: String? = null, - onClick: (() -> Unit)? = null, - showChevron: Boolean = onClick != null, - enabled: Boolean = true, - labelColor: Color = SiloForeground, -) { - SettingsRow( - label = label, - modifier = modifier, - description = description, - value = value, - labelColor = labelColor, - enabled = enabled, - onClick = onClick, - ) { - if (showChevron) { - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - SettingsRowChevron(enabled = enabled) - } - } -} - -/** - * Destructive row. One tint, [SiloDestructive], for every destructive action - * on this surface — Sign out, Reset playback settings, Remove all downloads — - * which previously used three different layouts and two different reds. - */ -@Composable -fun SettingsDestructiveRow( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - value: String? = null, - enabled: Boolean = true, -) { - SettingsNavigationRow( - label = label, - modifier = modifier, - description = description, - value = value, - onClick = onClick, - showChevron = false, - enabled = enabled, - labelColor = SiloDestructive, + // Same dialog the profile menu raises, so the answer to "are you sure?" + // does not depend on which of the two routes was taken. + SignOutConfirmDialog( + visible = confirmSignOut, + accountName = state.user?.username, + onConfirm = { + confirmSignOut = false + viewModel.logout() + }, + onDismiss = { confirmSignOut = false }, ) } /** - * Trailing value text — smaller and muted, so a picker's current choice does - * not read as a second label. - * - * Unweighted, so [SettingsRow]'s label line measures it first: it gets the - * width it asks for up to [SettingsDimens.rowValueMaxWidth], and the label - * takes what is left. + * The overview search (Apple `IOSSettingsOverview.matches`): a row shows when + * any of its keywords contains the trimmed query, case-insensitively. An + * empty query shows everything. */ -@Composable -private fun SettingsRowValue(value: String, enabled: Boolean) { - Text( - text = value, - style = SettingsTextStyles.rowValue, - color = SiloMutedText.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.End, - modifier = Modifier.widthIn(max = SettingsDimens.rowValueMaxWidth), - ) -} +private class SettingsSearch(query: String) { + private val needle = query.trim() -/** - * Settings row with a switch toggle. The whole row toggles, not just the - * thumb, and [enabled] now exists — the diagnostics screen used to hand-roll - * its own copy of this row purely to get a disabled switch. - */ -@Composable -fun SettingsSwitchRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - enabled: Boolean = true, -) { - SettingsRow( - label = label, - description = description, - enabled = enabled, - modifier = modifier.toggleable( - value = checked, - enabled = enabled, - role = Role.Switch, - onValueChange = onCheckedChange, - ), - ) { - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - Switch( - checked = checked, - // The row owns the gesture; the switch is the indicator. - onCheckedChange = null, - enabled = enabled, - colors = settingsSwitchColors(), - ) - } + fun matches(vararg terms: String): Boolean = + needle.isEmpty() || terms.any { it.contains(needle, ignoreCase = true) } } -@Composable -private fun settingsSwitchColors() = SwitchDefaults.colors( - checkedThumbColor = SiloSurfaceContainer, - checkedTrackColor = SiloForeground, - checkedBorderColor = Color.Transparent, - uncheckedThumbColor = SiloMutedText, - uncheckedTrackColor = SiloSurfaceContainerHigh, - uncheckedBorderColor = SiloBorder, - disabledCheckedThumbColor = SiloSurfaceContainer, - disabledCheckedTrackColor = SiloForeground.copy(alpha = SettingsDimens.disabledAlpha), - disabledCheckedBorderColor = Color.Transparent, - disabledUncheckedThumbColor = SiloMutedText.copy(alpha = SettingsDimens.disabledAlpha), - disabledUncheckedTrackColor = SiloSurfaceContainerHigh.copy(alpha = SettingsDimens.disabledAlpha), - disabledUncheckedBorderColor = SiloBorder.copy(alpha = SettingsDimens.disabledAlpha), -) - -/** - * Settings row for one option in a mutually exclusive set. Like the switch - * row, the whole row is the target. - */ -@Composable -fun SettingsChoiceRow( - label: String, - selected: Boolean, - onSelect: () -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - enabled: Boolean = true, -) { - SettingsRow( - label = label, - description = description, - enabled = enabled, - modifier = modifier.selectable( - selected = selected, - enabled = enabled, - role = Role.RadioButton, - onClick = onSelect, - ), - ) { - Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - RadioButton( - selected = selected, - // The row owns the gesture; the button is the indicator. - onClick = null, - enabled = enabled, - colors = RadioButtonDefaults.colors( - selectedColor = SiloForeground, - unselectedColor = SiloMutedText, - disabledSelectedColor = SiloForeground.copy(alpha = SettingsDimens.disabledAlpha), - disabledUnselectedColor = SiloMutedText.copy(alpha = SettingsDimens.disabledAlpha), - ), - ) - } +/** The host of [serverUrl], or null when there is no server. */ +private fun serverHost(serverUrl: String): String? { + if (serverUrl.isBlank()) return null + return serverUrl + .substringAfter("://", serverUrl) + .substringBefore('/') + .ifBlank { serverUrl } } -/** - * A settings row that opens a menu of options. - * - * Trailing value plus a chevron, so a picker reads as something you can open - * rather than as a read-only fact. - */ -@Composable -fun SettingsDropdownRow( - label: String, - value: String, - options: List, - onOptionSelected: (String) -> Unit, - modifier: Modifier = Modifier, - description: String? = null, - enabled: Boolean = true, -) { - var expanded by remember { mutableStateOf(false) } - - Box(modifier = modifier) { - SettingsNavigationRow( - label = label, - description = description, - value = value, - enabled = enabled, - onClick = { expanded = true }, - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - onOptionSelected(option) - expanded = false - }, - ) - } - } - } -} - -/** - * A prose block inside a card, for the notices that are explanation rather - * than setting. Carries the same divider rule as a row. - */ -@Composable -fun SettingsProse( - body: String, - modifier: Modifier = Modifier, - title: String? = null, -) { - val divider = !isFirstSettingsRow() - Column( - modifier = modifier - .fillMaxWidth() - .settingsRowTopDivider(divider) - .padding( - horizontal = SettingsDimens.proseHorizontalPadding, - vertical = SettingsDimens.proseVerticalPadding, - ), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), - ) { - if (title != null) { - Text( - text = title, - style = SettingsTextStyles.rowLabel, - color = SiloForeground, - ) - } - Text( - text = body, - style = SettingsTextStyles.rowDescription, - color = SiloMutedText, - ) - } +/** The Diagnostics row's value, in the Apple apps' wording. */ +private fun diagnosticsAvailabilityLabel(availability: DiagnosticsAvailabilityUi): String? = when (availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> "Available" + DiagnosticsAvailabilityUi.DISABLED -> "Disabled by server" + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" + DiagnosticsAvailabilityUi.OFFLINE -> "Offline" + DiagnosticsAvailabilityUi.INELIGIBLE -> null } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 1b6eba7b40..46a6eab9b2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -15,6 +15,7 @@ import org.siloserver.silo.common.player.AudiobookSettingsStore import org.siloserver.silo.domain.player.IntroSkipMode import org.siloserver.silo.domain.settings.ProfileSettingsController import org.siloserver.silo.model.auth.User +import org.siloserver.silo.model.profile.Profile import org.siloserver.silo.model.download.DownloadQuality import org.siloserver.silo.model.download.effectiveDefault import org.siloserver.silo.model.download.labelFor @@ -26,6 +27,7 @@ import org.siloserver.silo.model.settings.CardPresentation import org.siloserver.silo.model.settings.CardPresentationPreset import org.siloserver.silo.model.settings.QualityPresets import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.NotificationsRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -56,6 +58,10 @@ data class SettingsUiState( // Account val user: User? = null, val serverUrl: String = "", + // The account card shows the active profile's name and avatar, and the + // Server row the server's display name, as the Apple apps do. + val activeProfile: Profile? = null, + val serverName: String = "", val isLoadingUser: Boolean = false, val loggedOut: Boolean = false, @@ -84,6 +90,13 @@ data class SettingsUiState( val showAudiobooks: Boolean = false, val subtitleAppearance: org.siloserver.silo.model.settings.SubtitleAppearance = org.siloserver.silo.model.settings.SubtitleAppearance.DEFAULT, + /** + * What playback draws: [subtitleAppearance], or the device caption style + * while Use Device Settings is on. The preview shows this; the editors + * edit [subtitleAppearance]. + */ + val effectiveSubtitleAppearance: org.siloserver.silo.model.settings.SubtitleAppearance = + org.siloserver.silo.model.settings.SubtitleAppearance.DEFAULT, /** False when the server is known to discard subtitle text opacity. */ val subtitleTextOpacitySupported: Boolean = true, // Up Next card: auto-play the next episode at countdown expiry, and how @@ -140,6 +153,7 @@ class SettingsViewModel( private val seekIntervalStore: SeekIntervalStore, audiobookSettingsStore: AudiobookSettingsStore, private val downloadsRepository: DownloadsRepository? = null, + private val serverRegistry: ServerRegistry? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -157,6 +171,7 @@ class SettingsViewModel( init { loadUserInfo() + observeAccountCard() observePlayerSettings() observePlaybackBehaviorSettings() observeNotifications() @@ -165,6 +180,17 @@ class SettingsViewModel( seekIntervals.refresh() } + private fun observeAccountCard() { + activeProfileStore.activeProfile.onEach { profile -> + _uiState.update { it.copy(activeProfile = profile) } + }.launchIn(viewModelScope) + serverRegistry?.activeEntry?.onEach { entry -> + _uiState.update { it.copy(serverName = entry?.displayName.orEmpty()) } + }?.launchIn(viewModelScope) + // Cached after the first fetch, so this is cheap on every later visit. + viewModelScope.launch { activeProfileStore.refresh() } + } + private fun loadUserInfo() { viewModelScope.launch { _uiState.update { it.copy(isLoadingUser = true) } @@ -282,6 +308,9 @@ class SettingsViewModel( playerSettingsStore.subtitleAppearanceFlow.onEach { appearance -> _uiState.update { it.copy(subtitleAppearance = appearance) } }.launchIn(viewModelScope) + playerSettingsStore.effectiveSubtitleAppearanceFlow.onEach { appearance -> + _uiState.update { it.copy(effectiveSubtitleAppearance = appearance) } + }.launchIn(viewModelScope) playerSettingsStore.subtitleTextOpacitySupportedFlow.onEach { supported -> _uiState.update { it.copy(subtitleTextOpacitySupported = supported) } }.launchIn(viewModelScope) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SubtitleSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SubtitleSettings.kt index 0ecd36b97d..6c92e41185 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SubtitleSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SubtitleSettings.kt @@ -1,32 +1,52 @@ package org.siloserver.silo.android.ui.screens.settings +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.toColorInt +import org.siloserver.silo.android.ui.theme.SettingsDimens import org.siloserver.silo.model.settings.LanguageOptions import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.model.settings.SubtitleAppearance +import org.siloserver.silo.model.settings.SubtitleBackgroundStylePreset +import org.siloserver.silo.model.settings.SubtitleFontSizePreset +import org.siloserver.silo.model.settings.SubtitlePositionPreset +import org.siloserver.silo.model.settings.pointSize /** - * Subtitle settings section with language, display mode, and forced subtitles toggle. + * Subtitles → Profile (Apple `SubtitleSettingsView` "Profile"): language, + * behavior, and forced subtitles, with the Apple footer. */ @Composable -fun SubtitleSettings( +fun SubtitleProfileSection( subtitleLanguage: String, - subtitleLanguageSuggestions: List = emptyList(), + subtitleLanguageSuggestions: List, subtitleMode: SubtitleMode, showForcedSubtitles: Boolean, onLanguageChanged: (String) -> Unit, onModeChanged: (SubtitleMode) -> Unit, onForcedSubtitlesChanged: (Boolean) -> Unit, - subtitleMatchesDevice: Boolean = false, - onSubtitleMatchesDeviceChanged: (Boolean) -> Unit = {}, - onOpenSubtitleAppearance: () -> Unit = {}, modifier: Modifier = Modifier, - // Metadata AI description translation (server-gated; row hidden when off). - metadataLanguageEnabled: Boolean = false, - metadataLanguage: String = "", - metadataLanguageSuggestions: List = emptyList(), - onMetadataLanguageChanged: (String) -> Unit = {}, ) { val subtitleLanguageOptions = remember(subtitleLanguage, subtitleLanguageSuggestions) { LanguageOptions.options( @@ -35,17 +55,14 @@ fun SubtitleSettings( runtimeValues = subtitleLanguageSuggestions, ) } - val metadataLanguageOptions = remember(metadataLanguage, metadataLanguageSuggestions) { - LanguageOptions.options( - key = SettingKeys.CATALOG_METADATA_LANGUAGE, - currentValue = metadataLanguage, - runtimeValues = metadataLanguageSuggestions, - ) - } - SettingsSection(title = "Subtitles", modifier = modifier) { + SettingsSection( + title = "Profile", + footer = "Used to pick a matching track when one is available. Forced subtitles cover " + + "foreign-language dialogue even when subtitles are off or set to auto.", + modifier = modifier, + ) { SettingsDropdownRow( - label = "Subtitle language", - description = "Choose which subtitle language Silo should prefer first.", + label = "Language", value = LanguageOptions.label(subtitleLanguage, SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), options = subtitleLanguageOptions.map { it.second }, onOptionSelected = { label -> @@ -54,8 +71,7 @@ fun SubtitleSettings( ) SettingsDropdownRow( - label = "Subtitle behavior", - description = "When Silo should turn subtitles on.", + label = "Behavior", value = subtitleMode.label, options = SubtitleMode.entries.map { it.label }, onOptionSelected = { label -> @@ -64,39 +80,368 @@ fun SubtitleSettings( ) SettingsSwitchRow( - label = "Show forced subtitles", - description = "Show subtitles for foreign-language dialogue even when subtitles are off.", + label = "Show Forced Subtitles", checked = showForcedSubtitles, onCheckedChange = onForcedSubtitlesChanged, ) + } +} - // iOS SubtitleSettingsView APPEARANCE parity: Match Device Settings - // (appearance follows the OS captioning preferences) + the custom - // appearance editor (the same sheet the player uses). - SettingsSwitchRow( - label = "Match device caption settings", - description = "Use the operating system's caption style instead of Silo's.", - checked = subtitleMatchesDevice, - onCheckedChange = onSubtitleMatchesDeviceChanged, +/** + * Subtitles → Metadata, shown while the server's Metadata AI is on. The footer + * is the settings contract's own description of the key. + */ +@Composable +fun SubtitleMetadataSection( + metadataLanguage: String, + metadataLanguageSuggestions: List, + onMetadataLanguageChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val metadataLanguageOptions = remember(metadataLanguage, metadataLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + currentValue = metadataLanguage, + runtimeValues = metadataLanguageSuggestions, + ) + } + SettingsSection( + title = "Metadata", + footer = "Fallback language Silo prefers for titles, descriptions, and artwork.", + modifier = modifier, + ) { + SettingsDropdownRow( + label = "Metadata Language", + value = LanguageOptions.label(metadataLanguage, SettingKeys.CATALOG_METADATA_LANGUAGE), + options = metadataLanguageOptions.map { it.second }, + onOptionSelected = { label -> + onMetadataLanguageChanged(LanguageOptions.wireValue(label, metadataLanguageOptions)) + }, ) - if (!subtitleMatchesDevice) { - SettingsNavigationRow( - label = "Subtitle appearance", - description = "How subtitles are drawn during playback.", - onClick = onOpenSubtitleAppearance, + } +} + +/** + * Subtitles → Appearance: the live preview, then "Use Device Settings", whose + * footer says where the style comes from. The preview draws [previewAppearance], + * the style playback uses, so it shows the device caption style while Use + * Device Settings is on. The Text, Background, and Layout groups below edit the + * same appearance the player's Subtitle Style sheet does. + */ +@Composable +fun SubtitleAppearanceSection( + previewAppearance: SubtitleAppearance, + subtitleMatchesDevice: Boolean, + onSubtitleMatchesDeviceChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + SettingsSectionHeader("Appearance") + SubtitleAppearancePreview(previewAppearance) + Spacer(modifier = Modifier.height(SettingsDimens.sectionGap)) + SettingsSection( + title = null, + footer = if (subtitleMatchesDevice) { + "Subtitles follow this device's caption style from Android's accessibility settings." + } else { + "Saved on the server for this profile on this device." + }, + ) { + SettingsSwitchRow( + label = "Use Device Settings", + checked = subtitleMatchesDevice, + onCheckedChange = onSubtitleMatchesDeviceChanged, ) } + } +} - if (metadataLanguageEnabled) { +/** + * Subtitles → Text. Dimmed and disabled while the device caption style is in + * use, as on the Apple page. + */ +@Composable +fun SubtitleTextSection( + appearance: SubtitleAppearance, + enabled: Boolean, + showTextOpacity: Boolean, + onEdit: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsSection(title = "Text", modifier = modifier) { + SettingsDropdownRow( + label = "Font Size", + value = SubtitleOptionLabels.fontSize(appearance.fontSize), + options = SubtitleOptionLabels.FONT_SIZES.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.FONT_SIZES.firstOrNull { it.second == label } + ?.let { (size, _) -> onEdit { it.copy(fontSize = size) } } + }, + ) + SettingsDropdownRow( + label = "Font Family", + value = SubtitleOptionLabels.fontFamily(appearance.fontFamily), + options = SubtitleOptionLabels.FONT_FAMILIES.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.FONT_FAMILIES.firstOrNull { it.second == label } + ?.let { (family, _) -> onEdit { it.copy(fontFamily = family) } } + }, + ) + SettingsDropdownRow( + label = "Font Color", + value = SubtitleOptionLabels.colorName(appearance.fontColor, SubtitleOptionLabels.FONT_COLORS), + options = SubtitleOptionLabels.FONT_COLORS.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.FONT_COLORS.firstOrNull { it.second == label } + ?.let { (hex, _) -> onEdit { it.copy(fontColor = hex) } } + }, + ) + if (showTextOpacity) { + val steps = SubtitleOptionLabels.percentSteps(5, appearance.textOpacity) SettingsDropdownRow( - label = "Metadata language", - description = "Fallback language Silo prefers for titles, descriptions, and artwork.", - value = LanguageOptions.label(metadataLanguage, SettingKeys.CATALOG_METADATA_LANGUAGE), - options = metadataLanguageOptions.map { it.second }, + label = "Opacity", + value = "${appearance.textOpacity}%", + options = steps.map { "$it%" }, + enabled = enabled, onOptionSelected = { label -> - onMetadataLanguageChanged(LanguageOptions.wireValue(label, metadataLanguageOptions)) + label.removeSuffix("%").toIntOrNull()?.let { percent -> onEdit { it.copy(textOpacity = percent) } } }, ) } + SettingsSwitchRow( + label = "Text Outline", + checked = appearance.textOutline, + enabled = enabled, + onCheckedChange = { outline -> onEdit { it.copy(textOutline = outline) } }, + ) + SettingsDropdownRow( + label = "Outline Color", + value = SubtitleOptionLabels.colorName(appearance.textOutlineColor, SubtitleOptionLabels.DARK_COLORS), + options = SubtitleOptionLabels.DARK_COLORS.map { it.second }, + enabled = enabled && appearance.textOutline, + onOptionSelected = { label -> + SubtitleOptionLabels.DARK_COLORS.firstOrNull { it.second == label } + ?.let { (hex, _) -> onEdit { it.copy(textOutlineColor = hex) } } + }, + ) + } +} + +/** Subtitles → Background. Opacity and colour apply only to the box style. */ +@Composable +fun SubtitleBackgroundSection( + appearance: SubtitleAppearance, + enabled: Boolean, + onEdit: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, + modifier: Modifier = Modifier, +) { + val boxed = appearance.backgroundStyle == SubtitleBackgroundStylePreset.Box + SettingsSection(title = "Background", modifier = modifier) { + SettingsDropdownRow( + label = "Style", + value = SubtitleOptionLabels.backgroundStyle(appearance.backgroundStyle), + options = SubtitleOptionLabels.BACKGROUND_STYLES.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.BACKGROUND_STYLES.firstOrNull { it.second == label } + ?.let { (style, _) -> onEdit { it.copy(backgroundStyle = style) } } + }, + ) + val steps = SubtitleOptionLabels.percentSteps(0, appearance.backgroundOpacity) + SettingsDropdownRow( + label = "Opacity", + value = "${appearance.backgroundOpacity}%", + options = steps.map { "$it%" }, + enabled = enabled && boxed, + onOptionSelected = { label -> + label.removeSuffix("%").toIntOrNull()?.let { percent -> onEdit { it.copy(backgroundOpacity = percent) } } + }, + ) + SettingsDropdownRow( + label = "Color", + value = SubtitleOptionLabels.colorName(appearance.backgroundColor, SubtitleOptionLabels.DARK_COLORS), + options = SubtitleOptionLabels.DARK_COLORS.map { it.second }, + enabled = enabled && boxed, + onOptionSelected = { label -> + SubtitleOptionLabels.DARK_COLORS.firstOrNull { it.second == label } + ?.let { (hex, _) -> onEdit { it.copy(backgroundColor = hex) } } + }, + ) + } +} + +/** Subtitles → Layout. */ +@Composable +fun SubtitleLayoutSection( + appearance: SubtitleAppearance, + enabled: Boolean, + onEdit: ((SubtitleAppearance) -> SubtitleAppearance) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsSection(title = "Layout", modifier = modifier) { + SettingsDropdownRow( + label = "Position", + value = SubtitleOptionLabels.position(appearance.position), + options = SubtitleOptionLabels.POSITIONS.map { it.second }, + enabled = enabled, + onOptionSelected = { label -> + SubtitleOptionLabels.POSITIONS.firstOrNull { it.second == label } + ?.let { (position, _) -> onEdit { it.copy(position = position) } } + }, + ) } } + +private const val SubtitlePreviewText = "Subtitles will look like this" + +/** + * The Apple page's live preview: sample text over a dark gradient, drawn with + * the current font, colour, opacity, outline, background, and position. + */ +@Composable +private fun SubtitleAppearancePreview(appearance: SubtitleAppearance) { + val safe = appearance.sanitized() + val fontFamily = when (safe.fontFamily.lowercase()) { + SubtitleAppearance.SERIF -> FontFamily.Serif + SubtitleAppearance.MONOSPACE -> FontFamily.Monospace + else -> FontFamily.SansSerif + } + val fontSize = (safe.fontSize.pointSize * 0.36).sp + val foreground = previewColor(safe.fontColor).copy(alpha = safe.textOpacity.coerceIn(1, 100) / 100f) + val outline = previewColor(safe.textOutlineColor) + val showOutline = safe.textOutline || safe.backgroundStyle == SubtitleBackgroundStylePreset.Outline + val boxed = safe.backgroundStyle == SubtitleBackgroundStylePreset.Box + val boxColor = previewColor(safe.backgroundColor).copy( + alpha = if (boxed) safe.backgroundOpacity.coerceIn(0, 100) / 100f else 0f, + ) + val shadow = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Shadow) { + Shadow(color = Color.Black, blurRadius = 6f) + } else { + null + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(118.dp) + .clip(RoundedCornerShape(SettingsDimens.groupRadius)) + .background(Brush.linearGradient(colors = listOf(Color(0xFF55575A), Color(0xFF101113)))) + .padding(horizontal = 14.dp, vertical = 12.dp), + contentAlignment = when (safe.position) { + SubtitlePositionPreset.Top -> Alignment.TopCenter + SubtitlePositionPreset.LowerThird -> Alignment.Center + SubtitlePositionPreset.Bottom -> Alignment.BottomCenter + }, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(boxColor) + .padding(horizontal = if (boxed) 8.dp else 0.dp, vertical = if (boxed) 3.dp else 0.dp), + contentAlignment = Alignment.Center, + ) { + if (showOutline) { + listOf(-1 to -1, -1 to 1, 1 to -1, 1 to 1).forEach { (x, y) -> + Text( + text = SubtitlePreviewText, + color = outline, + fontFamily = fontFamily, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.offset(x.dp, y.dp), + ) + } + } + Text( + text = SubtitlePreviewText, + color = foreground, + fontFamily = fontFamily, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + style = TextStyle(shadow = shadow), + ) + } + } +} + +private fun previewColor(hex: String): Color = + runCatching { Color(hex.toColorInt()) }.getOrDefault(Color.White) + +/** + * Option labels for the Text / Background / Layout groups, in the Apple apps' + * wording. The palettes match silo-apple `SubtitleAppearance` and the TV app's + * `TvSubtitleAppearanceOptions`. Android also offers the outline background + * style, which the Apple apps draw as a text outline. + */ +private object SubtitleOptionLabels { + val FONT_SIZES = listOf( + SubtitleFontSizePreset.Small to "Small", + SubtitleFontSizePreset.Medium to "Medium", + SubtitleFontSizePreset.Large to "Large", + SubtitleFontSizePreset.XLarge to "X-Large", + SubtitleFontSizePreset.XXLarge to "XX-Large", + ) + + val FONT_FAMILIES = listOf( + SubtitleAppearance.SANS_SERIF to "Sans-serif", + SubtitleAppearance.SERIF to "Serif", + SubtitleAppearance.MONOSPACE to "Monospace", + ) + + val FONT_COLORS = listOf( + "#ffffff" to "White", + "#facc15" to "Yellow", + "#22c55e" to "Green", + "#06b6d4" to "Cyan", + "#d946ef" to "Magenta", + "#ef4444" to "Red", + "#3b82f6" to "Blue", + "#9ca3af" to "Gray", + "#000000" to "Black", + ) + + /** Background and outline palette (Apple `backgroundColors` / `outlineColors`). */ + val DARK_COLORS = listOf( + "#000000" to "Black", + "#374151" to "Dark Gray", + "#1e3a5f" to "Navy", + "#7f1d1d" to "Dark Red", + "#14532d" to "Dark Green", + ) + + val BACKGROUND_STYLES = listOf( + SubtitleBackgroundStylePreset.Box to "Box", + SubtitleBackgroundStylePreset.Shadow to "Drop Shadow", + SubtitleBackgroundStylePreset.Outline to "Outline", + SubtitleBackgroundStylePreset.None to "None", + ) + + val POSITIONS = listOf( + SubtitlePositionPreset.Bottom to "Bottom", + SubtitlePositionPreset.LowerThird to "Lower Third", + SubtitlePositionPreset.Top to "Top", + ) + + fun fontSize(value: SubtitleFontSizePreset) = FONT_SIZES.first { it.first == value }.second + + fun fontFamily(value: String) = + FONT_FAMILIES.firstOrNull { it.first.equals(value, ignoreCase = true) }?.second ?: value + + fun backgroundStyle(value: SubtitleBackgroundStylePreset) = BACKGROUND_STYLES.first { it.first == value }.second + + fun position(value: SubtitlePositionPreset) = POSITIONS.first { it.first == value }.second + + /** A palette name for [hex], or the hex itself for a colour set elsewhere. */ + fun colorName(hex: String, palette: List>) = + palette.firstOrNull { it.first.equals(hex, ignoreCase = true) }?.second ?: hex + + /** + * Percent steps of five from [from] to 100, plus the saved value so one set + * off the five-point cadence (the player sheet types any percent) is still + * the checked option rather than silently replaced. + */ + fun percentSteps(from: Int, current: Int): List = ((from..100 step 5) + current).toSortedSet().toList() +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt index 35dc484205..8a30e36cd7 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/TitleArtSettings.kt @@ -1,6 +1,5 @@ package org.siloserver.silo.android.ui.screens.settings -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -9,47 +8,44 @@ import org.siloserver.silo.common.settings.TitleArtState import org.siloserver.silo.common.settings.TitleArtStore /** - * "Show title art" and its "Apply to all devices" companion, as rows of the - * Interface card. Renders nothing until the server has confirmed the key - * (settings revision 16); older servers keep logos on, as before. The switches - * stay disabled until this session's read lands. [SettingsScreen] refreshes - * the store when it opens. + * "Show title art" and its "Apply to all devices" companion, as the Title + * Pages group of the Interface page. Renders nothing until the server has + * confirmed the key (settings revision 16); older servers keep logos on, as + * before. The switches stay disabled until this session's read lands. + * [SettingsInterfaceScreen] refreshes the store when it opens. */ @Composable -internal fun ColumnScope.TitleArtSettingsRows(store: TitleArtStore) { +internal fun TitleArtSettingsSection(store: TitleArtStore) { val state by store.state.collectAsState() val saveError by store.saveError.collectAsState() if (!state.isSupported) return val device = if (LocalConfiguration.current.smallestScreenWidthDp >= 600) "tablet" else "phone" - SettingsSwitchRow( - label = "Show title art", - description = "Use logo artwork as the title when available.", - checked = state.showTitleArt, - onCheckedChange = store::setShowTitleArt, - enabled = state.canEdit, - ) - SettingsSwitchRow( - label = "Apply to all devices", - description = titleArtScopeDescription(state, device), - checked = state.appliesToAllDevices, - onCheckedChange = store::setAppliesToAllDevices, - enabled = state.canEdit, - ) - if (state.appliesToAllDevices) { - SettingsProse(body = titleArtAllDevicesNote(state, device)) + SettingsSection( + title = "Title Pages", + footer = "Use logo artwork as the title when available. " + titleArtScopeFooter(state, device), + ) { + SettingsSwitchRow( + label = "Show title art", + checked = state.showTitleArt, + onCheckedChange = store::setShowTitleArt, + enabled = state.canEdit, + ) + SettingsSwitchRow( + label = "Apply to all devices", + checked = state.appliesToAllDevices, + onCheckedChange = store::setAppliesToAllDevices, + enabled = state.canEdit, + ) + saveError?.let { SettingsProse(body = it) } } - saveError?.let { SettingsProse(body = it) } } -internal fun titleArtScopeDescription(state: TitleArtState, device: String): String = +internal fun titleArtScopeFooter(state: TitleArtState, device: String): String = if (state.appliesToAllDevices) { - "On: every device on this profile uses this choice." + "Title art is ${if (state.showTitleArt) "on" else "off"} on every device signed into " + + "this profile. Changing it here changes it everywhere. Turn off “Apply to all " + + "devices” to choose for this $device only." } else { - "Off: only affects this $device. Your other devices keep their own setting." + "This choice only affects this $device. Your other devices keep their own setting." } - -internal fun titleArtAllDevicesNote(state: TitleArtState, device: String): String = - "Title art is ${if (state.showTitleArt) "on" else "off"} on every device signed into " + - "this profile. Changing it here changes it everywhere. Turn off “Apply to all " + - "devices” to choose for this $device only." diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt index d0363be02f..e75877e7f0 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt @@ -1,23 +1,15 @@ package org.siloserver.silo.android.ui.screens.settings.diagnostics -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -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.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -36,22 +28,23 @@ import java.text.DateFormat import java.util.Date import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.android.ui.components.SiloTopBar -import org.siloserver.silo.android.ui.screens.settings.SettingsChoiceRow +import org.siloserver.silo.android.ui.screens.settings.SILO_PRIVACY_POLICY_URL +import org.siloserver.silo.android.ui.screens.settings.SettingsDestructiveRow +import org.siloserver.silo.android.ui.screens.settings.SettingsDropdownRow import org.siloserver.silo.android.ui.screens.settings.SettingsNavigationRow -import org.siloserver.silo.android.ui.screens.settings.SettingsProse +import org.siloserver.silo.android.ui.screens.settings.SettingsPageScaffold import org.siloserver.silo.android.ui.screens.settings.SettingsRow import org.siloserver.silo.android.ui.screens.settings.SettingsSection -import org.siloserver.silo.android.ui.screens.settings.SettingsSectionCard import org.siloserver.silo.android.ui.screens.settings.SettingsSwitchRow -import org.siloserver.silo.android.ui.theme.SettingsDimens -import org.siloserver.silo.android.ui.theme.SettingsTextStyles import org.siloserver.silo.android.ui.theme.SiloForeground -import org.siloserver.silo.android.ui.theme.SiloMutedText +import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.android.ui.theme.SiloSettingsBackground +import org.siloserver.silo.android.ui.theme.SiloWarning import org.siloserver.silo.android.ui.theme.Spacing import org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi import org.siloserver.silo.common.diagnostics.DiagnosticsConsentMode import org.siloserver.silo.common.diagnostics.DiagnosticsDestinationKind +import org.siloserver.silo.model.diagnostics.DiagnosticsReportType import org.siloserver.silo.common.diagnostics.DiagnosticsUiState import org.siloserver.silo.common.diagnostics.TimedCaptureStatus @@ -80,6 +73,12 @@ fun DiagnosticsSettingsScreen( ) } +/** + * Diagnostics, in the Apple page's order (silo-apple `DiagnosticsSettingsView`): + * Feature State, Capture, Pending Reports, the send action, and Sent History. + * Android's timed capture sits beside "Send Diagnostics Now"; it has no Apple + * twin. + */ @Composable internal fun DiagnosticsSettingsContent( state: DiagnosticsUiState, @@ -94,8 +93,10 @@ internal fun DiagnosticsSettingsContent( onReportSelected: (String) -> Unit, ) { var confirmAlways by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current val model = diagnosticsPhoneScreenModel(state) + val hosted = state.destinationKind == DiagnosticsDestinationKind.HOSTED val effectiveConsent = if ( state.consent == DiagnosticsConsentMode.ALWAYS && !state.allowsAutomaticUpload ) { @@ -103,186 +104,147 @@ internal fun DiagnosticsSettingsContent( } else { state.consent } - Scaffold( - topBar = { - SiloTopBar( - title = "Diagnostics", - onBackClick = onBackClick, - containerColor = SiloSettingsBackground, - ) - }, - containerColor = SiloSettingsBackground, - ) { padding -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(SettingsDimens.pageGutter), - verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), - ) { - item { - SettingsSection(title = "Send reports to") { - DiagnosticsDestinationKind.entries.forEach { destination -> - val label = when (destination) { - DiagnosticsDestinationKind.HOSTED -> "Silo Diagnostics" - DiagnosticsDestinationKind.SELF_HOSTED -> "This Silo server" - } - SettingsChoiceRow( - label = label, - selected = state.destinationKind == destination, - onSelect = { onDestinationChanged(destination) }, - ) - } - SettingsProse( - body = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) { - "Reports include the Silo app version and build, Android version, device model, " + - "crash details, and diagnostic logs you review. A pseudonymous installation " + - "credential is not linked to an account on your self-hosted server. Username, " + - "email, profile, server address, and playback session IDs are omitted. Reports " + - "are never sent automatically and may be retained for up to " + - "${state.retentionDays} days." - } else { - "Compatibility mode sends reports to the diagnostics endpoint on your active server." - }, - ) - TextButton( - onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, - modifier = Modifier.padding( - start = SettingsDimens.rowHorizontalPadding - 12.dp, - bottom = SettingsDimens.rowVerticalPadding, - ), - ) { - Text("Privacy Policy") - } - } + val consentOptions = DiagnosticsConsentMode.entries + .filter { it != DiagnosticsConsentMode.ALWAYS || state.allowsAutomaticUpload } + val captureActive = state.timedCapture.status == TimedCaptureStatus.ACTIVE + SettingsPageScaffold(title = "Diagnostics", onBackClick = onBackClick) { + item(key = "state") { + SettingsSection(title = "Feature State", footer = availabilityDetail(state)) { + SettingsNavigationRow(label = "Status", value = availabilityTitle(state.availability)) + SettingsNavigationRow(label = "Destination", value = destinationLabel(state.destinationKind)) } - item { DiagnosticsStatusCard(state) } - item { - SettingsSection(title = "Crash reports") { - DiagnosticsConsentMode.entries - .filter { it != DiagnosticsConsentMode.ALWAYS || state.allowsAutomaticUpload } - .forEach { mode -> - val label = when (mode) { - DiagnosticsConsentMode.ASK -> "Ask before sending" - DiagnosticsConsentMode.ALWAYS -> "Always send" - DiagnosticsConsentMode.NEVER -> "Never send" + } + item(key = "capture") { + SettingsSection( + title = "Capture", + footer = if (hosted) { + "Reports include the Silo app version and build, Android version, device model, " + + "crash details, and diagnostic logs you review. A pseudonymous installation " + + "credential is not linked to an account on your self-hosted server. Username, " + + "email, profile, server address, and playback session IDs are omitted. Reports " + + "are never sent automatically and may be retained for up to " + + "${state.retentionDays} days." + } else { + "Crash report consent is tied to this server account. Debug logging is a setting " + + "for this device." + }, + ) { + SettingsDropdownRow( + label = "Send Reports To", + value = destinationLabel(state.destinationKind), + options = DiagnosticsDestinationKind.entries.map(::destinationLabel), + onOptionSelected = { label -> + DiagnosticsDestinationKind.entries + .firstOrNull { destinationLabel(it) == label } + ?.let(onDestinationChanged) + }, + ) + SettingsSwitchRow( + label = "Debug Logging", + checked = state.debugLogging, + enabled = state.consent != DiagnosticsConsentMode.NEVER, + onCheckedChange = onDebugLoggingChanged, + ) + SettingsDropdownRow( + label = "Crash Reports", + value = consentLabel(effectiveConsent), + options = consentOptions.map(::consentLabel), + onOptionSelected = { label -> + consentOptions.firstOrNull { consentLabel(it) == label }?.let { mode -> + if (consentActionModel(state.consent, mode).requiresConfirmation) { + confirmAlways = true + } else { + onConsentChanged(mode) } - SettingsChoiceRow( - label = label, - selected = effectiveConsent == mode, - onSelect = { - if (consentActionModel(state.consent, mode).requiresConfirmation) { - confirmAlways = true - } else { - onConsentChanged(mode) - } - }, - ) } - // The shared switch row now carries `enabled`, so this no - // longer needs its own hand-rolled copy of it. - SettingsSwitchRow( - label = "Debug logging", - description = "Record extra detail so a report can explain what went wrong.", - checked = state.debugLogging, - enabled = state.consent != DiagnosticsConsentMode.NEVER, - onCheckedChange = onDebugLoggingChanged, + }, + ) + // The policy behind the consent above, one tap away where the + // choice is made (Settings → About links it too). + SettingsNavigationRow( + label = "Privacy Policy", + onClick = { uriHandler.openUri(SILO_PRIVACY_POLICY_URL) }, + ) + } + } + item(key = "pending") { + SettingsSection(title = "Pending Reports (${state.pending.size})") { + if (state.pending.isEmpty()) { + SettingsNavigationRow(label = "No pending reports", labelColor = SiloSecondaryText) + } + state.pending.forEach { report -> + SettingsNavigationRow( + label = report.type.displayName(), + description = "${report.capturedAt} · ${formatDiagnosticBytes(report.evidenceBytes)}", + onClick = { onReportSelected(report.id) }, ) } } - item { - SettingsSection(title = "Capture") { - val paneModifier = Modifier.padding( - horizontal = SettingsDimens.proseHorizontalPadding, - vertical = SettingsDimens.proseVerticalPadding, + } + item(key = "send") { + SettingsSection( + title = null, + footer = when { + captureActive -> "Capture is running. Reproduce the issue, then stop to review exactly " + + "what will be sent." + !state.debugLogging -> "Debug logging is off. This report contains only the last few " + + "minutes of basic logs. Timed capture records more detail until you stop it." + else -> "A one-time report uses the recent in-memory log. Timed capture records more " + + "detail until you stop it." + }, + footerColor = if (!captureActive && !state.debugLogging) SiloWarning else SiloSecondaryText, + ) { + if (captureActive) { + SettingsNavigationRow(label = "Stop & Review", onClick = onStopCapture, showChevron = false) + SettingsDestructiveRow(label = "Cancel Capture", onClick = onCancelCapture) + } else { + SettingsNavigationRow( + label = "Send Diagnostics Now", + onClick = onSendNow, + enabled = model.canCapture, + showChevron = false, + ) + SettingsNavigationRow( + label = "Start Diagnostic Capture", + onClick = onStartCapture, + enabled = model.canCapture, + showChevron = false, ) - if (state.timedCapture.status == TimedCaptureStatus.ACTIVE) { - Column(paneModifier) { - Text( - "Diagnostic capture is running", - style = SettingsTextStyles.rowLabel, - color = SiloForeground, - ) - Text( - "Reproduce the issue, then stop to review exactly what will be sent.", - color = SiloMutedText, - style = SettingsTextStyles.rowDescription, - ) - Spacer(Modifier.height(Spacing.md)) - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - Button(onClick = onStopCapture) { Text("Stop & review") } - OutlinedButton(onClick = onCancelCapture) { Text("Cancel") } - } - } - } else { - Column(paneModifier) { - Button(onClick = onSendNow, enabled = model.canCapture) { - Text("Send diagnostics now") - } - Spacer(Modifier.height(Spacing.sm)) - OutlinedButton(onClick = onStartCapture, enabled = model.canCapture) { - Text("Start diagnostic capture") - } - Text( - "A one-time report uses the recent in-memory log. Timed capture records more detail until you stop it.", - color = SiloMutedText, - style = SettingsTextStyles.rowDescription, - modifier = Modifier.padding(top = Spacing.sm), - ) - } - } } } - if (model.showPending) { - item { - SettingsSection(title = "Pending reports") { - state.pending.forEach { report -> - SettingsNavigationRow( - label = report.type.displayName(), - description = "${report.capturedAt} · ${formatDiagnosticBytes(report.evidenceBytes)}", - onClick = { onReportSelected(report.id) }, - ) - } - } + } + item(key = "sent") { + SettingsSection( + title = "Sent History", + footer = if (state.sentHistory.isEmpty()) { + null + } else { + "Sent reports are removed from this device once the selected destination has a copy. " + + "Use the reference ID when asking for help." + }, + ) { + if (state.sentHistory.isEmpty()) { + SettingsNavigationRow(label = "No reports sent yet", labelColor = SiloSecondaryText) } - } - if (state.sentHistory.isNotEmpty()) { - item { - val clipboard = LocalClipboardManager.current - Column { - SettingsSection(title = "Recently sent") { - state.sentHistory.forEach { sent -> - SettingsRow( - label = sent.shortId, - description = "${sent.state.replace('_', ' ')} · " + - formatDiagnosticDate(sent.sentAtEpochMs), - ) { - IconButton( - onClick = { clipboard.setText(AnnotatedString(sent.shortId)) }, - ) { - Icon( - imageVector = Icons.Outlined.ContentCopy, - contentDescription = "Copy reference ID", - tint = SiloMutedText, - modifier = Modifier.size(18.dp), - ) - } - } - } + state.sentHistory.forEach { sent -> + SettingsRow( + label = sent.shortId, + description = "${sent.state.replace('_', ' ')} · " + + formatDiagnosticDate(sent.sentAtEpochMs), + ) { + IconButton( + onClick = { clipboard.setText(AnnotatedString(sent.shortId)) }, + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = "Copy reference ID", + tint = SiloSecondaryText, + modifier = Modifier.size(18.dp), + ) } - Text( - "Sent reports are removed from this device once the selected destination has a copy. " + - "Use the reference ID when asking for help.", - color = SiloMutedText, - style = SettingsTextStyles.rowDescription, - modifier = Modifier.padding( - start = SettingsDimens.headerStartInset, - end = SettingsDimens.headerStartInset, - top = Spacing.sm, - ), - ) } } } - item { Spacer(Modifier.height(SettingsDimens.pageBottomSpacer)) } } } @@ -306,7 +268,37 @@ internal fun DiagnosticsSettingsContent( } } -private const val PRIVACY_POLICY_URL = "https://siloserver.org/privacy" +private fun destinationLabel(destination: DiagnosticsDestinationKind): String = when (destination) { + DiagnosticsDestinationKind.HOSTED -> "Silo Diagnostics" + DiagnosticsDestinationKind.SELF_HOSTED -> "My Silo Server" +} + +private fun consentLabel(mode: DiagnosticsConsentMode): String = when (mode) { + DiagnosticsConsentMode.ASK -> "Ask" + DiagnosticsConsentMode.ALWAYS -> "Always" + DiagnosticsConsentMode.NEVER -> "Never" +} + +private fun availabilityTitle(availability: DiagnosticsAvailabilityUi): String = when (availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> "Available" + DiagnosticsAvailabilityUi.DISABLED -> "Disabled by server" + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" + DiagnosticsAvailabilityUi.OFFLINE -> "Offline" + DiagnosticsAvailabilityUi.INELIGIBLE -> "Unavailable" +} + +/** + * The Feature State footer: what a degraded availability means for reports. + * Like the Apple page, a working state needs no footer. + */ +private fun availabilityDetail(state: DiagnosticsUiState): String? = when (state.availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> null + DiagnosticsAvailabilityUi.DISABLED -> "Local reports remain available to inspect or delete." + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Local reports remain on this device." + DiagnosticsAvailabilityUi.OFFLINE -> + "Showing the last known diagnostics state. Reports stay on this device while the server is offline." + DiagnosticsAvailabilityUi.INELIGIBLE -> "Diagnostics are not available for this profile." +} @Composable private fun DiagnosticsUnavailableScreen(onBackClick: () -> Unit) { @@ -316,6 +308,7 @@ private fun DiagnosticsUnavailableScreen(onBackClick: () -> Unit) { title = "Diagnostics", onBackClick = onBackClick, containerColor = SiloSettingsBackground, + centerTitle = true, ) }, containerColor = SiloSettingsBackground, @@ -330,24 +323,14 @@ private fun DiagnosticsUnavailableScreen(onBackClick: () -> Unit) { } } -@Composable -private fun DiagnosticsStatusCard(state: DiagnosticsUiState) { - val destination = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) "Silo Diagnostics" else "this server" - val (title, detail) = when (state.availability) { - DiagnosticsAvailabilityUi.AVAILABLE -> "Available" to "Reports can be reviewed and sent to $destination." - DiagnosticsAvailabilityUi.DISABLED -> "Disabled" to "Local reports remain available to inspect or delete." - DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" to "Local reports remain on this device." - DiagnosticsAvailabilityUi.OFFLINE -> "Offline" to "Connect to refresh diagnostics availability." - DiagnosticsAvailabilityUi.INELIGIBLE -> "Unavailable" to "Diagnostics are not available for this profile." - } - SettingsSectionCard { - SettingsProse(title = title, body = detail) - } +/** The Apple apps' report titles; Android's ANR and native crash fold into them. */ +internal fun DiagnosticsReportType.displayName(): String = when (this) { + DiagnosticsReportType.CRASH, DiagnosticsReportType.NATIVE_CRASH -> "Crash" + DiagnosticsReportType.HANG, DiagnosticsReportType.ANR -> "Not Responding" + DiagnosticsReportType.ABNORMAL_EXIT -> "Unclean Shutdown" + DiagnosticsReportType.MANUAL -> "Manual Report" } -internal fun org.siloserver.silo.model.diagnostics.DiagnosticsReportType.displayName(): String = - name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) - internal fun formatDiagnosticBytes(bytes: Long): String = when { bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Color.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Color.kt index 4c24642515..972c457256 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Color.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Color.kt @@ -52,8 +52,34 @@ val SiloOpaqueControlBorder = Color.White.copy(alpha = 0.46f) // settings values, and they fill M3's `surfaceContainer*` ladder — which // `darkColorScheme` otherwise leaves on its purple-tinted baseline. -/** Lifted page ground for form-shaped screens. Web `--background`. */ -val SiloSettingsBackground = Color(0xFF141417) +/** + * Settings page ground. Pure black, like the Apple apps' Settings backdrop + * (`siloBackground`), so the grouped cells read as an inset list. + */ +val SiloSettingsBackground = Color(0xFF000000) + +// --- Settings grouped list (Apple Settings parity) --- +// +// The Apple apps draw Settings as a native inset-grouped list in Apple's +// dark-mode grouped palette (silo-apple `Colors.swift`). Settings uses these +// rather than the web card values above so both phones show the same list; +// its text uses the chrome's `SiloOnSurface` / `SiloSecondaryText`, which are +// already Apple's `siloOnSurface` / `siloSecondaryText`. + +/** Grouped row background. Apple `siloGroupedCell`. */ +val SiloGroupedCell = Color(0xFF1C1C1E) + +/** Graphite tile behind an overview row's glyph. Apple `siloIconTile`. */ +val SiloIconTile = Color(0xFF3A3A3C) + +/** Switch track while on. Apple `siloSwitchOn`, the system dark-mode green. */ +val SiloSwitchOn = Color(0xFF30D158) + +/** Switch track while off, Apple's dark grouped-cell switch fill. */ +val SiloSwitchOff = Color(0xFF39393D) + +/** Hairline between grouped rows, Apple's dark-mode separator. */ +val SiloGroupedSeparator = Color(0xFF38383A) /** Grouped card surface. Web `--card`. */ val SiloSurfaceContainer = Color(0xFF1C1C20) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt index 89f452bc43..0aa2e43505 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt @@ -34,14 +34,13 @@ object Spacing { /** * Metrics for the grouped-settings surface. * - * Mirrors the Silo web client's settings pages: a lifted page ground, opaque - * grouped cards with a generous radius, a lettered section heading sitting - * *above* its card, and rows that are tall enough to carry a label over a - * description. Android row mechanics are kept — the control stays trailing - * rather than stacking under the label the way the web layout does. + * Mirrors the Apple apps' Settings (silo-apple #546): a native inset-grouped + * list on a black ground, one-line rows with the current value trailing, a + * title-case section header above each group and an optional footer below it. + * Overview rows carry a graphite icon tile; sub-page rows are text only. * * Every value the settings tree needs lives here so the next visual pass has - * one file to edit instead of twenty-two literals spread across six files. + * one file to edit instead of literals spread across the settings files. */ object SettingsDimens { /** Page gutter for the settings list. */ @@ -53,71 +52,94 @@ object SettingsDimens { /** Trailing scroll runway so the last card clears the navigation bar. */ val pageBottomSpacer = Spacing.xxxl - /** Gap between two grouped cards, heading included. */ - val sectionGap = 22.dp + /** Gap between two groups, header and footer included. */ + val sectionGap = 20.dp - /** Grouped card corner radius. */ + /** Dialog corner radius. [SiloConfirmDialog] shares it. */ val cardRadius = 20.dp - /** Inset of the section heading relative to the card's leading edge. */ - val headerStartInset = Spacing.xs + /** Inset-grouped list corner radius, Apple's large grouped-cell rounding. */ + val groupRadius = 26.dp - /** Gap between a section heading and its card. */ + /** Header and footer inset, aligned with the row text inside the group. */ + val headerStartInset = Spacing.lg + + /** Gap between a section header and its group. */ val headerBottomGap = Spacing.sm - /** Minimum row height. Every row honours this so a row with a description - * and a row without still read as the same list. */ - val rowMinHeight = 60.dp + /** Gap between a group and its footer. */ + val footerTopGap = Spacing.sm + + /** Minimum row height on a sub-page (Apple's 52pt grouped row). */ + val rowMinHeight = 52.dp + + /** Minimum height of an overview row with an icon tile (Apple's 60pt). */ + val overviewRowMinHeight = 60.dp /** Row content insets. */ val rowHorizontalPadding = Spacing.lg val rowVerticalPadding = Spacing.md - /** Gap between a row label and its description. */ + /** Gap between a row label and a description, where a row still has one. */ val rowLabelGap = Spacing.xxs - /** - * Gap between the row text block and its trailing control — chevron, - * switch, radio. Never text: a trailing *value* rides on the label's own - * line instead (see [rowLabelValueGap]), so a description can never end up - * a hairsbreadth from it. - */ - val rowTrailingGap = Spacing.md + /** Gap between the row text block and its trailing control. */ + val rowTrailingGap = Spacing.sm /** * Minimum gap between a row label and the trailing value sharing its line. - * Only binds when the label is long enough to reach the value; a short - * label leaves the value trailing-aligned with slack between them. + * Only binds when the label is long enough to reach the value. */ val rowLabelValueGap = Spacing.md /** * Cap on a trailing value's width, so a long one (a server URL) cannot * crush the label it sits beside. The value ellipsizes at this width; the - * label wraps. Sized so the longest authored value on this surface — - * "30 seconds before end" — still renders whole. + * label wraps. */ - val rowValueMaxWidth = 170.dp + val rowValueMaxWidth = 190.dp - /** Hairline between rows. */ + /** Overview icon tile: Apple's 30pt square with continuous 0.24 rounding. */ + val iconTileSize = 30.dp + val iconTileRadius = 7.dp + val iconGlyphSize = 17.dp + + /** Gap between the icon tile and the row label. */ + val iconTileGap = 14.dp + + /** Hairline between popup-menu rows; also the menus' border width. */ val dividerThickness = 1.dp + /** Hairline between grouped settings rows, Apple's single-pixel separator. */ + val separatorThickness = 0.5.dp + /** Divider inset, aligned to the row label. */ val dividerStartInset = Spacing.lg + /** Divider trailing inset. */ + val dividerEndInset = Spacing.lg + /** Disclosure chevron. */ - val chevronSize = 18.dp + val chevronSize = 20.dp - /** Account header avatar. */ + /** Menu-picker indicator, the search glyph, and a menu row's checkmark. */ + val menuIndicatorSize = 18.dp + + /** Leading slot that holds a menu row's checkmark, kept for every row. */ + val menuCheckWidth = 28.dp + + /** Account card avatar (Apple `ProfileAvatarView` at 56pt). */ val avatarSize = 56.dp - val avatarIconSize = 30.dp val avatarGap = 14.dp + /** Settings overview search field. */ + val searchFieldHeight = 44.dp + /** Inset for prose blocks that sit inside a card rather than on a row. */ val proseHorizontalPadding = Spacing.lg val proseVerticalPadding = Spacing.md - /** Divider opacity over [SiloSurfaceContainer]. */ + /** Divider opacity over [SiloSurfaceContainer], for popup menus. */ const val dividerAlpha = 0.55f /** Opacity applied to a disabled row's text and controls. */ @@ -192,49 +214,68 @@ fun Modifier.siloRowTopDivider(show: Boolean): Modifier = } /** - * Type ramp for the grouped-settings surface. - * - * The M3 ramp in [SiloTypography] mirrors the iOS point sizes and has no slot - * for a row description, so the settings rows previously had a single 16sp - * label and nothing else. These four styles are the hierarchy the web client - * uses: a lettered caps heading, a medium-weight label, a muted description, - * and a smaller trailing value that reads as a picker's current choice rather - * than as a second label. + * Type ramp for the grouped-settings surface: Apple's grouped list at Android + * sizes. Row labels and values share one regular-weight size, the value in the + * secondary colour; headers are title case and semibold; footers are small. */ object SettingsTextStyles { + /** The large "Settings" title at the top of the overview. */ + val largeTitle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 41.sp, + ) + val sectionHeader = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, - lineHeight = 16.sp, - letterSpacing = 0.9.sp, + fontSize = 16.sp, + lineHeight = 21.sp, ) val rowLabel = TextStyle( fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.5.sp, - lineHeight = 19.sp, + fontWeight = FontWeight.Normal, + fontSize = 17.sp, + lineHeight = 22.sp, ) + /** Section footers and the few rows that still carry a description. */ val rowDescription = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 12.5.sp, + fontSize = 13.sp, lineHeight = 17.sp, ) val rowValue = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, - fontSize = 13.5.sp, - lineHeight = 18.sp, + fontSize = 17.sp, + lineHeight = 22.sp, ) val accountName = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.SemiBold, - fontSize = 19.sp, - lineHeight = 24.sp, + fontSize = 20.sp, + lineHeight = 25.sp, + ) + + /** The account card's "Admin" capsule. */ + val badge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + + /** Popup menu rows (`SiloMenuItem`), kept at the menus' own size. */ + val menuLabel = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.5.sp, + lineHeight = 19.sp, ) } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/pip/MobilePictureInPictureSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/pip/MobilePictureInPictureSourceTest.kt index f7f00643eb..cff5b8b9dd 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/pip/MobilePictureInPictureSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/pip/MobilePictureInPictureSourceTest.kt @@ -47,11 +47,11 @@ class MobilePictureInPictureSourceTest { @Test fun mobileSettingsExposePipToggle() { // Pin the binding, not just the label: the label is copy and moves - // with the settings voice (it was "Picture-in-Picture" before the - // sentence-case pass), while a switch row bound to the PiP preference - // is what actually makes the toggle reachable. + // with the settings voice (back to "Picture-in-Picture" with the Apple + // title-case pass), while a switch row bound to the PiP preference is + // what actually makes the toggle reachable. assertTrue(settings.contains("checked = pictureInPictureEnabled")) assertTrue(settings.contains("onCheckedChange = onPictureInPictureEnabledChanged")) - assertTrue(settings.contains("Picture-in-picture")) + assertTrue(settings.contains("Picture-in-Picture")) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListScreen.kt index 686d3a1be7..7bb4c1bf15 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListScreen.kt @@ -47,6 +47,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.tv.ui.screens.settings.SettingsBackground import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus import org.siloserver.silo.tv.ui.components.TvDialogOption import org.siloserver.silo.tv.ui.components.TvOptionDialog @@ -108,7 +109,7 @@ fun TvServerListScreen( modifier = Modifier .fillMaxSize() .then(contentInitialFocus) - .background(ServerSettingsBackground) + .background(SettingsBackground) .padding(start = 44.dp, top = Spacing.safeArea, end = 44.dp, bottom = Spacing.xxxl), ) { Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) { @@ -210,7 +211,6 @@ fun TvServerListScreen( } -private val ServerSettingsBackground = Color(0xFF17181A) private val ServerListMaxWidth = 620.dp private val ServerRowShape = RoundedCornerShape(8.dp) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index d962ff3903..2e5824d8c8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -35,13 +35,14 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.ClosedCaption +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.MonitorHeart +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SmartDisplay import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.ClosedCaption -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.MonitorHeart -import androidx.compose.material.icons.filled.PlayCircle -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -62,8 +63,6 @@ import org.siloserver.silo.model.settings.SeekDirection import org.siloserver.silo.model.settings.SeekIntervalSupport import org.siloserver.silo.model.settings.SeekIntervals import org.siloserver.silo.model.settings.SeekMedia -import org.siloserver.silo.tv.ui.components.TvDialogOption -import org.siloserver.silo.tv.ui.components.TvOptionDialog import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult @@ -114,6 +113,12 @@ import org.siloserver.silo.model.settings.SubtitlePositionPreset import org.siloserver.silo.model.settings.pointSize import org.siloserver.silo.tv.BuildConfig import org.siloserver.silo.tv.R +import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvPrivacyPolicyDialog +import org.siloserver.silo.common.ui.components.ProfileAvatarRef +import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.common.ui.components.profileAvatarDisplayText +import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage +import androidx.compose.ui.layout.ContentScale import org.siloserver.silo.tv.data.preferences.SubtitleMode import org.siloserver.silo.tv.ui.screens.player.TvSubtitleAppearanceOptions import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsSettingsPane @@ -320,44 +325,46 @@ fun TvSettingsScreen( } } +/** + * The rail categories, in tvOS `TVSettingsCategory` order, with its rail + * descriptions, pane blurbs, and outlined glyphs. `stethoscope` has no Material + * twin; MonitorHeart is the nearest "check the patient" glyph. + */ internal enum class TvSettingsCategory( val title: String, - val eyebrow: String, + val railDescription: String, val blurb: String, val icon: ImageVector, ) { General( title = "General", - eyebrow = "PREFERENCES", + railDescription = "App and navigation", blurb = "App-level options for this Android TV.", - icon = Icons.Filled.Settings, + icon = Icons.Outlined.Settings, ), Playback( title = "Playback", - eyebrow = "PREFERENCES", - blurb = "Streaming, episode, and playback behavior for this device.", - icon = Icons.Filled.PlayCircle, + railDescription = "Quality and episodes", + blurb = "Streaming quality and episode behavior for this Android TV.", + icon = Icons.Outlined.SmartDisplay, ), Subtitles( title = "Subtitles", - eyebrow = "PREFERENCES", - blurb = "Language, behavior, and subtitle appearance.", - icon = Icons.Filled.ClosedCaption, + railDescription = "Language and appearance", + blurb = "Language, behavior, and on-screen appearance.", + icon = Icons.Outlined.ClosedCaption, ), - // tvOS `TVSettingsCategory` puts Diagnostics fourth, ahead of Server, under - // its own SUPPORT eyebrow. `stethoscope` has no Material twin; MonitorHeart - // is the nearest "check the patient" glyph. Diagnostics( title = "Diagnostics", - eyebrow = "SUPPORT", + railDescription = "Reports and support", blurb = "Review and send diagnostics to this Silo server.", - icon = Icons.Filled.MonitorHeart, + icon = Icons.Outlined.MonitorHeart, ), Server( title = "Server", - eyebrow = "CONNECTION", - blurb = "Active server, device pairing, and account tools.", - icon = Icons.Filled.Dns, + railDescription = "Connection and version", + blurb = "The Silo server this Android TV is connected to.", + icon = Icons.Outlined.Dns, ), } @@ -453,8 +460,8 @@ private fun SettingsSplitLayout( modifier = Modifier .fillMaxSize() .background(SettingsBackground) - // tvOS TVSettingsView: safeAreaX 88pt + HStack spacing 64pt, with a - // 430pt rail — halved to Android dp. Full-screen surface (the shell + // tvOS TVSettingsView: safeAreaX 88pt + HStack spacing 52pt, with a + // 490pt rail — halved to Android dp. Full-screen surface (the shell // hides the top bar on this route), so only the safe-area inset. .padding( start = 44.dp, @@ -462,7 +469,7 @@ private fun SettingsSplitLayout( end = 44.dp, bottom = Spacing.xxxl, ), - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(26.dp), verticalAlignment = Alignment.Top, ) { SettingsRail( @@ -479,7 +486,7 @@ private fun SettingsSplitLayout( }, onSwitchProfile = onSwitchProfile, onRequestSignOut = onRequestSignOut, - modifier = Modifier.width(200.dp), + modifier = Modifier.width(245.dp), ) SettingsDetailPane( state = state, @@ -570,12 +577,13 @@ private fun SettingsRail( ) { Text( text = "Settings", - style = MaterialTheme.typography.displayMedium.copy(fontSize = 22.sp, lineHeight = 26.sp), + style = MaterialTheme.typography.displayMedium.copy(fontSize = 24.sp, lineHeight = 29.sp), + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 10.dp, bottom = 10.dp), + modifier = Modifier.padding(start = 12.dp, bottom = 10.dp), ) SettingsAccountRow( - name = state.profileName ?: state.user?.username ?: "-", + name = state.profileName ?: state.user?.username ?: "Silo", subtitle = accountSubtitle(state), avatar = state.profileAvatar, onClick = onSwitchProfile, @@ -607,21 +615,18 @@ private fun SettingsRail( ) Text( text = "Silo ${clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER)}", - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - letterSpacing = 1.sp, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.padding(start = 12.dp, top = 6.dp), ) } } /** - * tvOS `TVSettingsRailRowStyle` parity: rows rest transparent, the selected - * category keeps a soft white fill + hairline border while unfocused, and the - * focused row inverts to the white platter. + * tvOS `TVSettingsRailRowStyle` parity: a glyph beside the category title and + * its rail description. Rows rest transparent, the selected category keeps a + * soft white fill + hairline border while unfocused, and the focused row + * inverts to the white platter. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -660,7 +665,7 @@ private fun SettingsRailCategoryRow( scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), modifier = (focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) .fillMaxWidth() - .height(38.dp) + .height(RailRowHeight) .focusProperties { right = rightFocusRequester ?: FocusRequester.Default } @@ -671,20 +676,34 @@ private fun SettingsRailCategoryRow( .fillMaxSize() .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( imageVector = category.icon, contentDescription = null, tint = foreground, - modifier = Modifier.size(15.dp), - ) - Text( - text = category.title, - style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = foreground, - modifier = Modifier.weight(1f), + modifier = Modifier.size(17.dp), ) + Column(modifier = Modifier.weight(1f)) { + // One line each: the row is a fixed height, so a wrapped + // description at a large font scale would be cut off mid-line. + Text( + text = category.title, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp, lineHeight = 18.sp), + fontWeight = FontWeight.Medium, + color = foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // TV text keeps its 14sp floor, a little above tvOS's 16pt. + Text( + text = category.railDescription, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 17.sp), + color = foreground.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -722,7 +741,7 @@ private fun SettingsRailActionRow( scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), modifier = Modifier .fillMaxWidth() - .height(38.dp) + .height(40.dp) .onFocusChanged { if (it.isFocused) onFocused() }, ) { Row( @@ -736,7 +755,7 @@ private fun SettingsRailActionRow( imageVector = icon, contentDescription = null, tint = foreground, - modifier = Modifier.size(15.dp), + modifier = Modifier.size(17.dp), ) Text( text = label, @@ -800,24 +819,7 @@ private fun SettingsDetailPane( ) { CompositionLocalProvider(LocalSettingsDetailFocusReporter provides onDetailFocusChanged) { Column(modifier = modifier.fillMaxSize()) { - Text( - text = selectedCategory.eyebrow, - style = SettingsMonoHeaderStyle(), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = selectedCategory.title, - style = MaterialTheme.typography.displaySmall.copy(fontSize = 20.sp, lineHeight = 24.sp), - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(top = 5.dp), - ) - Text( - text = selectedCategory.blurb, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 5.dp, bottom = 14.dp), - ) + SettingsPaneHeader(selectedCategory) when (selectedCategory) { TvSettingsCategory.General -> TvGeneralSettingsPane( @@ -894,6 +896,47 @@ private fun SettingsDetailPane( } } +/** + * tvOS pane header (`TVSettingsView` detail header): the category glyph on a + * graphite tile beside the category title and its blurb. + */ +@Composable +private fun SettingsPaneHeader(category: TvSettingsCategory) { + Row( + modifier = Modifier.padding(bottom = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + .background(PaneHeaderTile), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = category.icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = category.title, + style = MaterialTheme.typography.displaySmall.copy(fontSize = 21.sp, lineHeight = 25.sp), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = category.blurb, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun TvGeneralSettingsPane( state: TvSettingsViewModel.UiState, @@ -971,8 +1014,9 @@ private fun TvGeneralSettingsPane( ) } SettingsFooterText( - text = "Choices sync with other TVs signed into this profile " + - "unless \"Only This Device\" is on.", + text = "Start with Balanced, Compact, Cinema, or Artwork Only, then fine-tune size " + + "and captions. These choices sync with other TVs on this profile unless Only " + + "This Device is on.", ) } } @@ -1115,6 +1159,14 @@ private fun TvPlaybackSettingsPane( onClick = { activePicker = PlaybackPicker.Quality }, focusRequester = firstFocusRequester, ) + SettingsValueRow( + label = "Audio Language", + value = LanguageOptions.label( + state.audioLanguage, + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + ), + onClick = { activePicker = PlaybackPicker.AudioLanguage }, + ) // tvOS TVPlaybackSettingsPane STREAMING parity: Dolby Vision // (default on; off plays the HDR10 base layer) with the // narrower Profile 7 fallback nested under it — the P7 row @@ -1141,33 +1193,16 @@ private fun TvPlaybackSettingsPane( checked = state.forceHdrPassthrough, onCheckedChange = onForceHdrPassthroughChanged, ) + // tvOS footer: the chosen preset's description, then the + // caveat for the one control here that can misbehave. SettingsFooterText( - text = "Allows HDR playback when this TV doesn't report support. " + - "This does not force the HDMI output into HDR; Android may still " + - "convert the picture to SDR. Enable only if you've confirmed your " + - "TV supports the source format. Unsupported formats may produce " + - "a black screen or incorrect colors.", - ) - } - } - item { - SettingsGroup(title = "Audio") { - SettingsValueRow( - label = "Preferred Audio Language", - value = LanguageOptions.label( - state.audioLanguage, - SettingKeys.PLAYBACK_AUDIO_LANGUAGE, - ), - onClick = { activePicker = PlaybackPicker.AudioLanguage }, - ) - SettingsInfoRow( - label = "Audio Quality", - value = "Best Compatible", - ) - SettingsFooterText( - text = "Uses the preferred language when available, then English. " + - "Within that language, Silo chooses the highest-quality track " + - "supported by this TV and its current audio output.", + text = ( + QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.description + ?: "${QualityPresets.describe(state.qualityResolution, state.maxBitrateKbps)}." + ) + " Force HDR Passthrough allows HDR playback when this TV doesn't report " + + "support. It does not force the HDMI output into HDR; Android may still convert " + + "the picture to SDR. Enable it only if you've confirmed your TV supports the " + + "source format. Unsupported formats may produce a black screen or incorrect colors.", ) } } @@ -1187,26 +1222,31 @@ private fun TvPlaybackSettingsPane( // is a select and TV has no segmented control, so this uses the // same value row + picker sheet every other enum here does. SettingsValueRow( - label = stringResource(R.string.settings_intro_skip_title), + label = "Skip Intros", value = stringResource(introSkipModeLabel(state.introSkipMode)), onClick = { activePicker = PlaybackPicker.IntroSkipMode }, ) SettingsToggleRow( - label = "Auto-Skip Credits", + label = "Skip Credits", checked = state.autoSkipCredits, onCheckedChange = onAutoSkipCreditsChanged, ) SettingsValueRow( - label = "Resume Skip-Back", + label = "Rewind on Resume", value = resumeRewindLabel(state.resumeRewindSeconds), onClick = { activePicker = PlaybackPicker.ResumeRewind }, ) SettingsValueRow( - label = "Still-Watching Prompt After", + label = "Still Watching Prompt", value = passOutThresholdLabel(state.passOutThreshold), onClick = { activePicker = PlaybackPicker.PassOutThreshold }, focusRequester = seekFallbackFocus, ) + SettingsFooterText( + text = "Rewind on Resume skips back when you return to a partly watched title. Still " + + "Watching Prompt sets how many episodes play in a row before Silo asks whether " + + "you're still watching.", + ) } } if (seekState != null) { @@ -1233,9 +1273,9 @@ private fun TvPlaybackSettingsPane( } if (recovery.visible) { item { - SettingsGroup(title = "Playback recovery") { + SettingsGroup(title = "Playback Recovery") { SettingsActionRow( - label = if (recovery.busy) "Recovering playback…" else "Retry pending playback stops", + label = if (recovery.busy) "Recovering Playback…" else "Retry Pending Playback Stops", onClick = recovery.retry, ) SettingsFooterText(text = recovery.message) @@ -1272,7 +1312,7 @@ private fun TvPlaybackSettingsPane( onDismiss = { activePicker = null }, ) PlaybackPicker.AudioLanguage -> TvSettingsPickerSheet( - title = "Preferred Audio Language", + title = "Audio Language", options = audioLanguages.map { PickerOption(it.first, it.second) }, selectedId = state.audioLanguage, onSelect = { onAudioLanguageChanged(it); activePicker = null }, @@ -1288,25 +1328,20 @@ private fun TvPlaybackSettingsPane( }, onDismiss = { activePicker = null }, ) - // Three short options: a compact popup over the settings list, not the - // full-screen picker the longer lists use. - PlaybackPicker.IntroSkipMode -> TvOptionDialog( - title = stringResource(R.string.settings_intro_skip_title), + PlaybackPicker.IntroSkipMode -> TvSettingsPickerSheet( + title = "Skip Intros", options = IntroSkipMode.entries.map { mode -> - TvDialogOption( - key = mode.wireValue, - title = stringResource(introSkipModeLabel(mode)), - selected = mode == state.introSkipMode, - onClick = { - onIntroSkipModeChanged(mode) - activePicker = null - }, - ) + PickerOption(mode.wireValue, stringResource(introSkipModeLabel(mode))) + }, + selectedId = state.introSkipMode.wireValue, + onSelect = { id -> + IntroSkipMode.entries.firstOrNull { it.wireValue == id }?.let(onIntroSkipModeChanged) + activePicker = null }, onDismiss = { activePicker = null }, ) PlaybackPicker.ResumeRewind -> TvSettingsPickerSheet( - title = "Resume Skip-Back", + title = "Rewind on Resume", options = ResumeRewindOptions.map { PickerOption(it.toString(), resumeRewindLabel(it)) }, selectedId = state.resumeRewindSeconds.toString(), onSelect = { id -> @@ -1316,7 +1351,7 @@ private fun TvPlaybackSettingsPane( onDismiss = { activePicker = null }, ) PlaybackPicker.PassOutThreshold -> TvSettingsPickerSheet( - title = "Still-Watching Prompt After", + title = "Still Watching Prompt", options = PassOutThresholdOptions.map { PickerOption(it.toString(), passOutThresholdLabel(it)) }, selectedId = state.passOutThreshold.toString(), onSelect = { id -> @@ -1468,7 +1503,16 @@ private fun TvSeekIntervalGroup( ) SettingsFooterText(text = "${state.legacyAudiobookSummary}. Uploads them to this profile.") } - SettingsFooterText(text = "Applies to every device on this profile.") + SettingsFooterText( + text = when (media) { + SeekMedia.Video -> + "Used by left and right presses on the remote and the on-screen skip buttons. " + + "Applies to every device signed in to this profile." + SeekMedia.Audiobook -> + "Used by the audiobook player's skip buttons. Applies to every device signed " + + "in to this profile." + }, + ) state.saveErrorFor(media)?.let { SettingsFooterText(text = it) } } } @@ -1534,12 +1578,6 @@ private fun TvSubtitleSettingsPane( ) { item { SettingsGroup(title = "Profile") { - SettingsValueRow( - label = "Mode", - value = state.subtitleMode.label, - onClick = { activePicker = SubtitlePicker.Mode }, - focusRequester = firstFocusRequester, - ) SettingsValueRow( label = "Language", value = LanguageOptions.label( @@ -1547,17 +1585,13 @@ private fun TvSubtitleSettingsPane( SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, ), onClick = { activePicker = SubtitlePicker.Language }, + focusRequester = firstFocusRequester, + ) + SettingsValueRow( + label = "Behavior", + value = state.subtitleMode.label, + onClick = { activePicker = SubtitlePicker.Mode }, ) - if (metadataLanguageEnabled) { - SettingsValueRow( - label = "Metadata Language", - value = LanguageOptions.label( - state.metadataLanguage, - SettingKeys.CATALOG_METADATA_LANGUAGE, - ), - onClick = { activePicker = SubtitlePicker.MetadataLanguage }, - ) - } SettingsToggleRow( label = "Show Forced Subtitles", checked = state.showForcedSubtitles, @@ -1569,13 +1603,31 @@ private fun TvSubtitleSettingsPane( ) } } + if (metadataLanguageEnabled) { + item { + // The footer is the settings contract's own description of the key. + SettingsGroup(title = "Metadata") { + SettingsValueRow( + label = "Metadata Language", + value = LanguageOptions.label( + state.metadataLanguage, + SettingKeys.CATALOG_METADATA_LANGUAGE, + ), + onClick = { activePicker = SubtitlePicker.MetadataLanguage }, + ) + SettingsFooterText( + text = "Fallback language Silo prefers for titles, descriptions, and artwork.", + ) + } + } + } item { SettingsGroup(title = "Appearance") { TvSettingsSubtitlePreview(state.effectiveSubtitleAppearance) SettingsToggleRow( // tvOS parity: appearance follows the OS captioning // settings while this is on. - label = "Use System Caption Style", + label = "Use Device Settings", checked = state.subtitleMatchesDevice, onCheckedChange = onSubtitleMatchesDeviceChanged, ) @@ -1669,7 +1721,7 @@ private fun TvSubtitleSettingsPane( when (activePicker) { SubtitlePicker.Mode -> TvSettingsPickerSheet( - title = "Mode", + title = "Behavior", options = SubtitleMode.values().map { PickerOption(it.name, it.label) }, selectedId = state.subtitleMode.name, onSelect = { id -> @@ -1897,6 +1949,7 @@ private fun TvServerSettingsPane( firstFocusRequester: FocusRequester, onManageServers: () -> Unit, ) { + var showPrivacyPolicy by remember { mutableStateOf(false) } LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -1909,7 +1962,7 @@ private fun TvServerSettingsPane( value = state.serverName.ifBlank { "Not configured" }, ) if (state.serverUrl.isNotBlank() && state.serverName != state.serverUrl) { - SettingsInfoRow(label = "URL", value = state.serverUrl, singleLine = false) + SettingsInfoRow(label = "Address", value = state.serverUrl, singleLine = false) } SettingsActionRow( label = "Manage Servers", @@ -1926,12 +1979,21 @@ private fun TvServerSettingsPane( // Same "1.0.0 (5)" form as the phone About row, so a TV support // report names the build the server's admin Activity page shows. SettingsInfoRow( - label = "Version", + label = "App Version", value = clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER), ) + // tvOS About: the policy as a QR code, for a device with a browser. + SettingsActionRow( + label = "Privacy Policy", + onClick = { showPrivacyPolicy = true }, + ) } } } + + if (showPrivacyPolicy) { + TvPrivacyPolicyDialog(onDismiss = { showPrivacyPolicy = false }) + } } private const val SubtitlePreviewText = "Subtitles will look like this" @@ -2026,32 +2088,74 @@ fun TvSettingsPickerSheet( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - // tvOS presents pickers as a fullScreenCover over the opaque app - // background with a leading nav title and a centered option column. + // tvOS `TVSettingsPickerSheet`: a card over a dark scrim, with the + // title, a "Choose an option" line and a close hint above a hairline, + // then the option list. Box( modifier = Modifier .fillMaxSize() .onFocusChanged { pickerHasFocus = it.hasFocus } - .background(Color.Black.copy(alpha = 0.94f)), + .background(Color.Black.copy(alpha = 0.88f)), contentAlignment = Alignment.Center, ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(380.dp) + .heightIn(max = 460.dp) + .clip(PickerCardShape) + .background(PickerCardFill) + .border(1.dp, Color.White.copy(alpha = 0.09f), PickerCardShape), ) { - Text( - text = title, - style = MaterialTheme.typography.displaySmall.copy(fontSize = 19.sp, lineHeight = 23.sp), - fontWeight = FontWeight.SemiBold, - color = Color.White, - modifier = Modifier.padding(bottom = 20.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 18.dp, end = 14.dp, top = 16.dp, bottom = 12.dp), + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.displaySmall.copy(fontSize = 21.sp, lineHeight = 25.sp), + fontWeight = FontWeight.Bold, + color = Color.White, + ) + Text( + text = "Choose an option", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.6f), + ) + } + Row( + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.055f)) + .padding(horizontal = 9.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Undo, + contentDescription = null, + tint = Color.White.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp), + ) + Text( + text = "Back to close", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.6f), + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.08f)), ) - LazyColumn( state = listState, - modifier = Modifier - .width(420.dp), - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(options, key = { it.id }) { option -> val isFocusTarget = option.id == (options.getOrNull(focusTargetIndex)?.id) @@ -2080,28 +2184,25 @@ private fun TvSettingsPickerOptionRow( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val shape = RoundedCornerShape(7.dp) val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val foreground = if (isFocused) FocusedContent else Color.White Surface( onClick = onClick, interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), + shape = ClickableSurfaceDefaults.shape(shape = RowShape), colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.07f), + // tvOS: the current option keeps the "selected" fill at rest. + containerColor = Color.White.copy(alpha = if (selected) 0.14f else 0.07f), contentColor = Color.White, focusedContainerColor = FocusedContainer, focusedContentColor = FocusedContent, pressedContainerColor = FocusedContainer, pressedContentColor = FocusedContent, ), - border = ClickableSurfaceDefaults.border( - border = Border(BorderStroke(1.dp, Color.White.copy(alpha = 0.09f)), shape = shape), - focusedBorder = Border.None, - pressedBorder = Border.None, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + border = invertedRowBorder(), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), modifier = modifier .fillMaxWidth() .semantics { this.selected = selected }, @@ -2109,26 +2210,28 @@ private fun TvSettingsPickerOptionRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 9.dp), + .heightIn(min = RowHeight) + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - // tvOS TVSettingsPickerOptionRow: the checkmark leads and always - // reserves its slot so option labels stay aligned. - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = if (isFocused) FocusedContent else Color.White, - modifier = Modifier - .size(15.dp) - .alpha(if (selected) 1f else 0f), - ) Text( text = option.label, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 15.sp, lineHeight = 18.sp), - color = if (isFocused) FocusedContent else Color.White, + style = SettingsRowTextStyle(), + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = foreground, modifier = Modifier.weight(1f), ) + // tvOS TVSettingsPickerOptionRow: a filled check circle trails the + // current option. + if (selected) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = foreground, + modifier = Modifier.size(16.dp), + ) + } } } } @@ -2164,17 +2267,19 @@ internal fun TvSettingsConfirmDialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { + // tvOS confirmation overlay: a #15171C card over a 62% black scrim. Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.86f)), + .background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier .width(320.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surface) + .clip(PickerCardShape) + .background(PickerCardFill) + .border(1.dp, Color.White.copy(alpha = 0.09f), PickerCardShape) .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), @@ -2182,12 +2287,14 @@ internal fun TvSettingsConfirmDialog( Text( text = title, style = MaterialTheme.typography.titleMedium.copy(fontSize = 19.sp, lineHeight = 22.sp), + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) Text( text = message, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -2247,8 +2354,8 @@ private fun DialogButton( // --------------------------------------------------------------------------- /** - * tvOS `TVSettingsSectionHeader` parity: small mono uppercase section label - * (size 15pt mono semibold, tracking 2) above tightly packed rows. + * tvOS `TVSettingsSectionHeader` parity: a small uppercase semibold label in + * the secondary colour, aligned with the row text, above tightly packed rows. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -2259,9 +2366,9 @@ internal fun SettingsGroup( Column(verticalArrangement = Arrangement.spacedBy(SettingsGroupRowSpacing)) { Text( text = title.uppercase(), - style = SettingsMonoHeaderStyle(), + style = SettingsGroupHeaderStyle(), color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 2.dp, top = 8.dp, bottom = 2.dp), + modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp), ) content() } @@ -2289,22 +2396,22 @@ private fun SettingsNestedGroup( } @Composable -private fun SettingsMonoHeaderStyle() = +private fun SettingsGroupHeaderStyle() = MaterialTheme.typography.labelMedium.copy( - fontFamily = FontFamily.Monospace, fontSize = 14.sp, lineHeight = 18.sp, - letterSpacing = 1.5.sp, + letterSpacing = 1.sp, fontWeight = FontWeight.SemiBold, ) -/** Shared 16sp text for all detail-pane row labels and values. */ +/** Shared text for all detail-pane row labels and values (tvOS 26pt). */ @Composable private fun SettingsRowTextStyle() = - MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp, lineHeight = 20.sp) + MaterialTheme.typography.bodyLarge.copy(fontSize = 15.sp, lineHeight = 19.sp) -private val RowShape = RoundedCornerShape(10.dp) -private val RowMaxWidth = 520.dp +// tvOS pane rows: 14pt corners, content capped at 1080pt — halved to dp. +private val RowShape = RoundedCornerShape(7.dp) +private val RowMaxWidth = 540.dp /** * Gap between the rows (and the trailing footer) inside one [SettingsGroup]. @@ -2314,29 +2421,49 @@ private val RowMaxWidth = 520.dp * height — see `TvDiagnosticsSettingsPane`. Two copies of the number would * silently drift. */ -internal val SettingsGroupRowSpacing = 6.dp -// 42dp keeps the 16sp row text comfortably centered — audit 2026-07-20. -private val RowHeight = 42.dp +internal val SettingsGroupRowSpacing = 4.dp +// tvOS rows are 66pt tall around 26pt text; 38dp keeps that proportion for +// the 15sp row text. +private val RowHeight = 38.dp + +/** Rail category rows carry a title over a description (tvOS rail rows). */ +private val RailRowHeight = 50.dp + +/** + * The one settings-surface ground color: black, like the Apple apps' + * `siloBackground`. Shared so no screen re-hardcodes it. + */ +internal val SettingsBackground = Color(0xFF000000) + +/** Graphite tile behind the pane header glyph (Apple `siloIconTile`). */ +private val PaneHeaderTile = Color(0xFF3A3A3C) -/** The one settings-surface ground color. Shared so no screen re-hardcodes it. */ -internal val SettingsBackground = Color(0xFF17181A) +/** Picker and confirmation cards (Apple `siloSurfaceElevated`), 30pt corners. */ +private val PickerCardFill = Color(0xFF15171C) +private val PickerCardShape = RoundedCornerShape(15.dp) // tvOS destructive row colors: bright red at rest on black, deeper red on the // focused white platter (TVSettingsRailRowStyle). private val DestructiveRed = Color(0xFFD22F3F) private val DestructiveRedOnPlatter = Color(0xFFB00020) +/** + * tvOS rail profile row: the profile's avatar, its name over the account line, + * and a chevron. Selecting it switches profile. + */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun SettingsAccountRow( name: String, subtitle: String, - avatar: String?, + avatar: ProfileAvatarRef, onClick: () -> Unit, focusRequester: FocusRequester? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val foreground = if (isFocused) FocusedContent else Color.White + val avatarImage = rememberProfileAvatarImage(avatar) Surface( onClick = onClick, interactionSource = interactionSource, @@ -2364,33 +2491,43 @@ private fun SettingsAccountRow( ) { Box( modifier = Modifier - .size(30.dp) + .size(34.dp) .clip(CircleShape) - .background( - if (isFocused) FocusedContent.copy(alpha = 0.15f) - else Color.White.copy(alpha = 0.12f), - ), + .background(Color.White.copy(alpha = 0.12f)), contentAlignment = Alignment.Center, ) { - Text( - text = name.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp), - color = if (isFocused) FocusedContent else Color.White, - ) + if (avatarImage != null) { + ThumbhashImage( + url = avatarImage.url, + thumbhash = null, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, + ) + } else { + Text( + text = profileAvatarDisplayText(avatar, name), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp), + color = foreground, + ) + } } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { Text( text = name, style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), fontWeight = FontWeight.SemiBold, - color = if (isFocused) FocusedContent else Color.White, + color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = subtitle, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = (if (isFocused) FocusedContent else Color.White).copy(alpha = 0.75f), + color = foreground.copy(alpha = 0.62f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -2398,7 +2535,7 @@ private fun SettingsAccountRow( Icon( imageVector = Icons.Default.ChevronRight, contentDescription = null, - tint = (if (isFocused) FocusedContent else Color.White).copy(alpha = 0.5f), + tint = foreground.copy(alpha = 0.5f), modifier = Modifier.size(14.dp), ) } @@ -2439,7 +2576,7 @@ internal fun SettingsValueRow( Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -2502,7 +2639,7 @@ internal fun SettingsActionRow( Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -2562,7 +2699,7 @@ internal fun SettingsToggleRow( Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -2595,7 +2732,7 @@ internal fun SettingsInfoRow(label: String, value: String, singleLine: Boolean = .clip(RowShape) .background(Color.White.copy(alpha = 0.07f)) .border(1.dp, Color.White.copy(alpha = 0.09f), RowShape) - .padding(horizontal = 16.dp, vertical = if (singleLine) 0.dp else 10.dp), + .padding(horizontal = 12.dp, vertical = if (singleLine) 0.dp else 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -2624,7 +2761,7 @@ internal fun SettingsFooterText(text: String, modifier: Modifier = Modifier) { Text( text = text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), // The modifier goes outermost so a caller measuring this footer sees the // laid-out block, not the text before its width cap and padding apply. modifier = modifier diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 2f4ccf75fa..813f2ec1f2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -23,6 +23,8 @@ import org.siloserver.silo.model.settings.SubtitleFontSizePreset import org.siloserver.silo.model.settings.SubtitlePositionPreset import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.common.ui.components.ProfileAvatarRef +import org.siloserver.silo.common.ui.components.avatarRef import org.siloserver.silo.network.TokenManager import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.ProfileRepository @@ -86,7 +88,7 @@ class TvSettingsViewModel( val userError: String? = null, // Active profile identity for the tappable account header row. val profileName: String? = null, - val profileAvatar: String? = null, + val profileAvatar: ProfileAvatarRef = ProfileAvatarRef.None, val serverUrl: String = "", val serverName: String = "", // Whether the canonical settings probe succeeded; playback is @@ -185,7 +187,7 @@ class TvSettingsViewModel( userLoading = false, userError = null, profileName = profile?.name, - profileAvatar = profile?.avatar, + profileAvatar = profile?.avatarRef() ?: ProfileAvatarRef.None, ) } return@launch diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt index 9083fc0b05..cef6f1704e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt @@ -408,7 +408,7 @@ internal fun TvDiagnosticsSettingsPane( */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvPrivacyPolicyDialog(onDismiss: () -> Unit) { +internal fun TvPrivacyPolicyDialog(onDismiss: () -> Unit) { BackHandler(onBack = onDismiss) val closeFocus = remember { FocusRequester() } LaunchedEffect(Unit) { diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsCategoryTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsCategoryTest.kt index 754729d444..185945df60 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsCategoryTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsCategoryTest.kt @@ -18,7 +18,6 @@ class TvSettingsCategoryTest { ), TvSettingsCategory.entries, ) - assertEquals("SUPPORT", TvSettingsCategory.Diagnostics.eyebrow) } @Test From 74fc3e98df27c92f927e466e6c3f59c74efbd19a Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:54:16 -0400 Subject: [PATCH 13/87] feat(auth): sign in with the server's identity provider and the new TV sign-in (#448) * feat(auth): sign in with the server's identity provider and the new TV sign-in The phone signs in through the server's OIDC provider in a Custom Tab, offers "Not you? Switch account", and connects or disconnects providers in Settings. The TV shows an 8-digit code with a QR, follows opened and canceled states, and hides the password form on provider-only servers; phones approve its code. Every sign-out now runs one shared teardown, which also clears title art. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): bind provider flows to the saved login and fix two TV approval gaps A provider sign-in or link left open in the browser now records the saved server's durable login id when it starts. The redirect is redeemed only while that login is unchanged, so a password sign-in or sign-out in between supersedes the flow, and a link still finishes after Android recreates the process. Password sign-in and sign-out also discard the pending flow. A silo://device?token= link can retry its lookup with Continue, and the nearby TV approval card names the account it signs the TV in as. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(pairing): fail fast on TV refusals and stop stale sign-in work The phone now reports a TV's failed result that arrives before any code (a different server at that address, an expired code) instead of waiting out the 30-90 second timeout. A pairing connection that ended after a newer advertiser start no longer touches that start's listener or busy flag. The nearby TV card goes away and its pairing is cancelled when the route hides the offer. In Sign-in settings, Cancel stops a link ticket request still under way, and identities are shown only while the same login is signed in. A TV approval whose sign-in context changed no longer cancels a password attempt made since. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): keep TV approvals on the server and account they started with The TV polls a resumed code only while the sign-in context it was started under is unchanged, and withdraws an expired code that failed to poll before asking for a new one. On the phone, a code lookup goes only to the server chosen when it started, a token link always uses the active server, an unknown lookup status offers no decision, and nearby approval stops when the card can't name the account. Provider discovery for a URL no longer depends on the active server's update verdict. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): drop results from superseded sign-in flows and stale pairing runs A provider redemption now publishes its outcome only while its flow is still the latest: starting a new flow, or a sign-in or sign-out discarding the pending one, silences it, and discarding also clears an unshown result. On the TV, a pairing run that ends after a newer connection took over the receiver leaves that connection's push, session and status alone. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): don't spend a pinned bearer after its scope is gone When a fresh-auth request's refresh fails and its scope was signed out or replaced meanwhile, the request now fails instead of going out with the bearer captured before. On the TV, an advertiser startup superseded by a later start or stop closes its socket instead of publishing it. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): let a nearby phone hear the sign-in result before leaving When a nearby phone approved the code, the sign-in screen now waits up to five seconds for that session to finish its result and done exchange before routing on, so the phone doesn't report a failed pairing for a TV that signed in. A completed session routes on after its own dwell as before. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): keep the nearby wait from cancelling its own fallback route Consuming the sign-in success flag changes the effect's key, so doing it before the wait cancelled the wait and its fallback route. It is consumed after the wait now. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): show a link's result only on the server it was made for Sign-in settings now ignores a provider link result for another saved server, so switching servers before opening Settings doesn't show one server's "Connected" or failure on the other. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): recheck the server before saying a link connected The "Connected" message after a provider link is shown only if the link's server is still the active one once the section has reloaded. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(pairing): keep stale attempts out of the connection that replaced them A TV pairing attempt that outlived its connection (its session save can't be cancelled) no longer reports its result into the newer connection. The "Connected" message after a provider link also requires the same login, not only the same server. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(pairing): hide the nearby TV offer over audiobooks and the reader The nearby-TV card and its discovery are now off on the audiobook player and book reader routes too, not only the video player and cast remote. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): name the provider in "Use a different account" The link under the provider buttons picks another account at the identity provider, not another way into Silo, which read wrong on a server with password sign-in off. With one provider it now says "Use a different account"; with several it stays generic and opens the chooser. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../pairing/CompanionPairingCoordinator.kt | 299 ++++- .../pairing/CompanionPairingNsdBrowser.kt | 1 + .../silo/common/pairing/PairingAuthPort.kt | 4 + .../silo/common/pairing/PairingReceiver.kt | 598 ++++++--- .../common/pairing/TvPairingAdvertiser.kt | 124 +- .../CompanionPairingCoordinatorTest.kt | 179 +++ .../common/pairing/PairingReceiverTest.kt | 243 +++- androidApp/build.gradle.kts | 2 + androidApp/gradle.lockfile | 4 + .../src/androidMain/AndroidManifest.xml | 23 + .../silo/android/auth/NativeSignIn.kt | 361 ++++++ .../android/auth/NativeSignInCoordinator.kt | 349 ++++++ .../silo/android/auth/NativeSignInPlatform.kt | 154 +++ .../silo/android/auth/SignOutTeardown.kt | 71 ++ .../silo/android/di/AndroidModule.kt | 82 +- .../android/ui/navigation/AppNavigation.kt | 280 +++-- .../ui/navigation/DeviceLoginRouteParser.kt | 48 +- .../ui/navigation/DeviceLoginServerMatch.kt | 63 + .../ui/navigation/ExternalRouteNavigation.kt | 1 + .../silo/android/ui/navigation/Routes.kt | 20 +- .../silo/android/ui/screens/MainScreen.kt | 18 +- .../ui/screens/auth/DevicePairingScreen.kt | 674 +++++++++-- .../auth/DevicePairingWrongServerScreen.kt | 4 +- .../android/ui/screens/auth/LoginScreen.kt | 226 +++- .../android/ui/screens/auth/LoginViewModel.kt | 233 +++- .../ui/screens/auth/ServerSetupScreen.kt | 6 + .../ui/screens/auth/ServerSetupViewModel.kt | 12 +- .../ui/screens/auth/SignInProviderButton.kt | 130 ++ .../android/ui/screens/home/HomeScreen.kt | 48 - .../pairing/CompanionPairingBottomOverlay.kt | 222 +++- .../pairing/CompanionPairingViewModel.kt | 102 +- .../ui/screens/settings/SettingsScreen.kt | 25 +- .../ui/screens/settings/SettingsViewModel.kt | 19 +- .../ui/screens/settings/SignInSection.kt | 254 ++++ .../settings/SignInSettingsViewModel.kt | 455 +++++++ .../src/androidMain/res/values/strings.xml | 97 ++ .../auth/InMemoryPendingNativeSignInStore.kt | 17 + .../android/auth/NativeSignInCompleterTest.kt | 248 ++++ .../auth/NativeSignInLinkTransportTest.kt | 199 +++ .../auth/NativeSignInPersistenceTest.kt | 112 ++ .../silo/android/auth/NativeSignInTest.kt | 569 +++++++++ .../silo/android/auth/SignOutTeardownTest.kt | 77 ++ .../navigation/DeviceLoginRouteParserTest.kt | 25 + .../DeviceLoginServerIdentityMatchTest.kt | 99 ++ .../screens/auth/ApprovalAccountSwitchTest.kt | 31 + .../auth/DevicePairingSignInRequestTest.kt | 30 + .../ui/screens/auth/LoginV2ConsumerTest.kt | 48 +- .../ui/screens/auth/LoginViewModelSsoTest.kt | 471 ++++++++ ...SettingsViewModelOfflinePreferencesTest.kt | 33 +- .../ui/screens/settings/SignInSettingsTest.kt | 155 +++ .../siloserver/silo/tv/di/AndroidTvModule.kt | 44 +- .../silo/tv/ui/navigation/TvAppNavigation.kt | 8 +- .../silo/tv/ui/navigation/TvRoute.kt | 11 +- .../silo/tv/ui/screens/auth/QrCodePanel.kt | 13 +- .../silo/tv/ui/screens/auth/TvLoginScreen.kt | 1073 ++++++++++------- .../tv/ui/screens/auth/TvLoginViewModel.kt | 679 +++++++++-- .../tv/ui/screens/auth/TvPairDeviceScreen.kt | 29 +- .../silo/tv/ui/screens/auth/TvPairingPanel.kt | 420 +++++++ .../tv/ui/screens/auth/TvServerSetupScreen.kt | 350 +----- .../src/androidMain/res/values/strings.xml | 128 ++ .../auth/TvLoginMatchCodeLayoutTest.kt | 27 - .../auth/TvLoginViewModelDeviceSignInTest.kt | 764 ++++++++++++ .../screens/auth/TvLoginViewModelRaceTest.kt | 10 +- .../ui/screens/auth/TvPairingPanelCopyTest.kt | 44 + baselineprofile/gradle.lockfile | 3 + docs/api-v2/android-migration-status.md | 4 +- docs/auth-api-v2.md | 233 +++- gradle/libs.versions.toml | 5 + gradle/verification-metadata.xml | 40 + scripts/sync-apiv2-fixtures.sh | 35 +- .../silo/network/AndroidServerRegistry.kt | 11 + .../silo/network/EncryptedTokenManagerImpl.kt | 79 +- ...ncryptedTokenManagerScopeGenerationTest.kt | 49 + .../org/siloserver/silo/di/NetworkModule.kt | 4 + .../siloserver/silo/di/RepositoryModule.kt | 2 + .../silo/model/auth/DeviceCodeFormat.kt | 45 + .../silo/model/auth/DeviceLoginModels.kt | 34 +- .../silo/model/auth/ExternalSignInModels.kt | 202 ++++ .../silo/model/server/ServerEntry.kt | 7 + .../silo/network/AuthInterceptorImpl.kt | 222 +++- .../silo/network/AuthScopeSnapshot.kt | 33 + .../siloserver/silo/network/ServerRegistry.kt | 18 + .../network/SiloAuthUnavailableException.kt | 14 + .../siloserver/silo/network/TokenManager.kt | 16 + .../siloserver/silo/network/api/AuthApi.kt | 17 + .../siloserver/silo/network/api/AuthWireV2.kt | 15 +- .../silo/network/api/DeviceLoginApi.kt | 51 +- .../silo/network/api/ExternalSignInApi.kt | 399 ++++++ .../silo/network/api/ServerIdentityApi.kt | 165 +++ .../silo/network/apiv2/ApiV2Gate.kt | 25 +- .../siloserver/silo/pairing/PairingMessage.kt | 110 +- .../silo/repository/AuthRepository.kt | 20 + .../silo/repository/DeviceLoginRepository.kt | 400 ++++-- .../silo/repository/DeviceSignInMachine.kt | 399 ++++++ .../repository/ExternalSignInRepository.kt | 78 ++ .../repository/ServerIdentityRepository.kt | 71 ++ .../silo/viewmodel/DevicePairingViewModel.kt | 436 ++++++- .../silo/model/auth/DeviceCodeFormatTest.kt | 39 + .../SiloAuthPluginFreshActiveRefreshTest.kt | 71 ++ .../SiloAuthPluginFreshPinnedRefreshTest.kt | 251 ++++ .../SiloAuthPluginRefreshFailureTest.kt | 63 + .../silo/network/api/ExternalSignInApiTest.kt | 356 ++++++ .../silo/network/apiv2/ApiV2ContractTest.kt | 273 ++++- .../silo/network/apiv2/ApiV2NoFallbackTest.kt | 34 +- .../PairingProtocolGoldenFramesTest.kt | 148 +++ .../repository/DeviceLoginRepositoryTest.kt | 100 +- .../repository/DeviceSignInMachineTest.kt | 471 ++++++++ .../DevicePairingServerChoiceTest.kt | 354 ++++++ .../viewmodel/DevicePairingUiStateTest.kt | 2 +- .../resources/api/v2/fixtures/SOURCE | 10 +- .../v2/fixtures/cancel_device_login_ok.json | 3 + ...e_account_identity_link_invalid_grant.json | 7 + .../complete_oauth_login_invalid_grant.json | 7 + .../complete_oauth_login_native_ok.json | 17 + ...reate_account_identity_link_ticket_ok.json | 4 + ...t_identity_link_ticket_wrong_password.json | 14 + ..._account_identity_last_sign_in_method.json | 7 + .../get_device_login_capability_ok.json | 10 + .../api/v2/fixtures/get_device_login_ok.json | 14 + .../get_external_sign_in_capabilities_ok.json | 12 + .../get_oauth_handshake_capabilities_ok.json | 9 + .../api/v2/fixtures/get_system_info_ok.json | 2 +- .../resources/api/v2/fixtures/index.json | 356 ++++++ .../fixtures/list_account_identities_ok.json | 17 + .../v2/fixtures/list_auth_providers_ok.json | 20 + .../api/v2/fixtures/poll_device_login_ok.json | 22 + .../v2/fixtures/poll_device_login_opened.json | 9 + .../refresh_session_provider_unavailable.json | 7 + .../v2/fixtures/server_connections_ok.json | 28 + .../api/v2/fixtures/server_identity_ok.json | 3 + .../v2/fixtures/start_device_login_ok.json | 14 + 131 files changed, 15364 insertions(+), 1777 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/NativeSignIn.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/NativeSignInCoordinator.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/NativeSignInPlatform.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/SignOutTeardown.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/InMemoryPendingNativeSignInStore.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInLinkTransportTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInPersistenceTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginServerIdentityMatchTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/ApprovalAccountSwitchTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingSignInRequestTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanelCopyTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormat.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ServerIdentityApi.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceSignInMachine.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/ServerIdentityRepository.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormatTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshActiveRefreshTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshPinnedRefreshTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/pairing/PairingProtocolGoldenFramesTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceSignInMachineTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingServerChoiceTest.kt create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/cancel_device_login_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_native_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/get_device_login_capability_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/get_device_login_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/get_oauth_handshake_capabilities_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/list_account_identities_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/list_auth_providers_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_opened.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/server_connections_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/server_identity_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/start_device_login_ok.json diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinator.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinator.kt index 6f880273ea..ee0f3d87f4 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinator.kt @@ -4,6 +4,8 @@ import android.util.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.coroutineScope @@ -14,17 +16,25 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import org.siloserver.silo.model.auth.DeviceCodeFormat import org.siloserver.silo.model.auth.DeviceLoginDecisionResponse import org.siloserver.silo.model.auth.DeviceLoginLookupResponse import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.SiloAuthUnavailableException import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.IdentityTransitionBarrier import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.signedInEntries +import org.siloserver.silo.network.api.ServerIdentityApi +import org.siloserver.silo.pairing.PairingEndpoint +import org.siloserver.silo.pairing.PairingFailureCode import org.siloserver.silo.pairing.PairingMessage import org.siloserver.silo.pairing.PairingProtocol +import org.siloserver.silo.pairing.PairingReceiverState import org.siloserver.silo.pairing.PairingServerStatus import org.siloserver.silo.repository.DeviceLoginRepository +import org.siloserver.silo.repository.ServerIdentityRepository data class CompanionPairingTarget( val deviceId: String, @@ -37,25 +47,99 @@ data class CompanionPairingTarget( val sessionId: String? = null, /** DNS-SD instance name used by onServiceLost; it can differ from the TXT display name. */ val serviceName: String = name, -) + /** TXT `srv`: the deployment identity a signed-out (`st=login`) TV wants. */ + val serverIdentity: String? = null, +) { + /** + * A TV on its sign-in screen for one server. Older TVs advertise no `st` + * (or `setup`); anything that isn't `login` is treated as a setup TV. + */ + val isSignedOutTv: Boolean get() = state == PairingReceiverState.Login.wire + + /** Only `setup` and `login` TVs are offered; an unknown state is a newer TV this build can't serve. */ + val isOfferable: Boolean + get() = state == null || state == PairingReceiverState.Setup.wire || + (isSignedOutTv && !serverIdentity.isNullOrBlank()) +} data class CompanionPairingServer( val id: String, val url: String, val displayName: String, + /** The phone's active server, preselected in the chooser. */ + val isActive: Boolean = false, ) +/** + * What the phone knows about a saved server beyond its URL: the deployment + * identity verified at that URL and the other addresses it offers + * (`GET /api/v2/system/connections`). Sent in `pushServer` so the TV can + * verify the server and reach it at another address (silo-apple parity). + */ +interface CompanionServerIdentitySource { + /** Verified identity of [server], or null when it can't be verified. */ + suspend fun identity(server: CompanionPairingServer): String? + + /** Addresses [server] offers other devices, or null when unavailable. */ + suspend fun endpoints(server: CompanionPairingServer): List? + + /** The servers among [servers] whose verified identity is [serverId], in the order given. */ + suspend fun serversWithIdentity( + servers: List, + serverId: String, + ): List = servers.filter { identity(it) == serverId } +} + +/** Production identity source over the saved-server registry. */ +class RegistryCompanionServerIdentitySource( + private val registry: ServerRegistry, + private val identities: ServerIdentityRepository, + private val identityApi: ServerIdentityApi, + private val identityTransitions: IdentityTransitionBarrier, +) : CompanionServerIdentitySource { + override suspend fun identity(server: CompanionPairingServer): String? { + val entry = registry.entries.value.firstOrNull { it.id == server.id } ?: return null + return identities.identityOf(entry) + } + + /** Recorded identities match without the network; the rest are probed in parallel. */ + override suspend fun serversWithIdentity( + servers: List, + serverId: String, + ): List { + val candidates = registry.entries.value.filter { entry -> servers.any { it.id == entry.id } } + val matched = identities.entriesFor(serverId, candidates).map { it.id }.toSet() + return servers.filter { it.id in matched } + } + + override suspend fun endpoints(server: CompanionPairingServer): List? = + identityApi.connections( + AuthScopeSnapshot.profileless(server.id, server.url, identityTransitions.generation.value), + )?.endpoints?.takeIf { it.isNotEmpty() } +} + data class CompanionPairingServerSnapshot( val activeServerId: String?, val servers: List, ) +/** + * The first server's confirmation. People compare [userCode], the code the TV + * shows on screen (from the server's lookup, not the TV's frame). The match + * words are checked programmatically; [serverMatchCode] is shown only on a + * secondary line for TV apps released before user codes, which show the + * words alone. Drop that line on the same schedule as the web `/activate` + * card's "Older TV apps show ..." line. + */ data class CompanionPairingApproval( val targetName: String, val serverName: String, val userCode: String, - val tvMatchCode: String, val serverMatchCode: String, + /** The server's host, shown next to its name ("Home (silo.example.com)"). */ + val serverHost: String = "", + /** The account approving signs the TV in as; null when it couldn't be read. */ + val accountName: String? = null, ) sealed class CompanionPairingStatus { @@ -83,6 +167,14 @@ interface CompanionDeviceLoginApprover { suspend fun lookup(server: CompanionPairingServer, code: String): ApiResult suspend fun approve(server: CompanionPairingServer, code: String): ApiResult suspend fun deny(server: CompanionPairingServer, code: String): ApiResult + + /** + * The account signed in on [server], which approving signs the TV in as + * (the approval card names it, like the web `/activate` card and the + * iPhone app). Null when it can't be read; the card then says "with this + * account". + */ + suspend fun accountName(server: CompanionPairingServer): String? = null } fun interface CompanionPairingTransportFactory { @@ -94,37 +186,30 @@ class RegistryCompanionPairingServerStore( private val tokenManager: TokenManager, ) : CompanionPairingServerStore { override suspend fun snapshot(): CompanionPairingServerSnapshot { - val signedInServers = buildList { - for (entry in registry.entries.value) { - if (entry.url.isBlank()) continue - if (tokenManager.getAccessTokenForScope(entry.id).isNullOrBlank()) continue - add(entry) - } - } + val activeId = registry.activeServerId.value return CompanionPairingServerSnapshot( - activeServerId = registry.activeServerId.value, - servers = signedInServers - .sortedWith( - compareByDescending { - it.id == registry.activeServerId.value - }.thenByDescending { it.lastUsedAtEpochMs }, + activeServerId = activeId, + servers = registry.signedInEntries(tokenManager).map { + CompanionPairingServer( + id = it.id, + url = it.url, + displayName = it.displayName, + isActive = it.id == activeId, ) - .map { - CompanionPairingServer( - id = it.id, - url = it.url, - displayName = it.displayName, - ) - }, + }, ) } - } class RepositoryCompanionDeviceLoginApprover( private val repository: DeviceLoginRepository, private val identityTransitions: IdentityTransitionBarrier, + /** The account on a scope's server, read with that server's own credentials. */ + private val accountNameOf: suspend (AuthScopeSnapshot) -> String? = { null }, ) : CompanionDeviceLoginApprover { + override suspend fun accountName(server: CompanionPairingServer): String? = + runCatching { accountNameOf(server.profilelessScope()) }.getOrNull()?.takeIf { it.isNotBlank() } + override suspend fun lookup( server: CompanionPairingServer, code: String, @@ -140,24 +225,15 @@ class RepositoryCompanionDeviceLoginApprover( code: String, ): ApiResult = repository.deny(server.profilelessScope(), code) - private fun CompanionPairingServer.profilelessScope() = AuthScopeSnapshot( - serverId = id, - serverUrl = url, - profileId = null, - profileToken = null, - // This inactive-server scope cannot carry the token manager's live - // persistent credential epoch. Pin its request to the current identity - // generation instead so a late refresh cannot overwrite a same-server - // account replacement. - identityGeneration = identityTransitions.generation.value, - isIdentityGenerationStamped = true, - ) + private fun CompanionPairingServer.profilelessScope() = + AuthScopeSnapshot.profileless(id, url, identityTransitions.generation.value) } class CompanionPairingCoordinator( private val serverStore: CompanionPairingServerStore, private val deviceLoginApprover: CompanionDeviceLoginApprover, private val transportFactory: CompanionPairingTransportFactory, + private val identitySource: CompanionServerIdentitySource? = null, ) { private val _status = MutableStateFlow(CompanionPairingStatus.Idle) val status: StateFlow = _status.asStateFlow() @@ -166,13 +242,38 @@ class CompanionPairingCoordinator( _status.value = CompanionPairingStatus.Idle } + /** + * The saved, signed-in server a signed-out TV ([CompanionPairingTarget.isSignedOutTv]) + * wants, matched by verified identity against its `srv`; null when this + * phone doesn't hold it and must not offer the TV. + */ + suspend fun serverForSignedOutTv(target: CompanionPairingTarget): CompanionPairingServer? { + val wanted = target.serverIdentity?.takeIf { target.isSignedOutTv && it.isNotBlank() } ?: return null + val source = identitySource ?: return null + return source.serversWithIdentity(serverStore.snapshot().orderedServers(), wanted).firstOrNull() + } + + /** + * [signInServer]: for a signed-out TV, the server [serverForSignedOutTv] + * already matched when the offer was made; resolved again only when absent. + */ suspend fun pair( target: CompanionPairingTarget, + signInServer: CompanionPairingServer? = null, chooseServers: suspend (List) -> List = { it }, confirmFirstMatch: suspend (CompanionPairingApproval) -> Boolean = { true }, ): CompanionPairingResult { val snapshot = serverStore.snapshot() - val orderedServers = snapshot.orderedServers() + // A signed-out TV wants exactly one server, the one it advertised: + // skip the chooser and push only that one. It must still be a server + // this phone is signed in to. + val signedOutServer = if (target.isSignedOutTv) { + (signInServer?.takeIf { matched -> snapshot.servers.any { it.id == matched.id } } ?: serverForSignedOutTv(target)) + ?: return fail(target, "This phone isn't signed in to the server ${target.name} uses.") + } else { + null + } + val orderedServers = signedOutServer?.let(::listOf) ?: snapshot.orderedServers() if (orderedServers.isEmpty()) { return fail(target, "No saved Silo servers are available to send.") } @@ -191,12 +292,19 @@ class CompanionPairingCoordinator( coroutineScope { val inbox = Channel(Channel.UNLIMITED) val collector = launch { - connected.incoming.collect { inbox.send(it) } + try { + connected.incoming.collect { inbox.send(it) } + } finally { + // The TV hung up (or the read failed): wake any wait + // for its next message instead of sitting on it until + // the step's timeout. + inbox.close() + } } try { awaitCompatibleHello(inbox) _status.value = CompanionPairingStatus.PickServers(target.name) - val selectedServers = chooseServers(orderedServers) + val selectedServers = (if (signedOutServer != null) orderedServers else chooseServers(orderedServers)) .filter { selected -> orderedServers.any { it.id == selected.id } } if (selectedServers.isEmpty()) { error("Choose at least one server to continue.") @@ -239,11 +347,24 @@ class CompanionPairingCoordinator( serverNames = signedInNames.toList(), ) return CompanionPairingResult.Completed(signedInCount) + } catch (e: TimeoutCancellationException) { + // A step timeout is a failure, not a cancellation of the caller: + // rethrowing it left the card spinning on its progress step. + Log.w(TAG, "Companion setup timed out for ${target.name}", e) + return fail(target, "${target.name} stopped responding. Try again.") } catch (e: CancellationException) { throw e } catch (t: Throwable) { Log.e(TAG, "Companion setup failed for ${target.name}", t) - val message = t.userFacingPairingMessage() + val message = when (t) { + is PeerCancelledException -> if (t.reason == CONSENT_DENIED_REASON) { + "${target.name} didn't allow it." + } else { + "${target.name} cancelled." + } + is ClosedReceiveChannelException -> "${target.name} closed the connection. Try again." + else -> t.userFacingPairingMessage() + } _status.value = CompanionPairingStatus.Failed(target.name, message) return CompanionPairingResult.Failed(message) } finally { @@ -255,6 +376,16 @@ class CompanionPairingCoordinator( } } + /** What the phone says when the TV reports [this] failure for [serverName]. */ + private fun PairingFailureCode.phoneMessage(tvName: String, serverName: String): String = when (this) { + PairingFailureCode.Denied -> "The sign-in was declined." + PairingFailureCode.Expired -> "The code on $tvName expired. Try again." + PairingFailureCode.Unreachable -> "$tvName can't reach $serverName. Check the TV's network connection." + PairingFailureCode.IdentityMismatch -> "$tvName reached a different server at that address. Nothing was signed in." + PairingFailureCode.UpdateRequired -> "$serverName or the Silo app on $tvName needs an update first." + PairingFailureCode.AuthFailed -> "$tvName couldn't finish signing in to $serverName." + } + private fun Throwable.userFacingPairingMessage(): String { val detail = generateSequence(this) { it.cause } .mapNotNull { it.message?.takeIf(String::isNotBlank) } @@ -271,51 +402,78 @@ class CompanionPairingCoordinator( confirmFirstMatch: suspend (CompanionPairingApproval) -> Boolean, ) { _status.value = CompanionPairingStatus.PushingServer(target.name, server.displayName) - transport.send(PairingMessage.PushServer(serverURL = server.url, serverName = server.displayName)) + transport.send( + PairingMessage.PushServer( + serverURL = server.url, + serverName = server.displayName, + serverIdentity = identitySource?.identity(server), + endpoints = identitySource?.endpoints(server), + ), + ) - val started = inbox.receiveFirst( - timeoutMs = deviceStartedTimeoutMs(requireUserConfirmation), - ) { it.serverURL == server.url } + // A TV can refuse the push before it starts a sign-in (a different + // server at that address, an expired code): it answers with the + // failed result instead, and the phone reports it at once. + val started = when ( + val first = inbox.receiveFirst(timeoutMs = deviceStartedTimeoutMs(requireUserConfirmation)) { + (it is PairingMessage.DeviceStarted && it.serverURL == server.url) || + (it is PairingMessage.ServerResult && it.serverURL == server.url) + } + ) { + is PairingMessage.DeviceStarted -> first + is PairingMessage.ServerResult -> + error(PairingFailureCode.fromWire(first.error).phoneMessage(target.name, server.displayName)) + else -> error("Unexpected TV setup message.") + } val lookup = when (val result = deviceLoginApprover.lookup(server, started.userCode)) { is ApiResult.Success -> result.data - is ApiResult.Error -> error(result.message.ifBlank { "Unable to verify TV setup code." }) - is ApiResult.NetworkError -> error(result.exception.message ?: "Unable to verify TV setup code.") + is ApiResult.Error -> error(approverFailure(result, "Unable to verify TV setup code.")) + is ApiResult.NetworkError -> error(approverFailure(result, "Unable to verify TV setup code.")) } val serverMatchCode = lookup.matchCode?.takeIf { it.isNotBlank() } ?: error("Server did not return a match code for TV setup.") if (serverMatchCode != started.matchCode) { deviceLoginApprover.deny(server, started.userCode) transport.send(PairingMessage.Cancel(reason = "match-code-mismatch")) - error("TV setup code mismatch. Nothing was approved.") + error("The codes didn't match. Nothing was approved.") } val approval = CompanionPairingApproval( targetName = target.name, serverName = server.displayName, - userCode = started.userCode, - tvMatchCode = started.matchCode, + userCode = lookup.userCode?.takeIf { it.isNotBlank() } ?: started.userCode, serverMatchCode = serverMatchCode, + serverHost = DeviceCodeFormat.host(server.url), ) if (requireUserConfirmation) { - _status.value = CompanionPairingStatus.AwaitingMatchConfirmation(approval) - if (!confirmFirstMatch(approval)) { + // The card names the account approving signs the TV in as, and + // nothing is approved for an account it couldn't name. The TV + // keeps its code, so "Try again" can pick it up. + val accountName = deviceLoginApprover.accountName(server) + if (accountName.isNullOrBlank()) { + transport.send(PairingMessage.Cancel(reason = "account-unknown")) + error("Couldn't confirm which account would sign in on ${target.name}. Try again.") + } + val shown = approval.copy(accountName = accountName) + _status.value = CompanionPairingStatus.AwaitingMatchConfirmation(shown) + if (!confirmFirstMatch(shown)) { deviceLoginApprover.deny(server, started.userCode) transport.send(PairingMessage.Cancel(reason = "user-cancelled")) - error("TV setup was cancelled.") + error("Cancelled. Nothing was approved.") } } _status.value = CompanionPairingStatus.Approving(target.name, server.displayName) when (val approved = deviceLoginApprover.approve(server, started.userCode)) { is ApiResult.Success -> Unit - is ApiResult.Error -> error(approved.message.ifBlank { "Unable to approve TV setup." }) - is ApiResult.NetworkError -> error(approved.exception.message ?: "Unable to approve TV setup.") + is ApiResult.Error -> error(approverFailure(approved, "Unable to approve TV setup.")) + is ApiResult.NetworkError -> error(approverFailure(approved, "Unable to approve TV setup.")) } val result = inbox.receiveFirst( timeoutMs = SERVER_RESULT_TIMEOUT_MS, ) { it.serverURL == server.url } if (result.status != PairingServerStatus.SignedIn) { - error(result.error ?: "The TV could not finish signing in.") + error(PairingFailureCode.fromWire(result.error).phoneMessage(target.name, server.displayName)) } } @@ -333,6 +491,11 @@ class CompanionPairingCoordinator( var found: T? = null while (found == null) { val message = receive() + // The TV withdrew (its user declined, or it left the screen): + // nothing it would have sent next is coming. + if (message is PairingMessage.Cancel && T::class != PairingMessage.Cancel::class) { + throw PeerCancelledException(message.reason) + } if (message is T && predicate(message)) { found = message } @@ -355,11 +518,39 @@ class CompanionPairingCoordinator( private fun deviceStartedTimeoutMs(isFirstServer: Boolean): Long = if (isFirstServer) FIRST_DEVICE_STARTED_TIMEOUT_MS else NEXT_DEVICE_STARTED_TIMEOUT_MS + /** The TV sent `cancel` while the phone waited for its next message. */ + private class PeerCancelledException(val reason: String) : IllegalStateException("peer cancelled: $reason") + private companion object { const val TAG = "CompanionPairing" + /** [PairingReceiver.denyPendingServer]'s reason when the TV user declines the push. */ + const val CONSENT_DENIED_REASON = "consent_denied" const val HELLO_TIMEOUT_MS = 15_000L const val FIRST_DEVICE_STARTED_TIMEOUT_MS = 90_000L const val NEXT_DEVICE_STARTED_TIMEOUT_MS = 30_000L const val SERVER_RESULT_TIMEOUT_MS = 30_000L } } + +/** + * The phone's text for a lookup or approval that failed. The sign-in + * provider being unreachable (503 `provider_unavailable` on the approver's + * refresh or answer) keeps this phone's session, so it says so instead of a + * raw reason. + */ +internal fun approverFailure(result: ApiResult.Error, fallback: String): String = + if (result.code == 503 && result.error == SiloAuthUnavailableException.PROVIDER_UNAVAILABLE_PROBLEM) { + APPROVER_PROVIDER_UNAVAILABLE + } else { + result.message.ifBlank { fallback } + } + +internal fun approverFailure(result: ApiResult.NetworkError, fallback: String): String = when { + SiloAuthUnavailableException.isProviderUnavailable(result.exception) -> APPROVER_PROVIDER_UNAVAILABLE + result.exception is SiloAuthUnavailableException -> fallback + else -> result.exception.message ?: fallback +} + +internal const val APPROVER_PROVIDER_UNAVAILABLE = + "The sign-in provider can't be reached right now, so this device couldn't confirm your account. " + + "You're still signed in. Try again in a moment." diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingNsdBrowser.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingNsdBrowser.kt index 946a5eb863..133174bfe4 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingNsdBrowser.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/CompanionPairingNsdBrowser.kt @@ -106,6 +106,7 @@ class CompanionPairingNsdBrowser(context: Context) { state = attributes.string("st"), version = version, sessionId = attributes.string("sid"), + serverIdentity = attributes.string("srv"), ) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt index cd25cd4ed1..87cabb0791 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingAuthPort.kt @@ -32,6 +32,8 @@ interface PairingAuthPort { refreshToken: String, expiresIn: Long, expectedIdentity: AccountSessionExpectation? = null, + /** Deployment identity verified at [serverUrl], recorded on the saved server. */ + verifiedServerId: String? = null, ) } @@ -52,6 +54,7 @@ class RegistryPairingAuthPort( refreshToken: String, expiresIn: Long, expectedIdentity: AccountSessionExpectation?, + verifiedServerId: String?, ): Unit = withContext(NonCancellable) { commitMutex.withLock { if (cleartextOriginConsent?.requiresApproval(serverUrl) == true) { @@ -101,6 +104,7 @@ class RegistryPairingAuthPort( // stale UPDATE_REQUIRED — so, like switchToServer, a replacement // probe is handed to the repository's background scope. authRepository?.refreshServerContractWithFallback(serverId) + if (verifiedServerId != null) serverRegistry.setVerifiedServerId(serverId, verifiedServerId) if (expectedIdentity != null && tokenManager.captureAccountSessionExpectation()?.generation != expectedIdentity.generation + 1) throw AccountSessionChangedException() } } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingReceiver.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingReceiver.kt index 3a2f3f0018..d069b6f284 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingReceiver.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/PairingReceiver.kt @@ -1,12 +1,19 @@ package org.siloserver.silo.common.pairing import android.util.Log +import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.network.api.ServerIdentityProbe +import org.siloserver.silo.pairing.PairingEndpoint +import org.siloserver.silo.pairing.PairingFailureCode import org.siloserver.silo.pairing.PairingMessage import org.siloserver.silo.pairing.PairingReceiverState import org.siloserver.silo.pairing.PairingProtocol import org.siloserver.silo.pairing.PairingServerStatus +import org.siloserver.silo.pairing.normalizeEndpointUrl import org.siloserver.silo.repository.DeviceLoginRepository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -32,6 +39,51 @@ data class PairingDeviceIdentity( val platform: String = "android-tv", ) +/** How the sign-in screen's own request ended, relayed to the phone. */ +sealed interface NearbySignInOutcome { + data object SignedIn : NearbySignInOutcome + data class Failed(val code: PairingFailureCode) : NearbySignInOutcome +} + +/** + * The sign-in screen's own device request, lent to the LAN receiver in + * `login` mode. The phone approves the code the TV already shows, so there is + * only ever one code on screen; the sign-in screen polls, saves the session + * and routes on, and the receiver only relays the outcome. Mirrors the + * silo-apple `NearbySignInCodeSource`. + */ +interface NearbySignInCodeSource { + /** The code on screen, waiting briefly while one is being fetched; null when none is available. */ + suspend fun codeForNearbyApproval(): DeviceLoginStartResponse? + + /** Waits until the request behind [deviceCode] signs in, fails, or is replaced by another code. */ + suspend fun nearbyApprovalOutcome(deviceCode: String): NearbySignInOutcome +} + +/** + * What the TV advertises while a screen accepts phones. + * + * - [Setup]: a first-run TV (or Add Server). Any signed-in phone may offer to + * set it up and choose which of its servers to push. + * - [login]: a TV on its sign-in screen for one server. TXT carries + * `st=login` and `srv=`; phones offer it only when they hold + * that server (matched by verified identity), and push only that one. The + * phone approves the code [source] already shows at [serverUrl]. + */ +data class PairingAdvertisement( + val state: PairingReceiverState, + val serverIdentity: String? = null, + val serverUrl: String? = null, + val source: NearbySignInCodeSource? = null, +) { + companion object { + val Setup = PairingAdvertisement(PairingReceiverState.Setup) + + fun login(serverIdentity: String, serverUrl: String, source: NearbySignInCodeSource) = + PairingAdvertisement(PairingReceiverState.Login, serverIdentity, serverUrl, source) + } +} + /** * UI-facing status of the TV pairing receiver. Mirrors the tvOS * `ReceiverPairingCoordinator.State`. @@ -48,21 +100,44 @@ sealed class PairingReceiverStatus { /** * A phone pushed a server and the TV user must allow it before the - * device-login (and its pairing code) starts — tvOS-parity consent step. + * device-login (and its sign-in code) starts — tvOS-parity consent step. */ data class ConsentRequested( val serverURL: String, val serverName: String, ) : PairingReceiverStatus() - /** Device-login started for a pushed server (interim). */ + /** Checking the server's address and starting device sign-in there (interim). */ data class Pairing(val serverURL: String, val serverName: String) : PairingReceiverStatus() - /** Showing the match code for a pushed server while the phone approves. */ + /** + * The pushed address didn't answer from this TV. [providerName] names the + * network provider behind it when the phone listed one; [alternateUrl] is + * a verified address of the same server the TV user may choose instead. + * Nothing switches without that choice. + */ + data class Unreachable( + val serverURL: String, + val serverName: String, + val providerName: String?, + val alternateUrl: String?, + ) : PairingReceiverStatus() + + /** + * Showing the TV's sign-in code for a pushed server while the phone + * approves. People compare [userCode] (the code the TV shows everywhere). + * [automatic] = a later server in a multi-server push, verified by the + * phone without asking. [matchCode] is the legacy match words: phones + * released before user codes show them instead, so a person-checked + * attempt names them in a fallback line (remove with the phones' + * "Older TV apps show ..." line). + */ data class AwaitingApproval( val serverURL: String, val serverName: String, - val matchCode: String, + val userCode: String, + val automatic: Boolean = false, + val matchCode: String? = null, ) : PairingReceiverStatus() /** A single server finished signing in; the phone may push more. */ @@ -71,8 +146,8 @@ sealed class PairingReceiverStatus { /** The phone sent Done after one or more servers signed in. */ data class Completed(val serverNames: List) : PairingReceiverStatus() - /** Terminal failure. */ - data class Failed(val serverName: String, val message: String) : PairingReceiverStatus() + /** Terminal failure for the last attempted server. */ + data class Failed(val serverName: String, val code: PairingFailureCode) : PairingReceiverStatus() } /** @@ -80,11 +155,16 @@ sealed class PairingReceiverStatus { * driven and transport-agnostic: it consumes one [PairingTransport] (real * TLS-PSK socket in production, in-memory fake in tests) and drives the flow: * - * 1. send [PairingMessage.Hello] (state = current setup/login). - * 2. on [PairingMessage.PushServer]: set the server via [AuthRepository], then - * run [DeviceLoginRepository.begin]; while it runs, observe its `state` — - * on Awaiting send [PairingMessage.DeviceStarted]; on Approved send - * [PairingMessage.ServerResult] signedIn; on Failed send `failed`. + * 1. send [PairingMessage.Hello] with the advertised state. + * 2. on [PairingMessage.PushServer]: ask the TV user once per session. In + * `setup` mode, pick the address to sign in at (identity-checked), run + * device login there and save the session. In `login` mode, check the + * pushed identity against the advertised one and lend the sign-in + * screen's own code ([NearbySignInCodeSource]). Either way send + * [PairingMessage.DeviceStarted], then [PairingMessage.ServerResult] + * with a [PairingFailureCode] on failure. A newer push replaces one in + * flight (the protocol is one server at a time; a new push means the + * phone gave up on the previous one). * 3. on [PairingMessage.Done] / [PairingMessage.Cancel] / EOF / error: finish. * * One [run] call drives exactly one connection. The real advertiser serializes @@ -96,7 +176,8 @@ class PairingReceiver( private val authPort: PairingAuthPort, private val deviceLogin: DeviceLoginPort, private val identityProvider: () -> PairingDeviceIdentity, - private val receiverStateProvider: () -> PairingReceiverState, + /** Classifies one address; production probes `GET /api/v2/system/identity`. */ + private val identityProbe: suspend (serverUrl: String) -> ServerIdentityProbe = { ServerIdentityProbe.Unreachable }, ) { private companion object { private const val TAG = "PairingReceiver" @@ -105,8 +186,9 @@ class PairingReceiver( private val _status = MutableStateFlow(PairingReceiverStatus.Idle) val status: StateFlow = _status.asStateFlow() - /** True while a server's device-login is in flight; the protocol is one at a time. */ - private var pollingServerUrl: String? = null + /** What the TV currently advertises; set by [TvPairingAdvertiser.start]. */ + @Volatile + var advertisement: PairingAdvertisement = PairingAdvertisement.Setup /** Push held until the TV user allows it (tvOS consent parity). */ private var pendingPush: PairingMessage.PushServer? = null @@ -121,27 +203,43 @@ class PairingReceiver( /** Session scope captured so [allowPendingServer] can launch the login. */ private var sessionScope: CoroutineScope? = null - /** The in-flight device-login child job, so EOF/Cancel teardown can cancel it. */ + /** The in-flight attempt, so a newer push, EOF or Cancel can cancel it. */ private var pushJob: Job? = null + /** The TV user's pending choice while [status] is [PairingReceiverStatus.Unreachable]. */ + private var alternateDecision: CompletableDeferred? = null + /** Active transport, so the UI can cancel the in-place receiver flow. */ private var activeTransport: PairingTransport? = null - private var signedInCount = 0 private val signedInNames = mutableListOf() + private enum class AlternateChoice { UseAlternate, Retry } + + /** An attempt failure with its wire code. */ + private class AttemptFailure(val code: PairingFailureCode) : Exception(code.wire) + + private data class LoginTarget(val url: String, val verifiedServerId: String?) + + /** Whether the advertiser is listening, so closing a session returns to Advertising rather than Idle. */ + @Volatile + private var listening = false + fun setAdvertising() { + listening = true _status.value = PairingReceiverStatus.Advertising } fun setIdle() { + listening = false _status.value = PairingReceiverStatus.Idle } + /** Cancel/Back/OK on the pairing panel: end the session, or close a failure left on screen. */ fun cancelActiveSession() { pushJob?.cancel() runCatching { activeTransport?.close() } - _status.value = PairingReceiverStatus.Idle + _status.value = if (listening) PairingReceiverStatus.Advertising else PairingReceiverStatus.Idle } /** @@ -156,7 +254,7 @@ class PairingReceiver( if (_status.value !is PairingReceiverStatus.ConsentRequested) return consented = true pendingPush = null - handlePushServer(push, transport, scope) + beginAttempt(push, transport, scope) } /** @@ -177,6 +275,19 @@ class PairingReceiver( _status.value = PairingReceiverStatus.Idle } + /** TV user chose the verified alternate address shown on the unreachable screen. */ + fun useAlternateAddress() { + val status = _status.value as? PairingReceiverStatus.Unreachable ?: return + if (status.alternateUrl == null) return + alternateDecision?.complete(AlternateChoice.UseAlternate) + } + + /** TV user fixed the connection (or set up the provider) and wants the pushed address tried again. */ + fun retryPushedAddress() { + if (_status.value !is PairingReceiverStatus.Unreachable) return + alternateDecision?.complete(AlternateChoice.Retry) + } + /** * Drive one connection to completion. Returns when the peer finishes * (Done), cancels, the stream ends, or an error is thrown. Always closes the @@ -184,13 +295,11 @@ class PairingReceiver( * the in-flight device-login and the transport is closed. */ suspend fun run(transport: PairingTransport) { - pollingServerUrl = null pushJob = null pendingPush = null consented = false sessionDenied = false activeTransport = transport - signedInCount = 0 signedInNames.clear() var preserveTerminalStatus = false try { @@ -200,7 +309,7 @@ class PairingReceiver( PairingMessage.Hello( tvName = identity.name, tvDeviceId = identity.deviceId, - state = receiverStateProvider(), + state = advertisement.state, supportedVersions = listOf(PairingProtocol.VERSION), ), ) @@ -220,19 +329,30 @@ class PairingReceiver( handlePushServer(message, transport, scope) is PairingMessage.Done -> { Log.i(TAG, "received pairing done") + // An in-flight server has no committed result; abandon it. pushJob?.cancelAndJoin() - if (signedInCount > 0) { - _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) - preserveTerminalStatus = true - } else { - _status.value = PairingReceiverStatus.Idle + when { + signedInNames.isNotEmpty() -> { + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } + // A lone failure keeps its explanation on screen. + _status.value is PairingReceiverStatus.Failed -> preserveTerminalStatus = true + else -> _status.value = PairingReceiverStatus.Idle } return@collectMessage false } is PairingMessage.Cancel -> { Log.i(TAG, "received pairing cancel") pushJob?.cancelAndJoin() - _status.value = PairingReceiverStatus.Idle + if (signedInNames.isNotEmpty()) { + // A peer timeout can race the persistence boundary. + // Never discard a sign-in that already committed. + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } else { + _status.value = PairingReceiverStatus.Idle + } return@collectMessage false } else -> { @@ -243,168 +363,346 @@ class PairingReceiver( } // Stream ended (Done/Cancel/EOF) — cancel any in-flight device-login. pushJob?.cancel() + if (!preserveTerminalStatus) { + if (signedInNames.isNotEmpty()) { + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } else if (_status.value is PairingReceiverStatus.Failed) { + preserveTerminalStatus = true + } + } } } catch (e: CancellationException) { throw e } catch (_: Throwable) { // Transport / decode error: connection dropped mid-session. + if (signedInNames.isNotEmpty()) { + _status.value = PairingReceiverStatus.Completed(signedInNames.toList()) + preserveTerminalStatus = true + } } finally { - pushJob?.cancel() - pushJob = null - pendingPush = null - sessionScope = null + // A newer connection may own the receiver already (the advertiser + // restarted while this one unwound): its session, push and status + // are not this run's to clear. if (activeTransport === transport) { + pushJob?.cancel() + pushJob = null + pendingPush = null + sessionScope = null + alternateDecision?.cancel() + alternateDecision = null activeTransport = null - } - if (!preserveTerminalStatus) { - _status.value = PairingReceiverStatus.Idle + if (!preserveTerminalStatus) { + _status.value = PairingReceiverStatus.Idle + } } transport.close() } } - /** - * Start device-login against the pushed (not-yet-trusted) server and drive - * its outcome back to the phone. One at a time — an overlapping PushServer - * while a poll is in flight is ignored. - */ - private fun handlePushServer( + private suspend fun handlePushServer( message: PairingMessage.PushServer, transport: PairingTransport, sessionScope: CoroutineScope, ) { - if (pollingServerUrl != null) return // one at a time; ignore overlap. if (sessionDenied) return // denied: session is tearing down. - val serverURL = message.serverURL - val serverName = message.serverName?.takeIf { it.isNotBlank() } ?: serverURL Log.i(TAG, "received pushed server") + // The protocol is one server at a time: a new push while one is in + // flight means the phone gave up on the previous server — supersede + // it, don't ignore the push (silo-apple parity). + pushJob?.cancelAndJoin() + pushJob = null // tvOS parity: the first push of a session needs the TV user's consent - // before device-login starts (and before any pairing code shows). A + // before device-login starts (and before any sign-in code shows). A // newer push while consent is pending supersedes the earlier one. if (!consented) { pendingPush = message - _status.value = PairingReceiverStatus.ConsentRequested(serverURL, serverName) + _status.value = PairingReceiverStatus.ConsentRequested(message.serverURL, message.displayName()) return } - pollingServerUrl = serverURL - // Run the device-login begin/await CONCURRENTLY with continued inbound - // reading so Cancel/EOF can cancel this job and tear the session down. + beginAttempt(message, transport, sessionScope) + } + + private fun beginAttempt( + push: PairingMessage.PushServer, + transport: PairingTransport, + sessionScope: CoroutineScope, + ) { + // "Automatic" only once a sign-in has been COMMITTED: the phone + // auto-approves only after its user confirmed the first code. + val automatic = signedInNames.isNotEmpty() + val ad = advertisement pushJob = sessionScope.launch { - try { - runDeviceLogin( - serverURL = serverURL, - serverName = serverName, - fetchedName = message.serverName, - transport = transport, - ) - } finally { - deviceLogin.reset() - pollingServerUrl = null + if (ad.state == PairingReceiverState.Login) { + runLoginAttempt(push, transport, ad) + } else { + try { + runAttempt(push, transport, automatic) + } finally { + deviceLogin.reset() + } } } } - private suspend fun runDeviceLogin( - serverURL: String, - serverName: String, - fetchedName: String?, + /** + * `login` mode: only the advertised server is accepted, by the identity + * the phone pushed (a push without one can't be checked, so it's + * refused), and the phone approves the code the sign-in screen already + * shows. That screen polls, saves the session and routes on; this only + * relays the outcome. No second request is started, so there is nothing + * of this attempt's own to withdraw. + */ + private suspend fun runLoginAttempt( + push: PairingMessage.PushServer, transport: PairingTransport, + ad: PairingAdvertisement, ) { - _status.value = PairingReceiverStatus.Pairing(serverURL, serverName) - - val expectedIdentity = authPort.captureExpectedIdentity() ?: error("Unable to capture the current sign-in context") - val identity = identityProvider() - var deviceStartedSent = false - - coroutineScope { - // Observe the device-login state machine while begin() runs the - // poll loop. Cancel the collector once we reach a terminal state. - val observer = launch { - deviceLogin.state.collectMessage { state -> - when (state) { - is DeviceLoginRepository.DeviceLoginState.Awaiting -> { - if (!deviceStartedSent) { - deviceStartedSent = true - val session = state.session - _status.value = PairingReceiverStatus.AwaitingApproval( - serverURL = serverURL, - serverName = serverName, - matchCode = session.matchCode, - ) - transport.send( - PairingMessage.DeviceStarted( - serverURL = serverURL, - userCode = session.userCode, - matchCode = session.matchCode, - ), - ) - Log.i(TAG, "sent device-started") - } - true - } - is DeviceLoginRepository.DeviceLoginState.Approved -> { - // Persist the approved session's tokens BEFORE - // signaling SignedIn — same as the credential / QR - // login flow (TvLoginViewModel.handleDeviceLoginApproved). - // Without this the TV navigates to profile selection - // unauthenticated. The repository already guards - // against null tokens (Failed.MissingTokens). - val response = state.response - val accessToken = response.accessToken - val refreshToken = response.refreshToken - if (!accessToken.isNullOrBlank() && !refreshToken.isNullOrBlank()) { - authPort.persistApprovedSession( - serverUrl = serverURL, - serverName = fetchedName, - accessToken = accessToken, - refreshToken = refreshToken, - expiresIn = response.expiresIn ?: 0L, - expectedIdentity = expectedIdentity, - ) - } - signedInCount += 1 - signedInNames += serverName - _status.value = PairingReceiverStatus.SignedIn(signedInCount) - transport.send( - PairingMessage.ServerResult( - serverURL = serverURL, - status = PairingServerStatus.SignedIn, - error = null, - ), - ) - Log.i(TAG, "sent signed-in result") - false // terminal — stop observing. - } - is DeviceLoginRepository.DeviceLoginState.Failed -> { - _status.value = PairingReceiverStatus.Failed( - serverName = serverName, - message = state.message ?: state.reason.name, - ) - transport.send( - PairingMessage.ServerResult( - serverURL = serverURL, - status = PairingServerStatus.Failed, - error = state.message ?: state.reason.name, - ), - ) - Log.i(TAG, "sent failed result") - false // terminal — stop observing. - } - else -> true // Idle / Initiating — keep observing. + val pushedUrl = push.serverURL + val displayName = push.displayName() + try { + val source = ad.source ?: throw AttemptFailure(PairingFailureCode.AuthFailed) + val pushedIdentity = push.serverIdentity?.trim()?.takeIf { it.isNotEmpty() } + if (ad.serverIdentity == null || pushedIdentity != ad.serverIdentity) { + throw AttemptFailure(PairingFailureCode.IdentityMismatch) + } + _status.value = PairingReceiverStatus.Pairing(pushedUrl, displayName) + val code = source.codeForNearbyApproval() ?: throw AttemptFailure(PairingFailureCode.Expired) + _status.value = PairingReceiverStatus.AwaitingApproval( + serverURL = pushedUrl, + serverName = displayName, + userCode = code.userCode, + matchCode = code.matchCode, + ) + transport.send( + PairingMessage.DeviceStarted(serverURL = pushedUrl, userCode = code.userCode, matchCode = code.matchCode), + ) + Log.i(TAG, "sent device-started for the sign-in screen's code") + when (val outcome = source.nearbyApprovalOutcome(code.deviceCode)) { + NearbySignInOutcome.SignedIn -> reportSignedIn(pushedUrl, displayName, transport) + is NearbySignInOutcome.Failed -> throw AttemptFailure(outcome.code) + } + } catch (failure: AttemptFailure) { + reportFailure(pushedUrl, displayName, failure.code, transport) + } + } + + private suspend fun reportSignedIn(pushedUrl: String, displayName: String, transport: PairingTransport) { + // An attempt that outlived its connection (the session save can't be + // cancelled) must not report into the connection that replaced it. + if (activeTransport !== transport) return + signedInNames += displayName + _status.value = PairingReceiverStatus.SignedIn(signedInNames.size) + // Best-effort: the tokens are committed, so a lost confirmation + // frame must not repaint a real sign-in as a failure. + runCatching { + transport.send( + PairingMessage.ServerResult( + serverURL = pushedUrl, + status = PairingServerStatus.SignedIn, + error = null, + ), + ) + } + Log.i(TAG, "sent signed-in result") + } + + private suspend fun reportFailure( + pushedUrl: String, + displayName: String, + code: PairingFailureCode, + transport: PairingTransport, + ) { + if (activeTransport !== transport) return + Log.i(TAG, "server pairing failed: ${code.wire}") + _status.value = PairingReceiverStatus.Failed(displayName, code) + runCatching { + transport.send( + PairingMessage.ServerResult( + serverURL = pushedUrl, + status = PairingServerStatus.Failed, + error = code.wire, + ), + ) + } + } + + private suspend fun runAttempt( + push: PairingMessage.PushServer, + transport: PairingTransport, + automatic: Boolean, + ) { + // Every frame back to the phone names the PUSHED address exactly as + // sent, whatever address this TV ends up using: phones key their + // per-server state on the URL they sent. + val pushedUrl = push.serverURL + val displayName = push.displayName() + try { + _status.value = PairingReceiverStatus.Pairing(pushedUrl, displayName) + val target = resolveLoginTarget(push, displayName) + val expectedIdentity = authPort.captureExpectedIdentity() + ?: throw AttemptFailure(PairingFailureCode.AuthFailed) + val device = identityProvider() + + val terminal = coroutineScope { + var deviceStartedSent = false + val observer = launch { + deviceLogin.state.collectMessage { state -> + if (state is DeviceLoginRepository.DeviceLoginState.Awaiting && !deviceStartedSent) { + deviceStartedSent = true + val session = state.session + _status.value = PairingReceiverStatus.AwaitingApproval( + serverURL = pushedUrl, + serverName = displayName, + userCode = session.userCode, + automatic = automatic, + matchCode = session.matchCode, + ) + transport.send( + PairingMessage.DeviceStarted( + serverURL = pushedUrl, + userCode = session.userCode, + matchCode = session.matchCode, + ), + ) + Log.i(TAG, "sent device-started") } + true } } - deviceLogin.begin( - serverUrl = serverURL, - deviceName = identity.name, - devicePlatform = identity.platform, + serverUrl = target.url, + deviceName = device.name, + devicePlatform = device.platform, ) - // begin() has reached a terminal state; let the observer drain it - // (it stops itself on the terminal value). - observer.join() + observer.cancel() + deviceLogin.state.value } + + val approved = when (terminal) { + is DeviceLoginRepository.DeviceLoginState.Approved -> terminal.response + is DeviceLoginRepository.DeviceLoginState.Failed -> throw AttemptFailure(terminal.reason.pairingFailure()) + else -> throw AttemptFailure(PairingFailureCode.AuthFailed) + } + persist(approved, target, push, expectedIdentity) + reportSignedIn(pushedUrl, displayName, transport) + } catch (failure: AttemptFailure) { + reportFailure(pushedUrl, displayName, failure.code, transport) + } + } + + /** + * Commit the approved tokens. A failed commit is a failed attempt, and the + * phone is told so: the TV must never sit on "almost there" while the phone + * waits for a result that isn't coming (silo-apple#554). + */ + private suspend fun persist( + response: DeviceLoginPollResponse, + target: LoginTarget, + push: PairingMessage.PushServer, + expectedIdentity: org.siloserver.silo.network.AccountSessionExpectation, + ) { + val accessToken = response.accessToken + val refreshToken = response.refreshToken + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + throw AttemptFailure(PairingFailureCode.AuthFailed) + } + try { + authPort.persistApprovedSession( + serverUrl = target.url, + serverName = push.serverName, + accessToken = accessToken, + refreshToken = refreshToken, + expiresIn = response.expiresIn ?: 0L, + expectedIdentity = expectedIdentity, + verifiedServerId = target.verifiedServerId, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.w(TAG, "pairing persist failed", e) + throw AttemptFailure(PairingFailureCode.AuthFailed) + } } + + /** + * The address to run device authorization at (`setup` mode). + * + * With an identity, the pushed address must answer with that + * identity from this TV. If it doesn't answer at all, the server's other + * addresses are checked for the same identity and the first match is + * OFFERED, never taken. An address answering with a different identity is + * never used. Legacy pushes (no identity) use the pushed address exactly. + */ + private suspend fun resolveLoginTarget(push: PairingMessage.PushServer, displayName: String): LoginTarget { + val pushedIdentity = push.serverIdentity?.trim()?.takeIf { it.isNotEmpty() } + if (pushedIdentity == null) return LoginTarget(push.serverURL, null) + + val pushedUrl = normalizeEndpointUrl(push.serverURL) + val endpoints = push.endpoints.orEmpty() + while (true) { + when (val probe = identityProbe(pushedUrl)) { + is ServerIdentityProbe.Identity -> + if (probe.serverId == pushedIdentity) { + return LoginTarget(push.serverURL, pushedIdentity) + } else { + // The phone verified this identity at this very address; + // a different answer from here is not the server it meant. + throw AttemptFailure(PairingFailureCode.IdentityMismatch) + } + ServerIdentityProbe.UnsupportedServer -> throw AttemptFailure(PairingFailureCode.IdentityMismatch) + ServerIdentityProbe.Unreachable -> Unit + } + + var alternate: PairingEndpoint? = null + for (endpoint in endpoints) { + if (endpoint.url == pushedUrl) continue + val answer = identityProbe(endpoint.url) + if (answer is ServerIdentityProbe.Identity && answer.serverId == pushedIdentity) { + alternate = endpoint + break + } + } + val provider = endpoints.firstOrNull { it.kind == PairingEndpoint.Kind.Provider && it.url == pushedUrl } + val decision = CompletableDeferred() + alternateDecision = decision + _status.value = PairingReceiverStatus.Unreachable( + serverURL = push.serverURL, + serverName = displayName, + providerName = provider?.displayName?.takeIf { it.isNotBlank() }, + alternateUrl = alternate?.url, + ) + val choice = try { + decision.await() + } finally { + alternateDecision = null + } + _status.value = PairingReceiverStatus.Pairing(push.serverURL, displayName) + if (choice == AlternateChoice.UseAlternate && alternate != null) { + return LoginTarget(alternate.url, pushedIdentity) + } + } + } + + private fun PairingMessage.PushServer.displayName(): String = + serverName?.takeIf { it.isNotBlank() } ?: normalizeEndpointUrl(serverURL) +} + +/** The wire code for a failed device-login attempt. */ +internal fun DeviceLoginRepository.FailureReason.pairingFailure(): PairingFailureCode = when (this) { + DeviceLoginRepository.FailureReason.Denied -> PairingFailureCode.Denied + DeviceLoginRepository.FailureReason.Expired, + DeviceLoginRepository.FailureReason.Consumed, + DeviceLoginRepository.FailureReason.Canceled, + -> PairingFailureCode.Expired + DeviceLoginRepository.FailureReason.Unreachable -> PairingFailureCode.Unreachable + DeviceLoginRepository.FailureReason.UpdateRequired -> PairingFailureCode.UpdateRequired + DeviceLoginRepository.FailureReason.StartFailed, + DeviceLoginRepository.FailureReason.RateLimited, + DeviceLoginRepository.FailureReason.Unsupported, + DeviceLoginRepository.FailureReason.MissingTokens, + DeviceLoginRepository.FailureReason.UnknownStatus, + -> PairingFailureCode.AuthFailed } /** diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/TvPairingAdvertiser.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/TvPairingAdvertiser.kt index 3e44f7e9b2..4d92c61191 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/TvPairingAdvertiser.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pairing/TvPairingAdvertiser.kt @@ -25,15 +25,17 @@ import java.util.concurrent.atomic.AtomicBoolean * wiring. * * TXT record carries: v (protocol version), name (device name), id (stable - * device id), sid (fresh nonce per [start]), st (receiver state). + * device id), sid (fresh nonce per [start]), st (receiver state) and, for a + * signed-out TV (`st=login`), srv (the deployment identity of the server it + * wants, from `GET /api/v2/system/identity`). Phones offer a `login` TV only + * when they hold a saved server with that verified identity. * - * UI (a later step) observes [PairingReceiver.status]. This class owns only the + * The UI observes [PairingReceiver.status]. This class owns only the * networking lifecycle. */ class TvPairingAdvertiser( private val context: Context, private val receiver: PairingReceiver, - private val receiverStateProvider: () -> PairingReceiverState, ) { private companion object { private const val TAG = "TvPairingAdvertiser" @@ -52,22 +54,53 @@ class TvPairingAdvertiser( /** True between [start] and [stop]; gates restoring Advertising after a connection. */ private val running = AtomicBoolean(false) - /** Start advertising and accepting pairing connections. Idempotent restart. */ + /** + * Which [start] call owns the advertiser. Two screens use it (server setup + * and sign-in) and navigation composes both during a transition, so the + * leaving screen's [stop] must not tear down what the arriving one started. + */ + private var owner = 0L + + /** + * Start advertising [advertisement] and accepting pairing connections. + * Idempotent restart: the server-setup screen advertises + * [PairingAdvertisement.Setup]; the sign-in screen advertises + * [PairingAdvertisement.login] once it knows its server's identity. + */ @Synchronized - fun start() { + fun start(advertisement: PairingAdvertisement = PairingAdvertisement.Setup): Long { stop() + val token = ++owner + receiver.advertisement = advertisement running.set(true) val identity = currentIdentity() val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { this.scope = it } scope.launch { - val socket = ServerSocket(0).also { serverSocket = it } - val port = socket.localPort - registerService(port, identity) - Log.i(TAG, "started pairing listener on port $port") - receiver.setAdvertising() - acceptLoop(socket) + val socket = ServerSocket(0) + // A later start() or stop() may have run while the socket opened: + // then this startup publishes nothing and closes its socket. + val published = synchronized(this@TvPairingAdvertiser) { + if (token != owner || !running.get()) return@synchronized false + serverSocket = socket + registerService(socket.localPort, identity) + receiver.setAdvertising() + true + } + if (!published) { + runCatching { socket.close() } + return@launch + } + Log.i(TAG, "started pairing listener on port ${socket.localPort}") + acceptLoop(socket, token) } + return token + } + + /** Stop only if the [start] that returned [token] still owns the advertiser. */ + @Synchronized + fun stop(token: Long) { + if (token == owner) stop() } /** Stop advertising, close the listening socket, and release any session. */ @@ -86,7 +119,7 @@ class TvPairingAdvertiser( receiver.setIdle() } - private suspend fun acceptLoop(socket: ServerSocket) { + private suspend fun acceptLoop(socket: ServerSocket, token: Long) { while (true) { val client: Socket = try { withContext(Dispatchers.IO) { socket.accept() } @@ -116,30 +149,45 @@ class TvPairingAdvertiser( Log.w(TAG, "pairing connection failed", t) runCatching { client.close() } } finally { - // Release for the next peer and resume advertising state — - // but only if we're still running. After stop()/cancellation - // the NSD + socket are down, so leave the receiver Idle rather - // than falsely claiming Advertising. Completed is a terminal - // UI dwell state; the setup screen advances after showing it. - busy.set(false) - if (receiver.status.value is PairingReceiverStatus.Completed) { - running.set(false) - runCatching { socket.close() } - if (serverSocket === socket) { - serverSocket = null - } - registrationListener?.let { listener -> - runCatching { nsdManager.unregisterService(listener) } - } - registrationListener = null - } else if (running.get()) { - receiver.setAdvertising() - } + finishConnection(socket, token) } } } } + /** + * Release for the next peer and resume advertising state, but only if + * we're still running. After stop()/cancellation the NSD + socket are + * down, so leave the receiver Idle rather than falsely claiming + * Advertising. Completed is a terminal UI dwell state; the setup screen + * advances after showing it. A lone Failed stays on screen with its + * explanation until the person closes it + * (PairingReceiver.cancelActiveSession). + * + * A connection whose [start] no longer owns the advertiser touches + * nothing: a later [start] already reset it, and the listener, busy flag + * and receiver state are that owner's now. + */ + @Synchronized + private fun finishConnection(socket: ServerSocket, token: Long) { + if (token != owner) return + busy.set(false) + val ended = receiver.status.value + if (ended is PairingReceiverStatus.Completed) { + running.set(false) + runCatching { socket.close() } + if (serverSocket === socket) { + serverSocket = null + } + registrationListener?.let { listener -> + runCatching { nsdManager.unregisterService(listener) } + } + registrationListener = null + } else if (running.get() && resumesAdvertisingAfterConnection(ended)) { + receiver.setAdvertising() + } + } + private fun registerService(port: Int, identity: PairingDeviceIdentity) { val sid = UUID.randomUUID().toString() val serviceInfo = NsdServiceInfo().apply { @@ -150,7 +198,11 @@ class TvPairingAdvertiser( setAttribute("name", identity.name) setAttribute("id", identity.deviceId) setAttribute("sid", sid) - setAttribute("st", receiverStateProvider().wire) + val advertisement = receiver.advertisement + setAttribute("st", advertisement.state.wire) + if (advertisement.state == PairingReceiverState.Login) { + advertisement.serverIdentity?.let { setAttribute("srv", it) } + } } val listener = object : NsdManager.RegistrationListener { override fun onServiceRegistered(info: NsdServiceInfo) {} @@ -173,3 +225,11 @@ class TvPairingAdvertiser( return PairingDeviceIdentity(name = name, deviceId = PairingDeviceId.stable(context)) } } + +/** + * Whether the receiver goes back to Advertising when a connection ends with + * [ended]. Completed dwells and then leaves the screen; a lone Failed keeps + * its explanation on screen until the person closes it. + */ +internal fun resumesAdvertisingAfterConnection(ended: PairingReceiverStatus): Boolean = + ended !is PairingReceiverStatus.Completed && ended !is PairingReceiverStatus.Failed diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinatorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinatorTest.kt index 415618d429..ba77795bc1 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinatorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/CompanionPairingCoordinatorTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.runTest import org.siloserver.silo.model.auth.DeviceLoginDecisionResponse import org.siloserver.silo.model.auth.DeviceLoginLookupResponse import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.pairing.PairingEndpoint import org.siloserver.silo.pairing.PairingMessage import org.siloserver.silo.pairing.PairingReceiverState import org.siloserver.silo.pairing.PairingServerStatus @@ -70,7 +71,10 @@ private class ScriptedPhoneTransport : PairingTransport { private class FakeCompanionApprover( private val matchCodes: Map = mapOf("ABCD-0001" to "MATCH-1"), private val onApprove: suspend (String) -> Unit = {}, + private val account: String? = "alice", ) : CompanionDeviceLoginApprover { + override suspend fun accountName(server: CompanionPairingServer): String? = account + val lookups = mutableListOf() val approvals = mutableListOf() val denials = mutableListOf() @@ -256,4 +260,179 @@ class CompanionPairingCoordinatorTest { coordinator.status.value, ) } + + @Test + fun signedOutTvGetsOnlyItsServerWithIdentityAndNoChooser() = runTest { + val transport = ScriptedPhoneTransport() + val home = CompanionPairingServer(id = "srv-1", url = "https://lib.example", displayName = "Home", isActive = true) + val other = CompanionPairingServer(id = "srv-2", url = "https://other.example", displayName = "Other") + val identities = mapOf("srv-1" to "S-HOME", "srv-2" to "S-OTHER") + val endpoints = listOf(PairingEndpoint.of("https://lib.example", PairingEndpoint.Kind.Public)) + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(home, other))), + deviceLoginApprover = FakeCompanionApprover( + matchCodes = mapOf("ABCD-0001" to "MATCH-1"), + onApprove = { transport.signIn("https://other.example") }, + ), + transportFactory = { transport }, + identitySource = object : CompanionServerIdentitySource { + override suspend fun identity(server: CompanionPairingServer) = identities[server.id] + override suspend fun endpoints(server: CompanionPairingServer) = endpoints + }, + ) + val target = CompanionPairingTarget( + deviceId = "tv-1", name = "Den TV", host = "127.0.0.1", port = 9999, + state = "login", serverIdentity = "S-OTHER", + ) + assertEquals(other, coordinator.serverForSignedOutTv(target)) + + var chooserShown = false + val result = coordinator.pair(target, chooseServers = { chooserShown = true; it }) + + assertEquals(CompanionPairingResult.Completed(serverCount = 1), result) + assertEquals(false, chooserShown) + val push = transport.sent.filterIsInstance().single() + assertEquals("https://other.example", push.serverURL) + assertEquals("S-OTHER", push.serverIdentity) + assertEquals(endpoints, push.endpoints) + } + + @Test + fun signedOutTvForAServerThisPhoneLacksIsNotOffered() = runTest { + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore( + CompanionPairingServerSnapshot("srv-1", listOf(CompanionPairingServer("srv-1", "https://lib.example", "Home"))), + ), + deviceLoginApprover = FakeCompanionApprover(), + transportFactory = { error("must not connect") }, + identitySource = object : CompanionServerIdentitySource { + override suspend fun identity(server: CompanionPairingServer) = "S-HOME" + override suspend fun endpoints(server: CompanionPairingServer) = null + }, + ) + val target = CompanionPairingTarget("tv-1", "Den TV", "127.0.0.1", 9999, state = "login", serverIdentity = "S-ELSEWHERE") + assertEquals(null, coordinator.serverForSignedOutTv(target)) + assertIs(coordinator.pair(target)) + // st filtering: login without srv, and unknown states, are never offered. + assertEquals(false, target.copy(serverIdentity = null).isOfferable) + assertEquals(false, target.copy(state = "future").isOfferable) + assertEquals(true, target.copy(state = null).isOfferable) + assertEquals(true, target.copy(state = "setup").isOfferable) + } + + @Test + fun tvFailureCodeBecomesReadablePhoneCopy() = runTest { + val transport = object : PairingTransport { + private val inbound = Channel(Channel.UNLIMITED) + override val incoming: Flow = inbound.consumeAsFlow() + init { + inbound.trySend(PairingMessage.Hello("Den TV", "tv-1", PairingReceiverState.Setup, listOf(1))) + } + override suspend fun send(message: PairingMessage) { + if (message is PairingMessage.PushServer) { + inbound.send(PairingMessage.DeviceStarted(message.serverURL, "ABCD-0001", "MATCH-1")) + } + } + suspend fun fail(url: String) { + inbound.send(PairingMessage.ServerResult(url, PairingServerStatus.Failed, "unreachable")) + } + override fun close() { inbound.close() } + } + val server = CompanionPairingServer("srv-1", "https://lib.example", "Home") + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(server))), + deviceLoginApprover = FakeCompanionApprover(onApprove = { transport.fail(server.url) }), + transportFactory = { transport }, + ) + val result = coordinator.pair(CompanionPairingTarget("tv-1", "Den TV", "127.0.0.1", 9999)) + assertEquals( + CompanionPairingResult.Failed("Den TV can't reach Home. Check the TV's network connection."), + result, + ) + } + + /** The card can't name the account approving would sign the TV in as: nothing is approved. */ + @Test + fun anUnnamedAccountIsNeverApproved() = runTest { + val transport = ScriptedPhoneTransport() + val server = CompanionPairingServer(id = "srv-1", url = "https://lib.example", displayName = "Home") + val approver = FakeCompanionApprover(account = null) + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(server))), + deviceLoginApprover = approver, + transportFactory = { transport }, + ) + + val result = coordinator.pair(target = CompanionPairingTarget("tv-1", "Living Room", "127.0.0.1", 9999)) + + assertEquals( + CompanionPairingResult.Failed("Couldn't confirm which account would sign in on Living Room. Try again."), + result, + ) + assertTrue(approver.approvals.isEmpty()) + assertTrue(approver.denials.isEmpty()) + assertTrue(transport.sent.any { it is PairingMessage.Cancel }) + } + + /** A TV that refuses the push before starting a sign-in fails the pairing at once, with its reason. */ + @Test + fun tvRefusingThePushBeforeStartingFailsAtOnceWithItsReason() = runTest { + val result = pairWithTvThat { inbound, push -> + inbound.send(PairingMessage.ServerResult(push.serverURL, PairingServerStatus.Failed, "identity_mismatch")) + } + assertEquals( + CompanionPairingResult.Failed("Den TV reached a different server at that address. Nothing was signed in."), + result, + ) + } + + @Test + fun tvDecliningThePushFailsAtOnceInsteadOfSpinning() = runTest { + // The TV user picks "Don't allow": the TV sends `cancel` and hangs up. + val result = pairWithTvThat { inbound, _ -> + inbound.send(PairingMessage.Cancel(reason = "consent_denied")) + inbound.close() + } + assertEquals(CompanionPairingResult.Failed("Den TV didn't allow it."), result) + } + + @Test + fun tvHangingUpMidPushFailsAtOnce() = runTest { + val result = pairWithTvThat { inbound, _ -> inbound.close() } + assertEquals(CompanionPairingResult.Failed("Den TV closed the connection. Try again."), result) + } + + @Test + fun silentTvTimesOutIntoAFailureNotAStuckCard() = runTest { + val coordinator = arrayOfNulls(1) + val result = pairWithTvThat(onCoordinator = { coordinator[0] = it }) { _, _ -> } + assertEquals(CompanionPairingResult.Failed("Den TV stopped responding. Try again."), result) + assertIs(coordinator[0]?.status?.value) + } + + /** Pairs with a TV whose reaction to the pushed server is [onPush]. */ + private suspend fun pairWithTvThat( + onCoordinator: (CompanionPairingCoordinator) -> Unit = {}, + onPush: suspend (Channel, PairingMessage.PushServer) -> Unit, + ): CompanionPairingResult { + val transport = object : PairingTransport { + private val inbound = Channel(Channel.UNLIMITED) + override val incoming: Flow = inbound.consumeAsFlow() + init { + inbound.trySend(PairingMessage.Hello("Den TV", "tv-1", PairingReceiverState.Setup, listOf(1))) + } + override suspend fun send(message: PairingMessage) { + if (message is PairingMessage.PushServer) onPush(inbound, message) + } + override fun close() { inbound.close() } + } + val server = CompanionPairingServer("srv-1", "https://lib.example", "Home") + val coordinator = CompanionPairingCoordinator( + serverStore = FakeCompanionServerStore(CompanionPairingServerSnapshot("srv-1", listOf(server))), + deviceLoginApprover = FakeCompanionApprover(), + transportFactory = { transport }, + ) + onCoordinator(coordinator) + return coordinator.pair(CompanionPairingTarget("tv-1", "Den TV", "127.0.0.1", 9999)) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/PairingReceiverTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/PairingReceiverTest.kt index 7e2368ccca..bf68301839 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/PairingReceiverTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/pairing/PairingReceiverTest.kt @@ -2,6 +2,9 @@ package org.siloserver.silo.common.pairing import org.siloserver.silo.model.auth.DeviceLoginStartResponse import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.network.api.ServerIdentityProbe +import org.siloserver.silo.pairing.PairingEndpoint +import org.siloserver.silo.pairing.PairingFailureCode import org.siloserver.silo.pairing.PairingMessage import org.siloserver.silo.pairing.PairingReceiverState import org.siloserver.silo.pairing.PairingServerStatus @@ -16,6 +19,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.assertIs @@ -55,6 +59,8 @@ private class FakeAuthPort : PairingAuthPort { ) val committedSessions = mutableListOf() + val verifiedIds = mutableListOf() + var failPersist = false override suspend fun persistApprovedSession( serverUrl: String, @@ -63,7 +69,10 @@ private class FakeAuthPort : PairingAuthPort { refreshToken: String, expiresIn: Long, expectedIdentity: org.siloserver.silo.network.AccountSessionExpectation?, + verifiedServerId: String?, ) { + if (failPersist) throw IllegalStateException("disk full") + verifiedIds += verifiedServerId committedSessions += CommittedSession( serverUrl = serverUrl, serverName = serverName, @@ -145,12 +154,13 @@ class PairingReceiverTest { auth: FakeAuthPort, login: FakeDeviceLogin, state: PairingReceiverState = PairingReceiverState.Setup, + identities: Map = emptyMap(), ) = PairingReceiver( authPort = auth, deviceLogin = login, identityProvider = { PairingDeviceIdentity(name = "Test TV", deviceId = "device-1") }, - receiverStateProvider = { state }, - ) + identityProbe = { url -> identities[url] ?: ServerIdentityProbe.Unreachable }, + ).also { it.advertisement = PairingAdvertisement(state) } @Test fun helloSentFirstWithCurrentState() = runTest { @@ -212,7 +222,11 @@ class PairingReceiverTest { val status = recv.status.value assertIs(status) assertEquals("Srv", status.serverName) + // People compare the TV's sign-in code, never the match words. + assertEquals("USER-CODE", status.userCode) + // The words ride along for the "Older phones show ..." fallback line. assertEquals("MATCH-42", status.matchCode) + assertFalse(status.automatic) // Approve → ServerResult(signedIn). login.approve() @@ -264,10 +278,12 @@ class PairingReceiverTest { val result = transport.sent.filterIsInstance().single() assertEquals(PairingServerStatus.Failed, result.status) - assertEquals("denied-by-user", result.error) + // A structured code, never human text (iOS reads unknown values as auth_failed). + assertEquals("denied", result.error) val failed = recv.status.value assertIs(failed) assertEquals("https://srv.test", failed.serverName) + assertEquals(PairingFailureCode.Denied, failed.code) assertEquals(emptyList(), auth.committedSessions) transport.deliver(PairingMessage.Done) @@ -340,4 +356,225 @@ class PairingReceiverTest { transport.deliver(PairingMessage.Done) job.join() } + + private suspend fun kotlinx.coroutines.test.TestScope.settle() = repeat(20) { yield() } + + @Test + fun newerPushSupersedesTheOneInFlight() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver(auth, login) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer(serverURL = "https://one.test", serverName = "One")) + settle() + recv.allowPendingServer() + settle() + assertEquals("https://one.test", login.beganWith?.serverUrl) + + transport.deliver(PairingMessage.PushServer(serverURL = "https://two.test", serverName = "Two")) + settle() + assertEquals("https://two.test", login.beganWith?.serverUrl) + val started = transport.sent.filterIsInstance() + assertEquals(listOf("https://one.test", "https://two.test"), started.map { it.serverURL }) + login.approve() + settle() + assertEquals(listOf("https://two.test"), auth.committedSessions.map { it.serverUrl }) + transport.deliver(PairingMessage.Done) + job.join() + } + + @Test + fun pushedIdentityIsVerifiedAndRecorded() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver(auth, login, identities = mapOf("https://srv.test" to ServerIdentityProbe.Identity("S1"))) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://srv.test", "Srv", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + login.approve() + settle() + assertEquals(listOf("S1"), auth.verifiedIds) + transport.deliver(PairingMessage.Done) + job.join() + } + + @Test + fun differentIdentityAtThePushedAddressFailsWithIdentityMismatch() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver(auth, login, identities = mapOf("https://srv.test" to ServerIdentityProbe.Identity("OTHER"))) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://srv.test", "Srv", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + assertEquals(null, login.beganWith, "never signs in at a different deployment") + val result = transport.sent.filterIsInstance().single() + assertEquals("identity_mismatch", result.error) + transport.deliver(PairingMessage.Done) + job.join() + assertEquals(PairingReceiverStatus.Failed("Srv", PairingFailureCode.IdentityMismatch), recv.status.value) + } + + @Test + fun unreachableAddressOffersAVerifiedAlternateAndUsesItOnlyWhenChosen() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val recv = receiver( + auth, + login, + identities = mapOf("https://alt.test" to ServerIdentityProbe.Identity("S1")), + ) + val job = launch { recv.run(transport) } + yield() + transport.deliver( + PairingMessage.PushServer( + "https://srv.test", + "Srv", + serverIdentity = "S1", + endpoints = listOf( + PairingEndpoint.of("https://srv.test", PairingEndpoint.Kind.Provider, "tailscale", "Tailscale"), + PairingEndpoint.of("https://alt.test", PairingEndpoint.Kind.Public), + ), + ), + ) + settle() + recv.allowPendingServer() + settle() + assertEquals( + PairingReceiverStatus.Unreachable("https://srv.test", "Srv", "Tailscale", "https://alt.test"), + recv.status.value, + ) + assertEquals(null, login.beganWith) + recv.useAlternateAddress() + settle() + assertEquals("https://alt.test", login.beganWith?.serverUrl) + // Frames still name the pushed address. + assertEquals("https://srv.test", transport.sent.filterIsInstance().single().serverURL) + transport.deliver(PairingMessage.Cancel("done")) + job.join() + } + + /** The sign-in screen's code, lent to the receiver in login mode. */ + private class FakeCodeSource : NearbySignInCodeSource { + val outcome = kotlinx.coroutines.CompletableDeferred() + var askedFor = mutableListOf() + override suspend fun codeForNearbyApproval() = FakeDeviceLogin.START_RESPONSE + override suspend fun nearbyApprovalOutcome(deviceCode: String): NearbySignInOutcome { + askedFor += deviceCode + return outcome.await() + } + } + + @Test + fun signedOutTvLendsItsOwnCodeAndStartsNoSecondRequest() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort() + val login = FakeDeviceLogin() + val source = FakeCodeSource() + val recv = receiver(auth, login) + recv.advertisement = PairingAdvertisement.login(serverIdentity = "S1", serverUrl = "http://192.168.1.5:8090", source = source) + val job = launch { recv.run(transport) } + yield() + assertEquals(PairingReceiverState.Login, (transport.sent.first() as PairingMessage.Hello).state) + transport.deliver(PairingMessage.PushServer("https://media.example.com", "Home", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + val started = transport.sent.filterIsInstance().single() + assertEquals("USER-CODE", started.userCode) + assertEquals("MATCH-42", started.matchCode) + assertEquals("https://media.example.com", started.serverURL) + assertEquals(null, login.beganWith, "no second device request") + assertEquals(listOf("dev-code"), source.askedFor) + + source.outcome.complete(NearbySignInOutcome.SignedIn) + settle() + assertTrue(auth.committedSessions.isEmpty(), "the sign-in screen saves the session") + val result = transport.sent.filterIsInstance().single() + assertEquals(PairingServerStatus.SignedIn, result.status) + assertEquals("https://media.example.com", result.serverURL) + transport.deliver(PairingMessage.Done) + job.join() + } + + @Test + fun signedOutTvRefusesAnotherServerOrAPushWithoutIdentity() = runTest { + for (identity in listOf("S2", null)) { + val transport = FakeTransport() + val source = FakeCodeSource() + val recv = receiver(FakeAuthPort(), FakeDeviceLogin(), identities = mapOf("https://media.example.com" to ServerIdentityProbe.Identity("S1"))) + recv.advertisement = PairingAdvertisement.login(serverIdentity = "S1", serverUrl = "http://192.168.1.5:8090", source = source) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://media.example.com", "Home", serverIdentity = identity)) + settle() + recv.allowPendingServer() + settle() + val refused = transport.sent.filterIsInstance().single() + assertEquals("identity_mismatch", refused.error, "identity=$identity") + assertTrue(transport.sent.none { it is PairingMessage.DeviceStarted }) + assertTrue(source.askedFor.isEmpty()) + transport.deliver(PairingMessage.Done) + job.join() + } + } + + @Test + fun aLoneFailureStaysOnScreenUntilClosedThenAdvertisingResumes() = runTest { + val transport = FakeTransport() + val source = FakeCodeSource() + val recv = receiver(FakeAuthPort(), FakeDeviceLogin()) + recv.advertisement = PairingAdvertisement.login(serverIdentity = "S1", serverUrl = "http://192.168.1.5:8090", source = source) + recv.setAdvertising() + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://media.example.com", "Home", serverIdentity = "S1")) + settle() + recv.allowPendingServer() + settle() + source.outcome.complete(NearbySignInOutcome.Failed(PairingFailureCode.Expired)) + settle() + transport.deliver(PairingMessage.Done) + job.join() + // What the advertiser sees when the connection ends: it keeps a + // Failed status rather than replacing it with Advertising. + assertEquals(PairingReceiverStatus.Failed("Home", PairingFailureCode.Expired), recv.status.value) + assertEquals(false, resumesAdvertisingAfterConnection(recv.status.value)) + assertEquals(true, resumesAdvertisingAfterConnection(PairingReceiverStatus.Idle)) + + recv.cancelActiveSession() + assertEquals(PairingReceiverStatus.Advertising, recv.status.value) + } + + @Test + fun failedCommitIsReportedInsteadOfStalling() = runTest { + val transport = FakeTransport() + val auth = FakeAuthPort().apply { failPersist = true } + val login = FakeDeviceLogin() + val recv = receiver(auth, login) + val job = launch { recv.run(transport) } + yield() + transport.deliver(PairingMessage.PushServer("https://srv.test", "Srv")) + settle() + recv.allowPendingServer() + settle() + login.approve() + settle() + val result = transport.sent.filterIsInstance().single() + assertEquals(PairingServerStatus.Failed, result.status) + assertEquals("auth_failed", result.error) + assertEquals(PairingReceiverStatus.Failed("Srv", PairingFailureCode.AuthFailed), recv.status.value) + transport.deliver(PairingMessage.Done) + job.join() + } } diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 06e42f8ac8..387f12726c 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -132,6 +132,8 @@ kotlin { implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.webkit) + implementation(libs.androidx.browser) + implementation(libs.coil.svg) implementation(libs.koin.androidx.workmanager) implementation(libs.firebase.messaging) // Google Cast (Chromecast) — phone app only. TV app must not depend diff --git a/androidApp/gradle.lockfile b/androidApp/gradle.lockfile index 2748063483..3d2e3117d6 100644 --- a/androidApp/gradle.lockfile +++ b/androidApp/gradle.lockfile @@ -15,6 +15,7 @@ androidx.arch.core:core-common:2.2.0=allInstrumentedTestSourceSetsCompileDepende androidx.arch.core:core-runtime:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.asynclayoutinflater:asynclayoutinflater:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath androidx.autofill:autofill:1.0.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.browser:browser:1.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-jvm:1.5.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-ktx:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -240,6 +241,7 @@ com.android.tools:annotations:31.10.1=_internal-unified-test-platform-android-de com.android.tools:common:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:desugar_jdk_libs:2.1.5=coreLibraryDesugaring com.android.tools:desugar_jdk_libs_configuration:2.1.5=coreLibraryDesugaring +com.caverock:androidsvg-aar:1.4=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.accompanist:accompanist-drawablepainter:0.36.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.android.datatransport:transport-api:3.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath com.google.android.datatransport:transport-api:3.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -327,6 +329,8 @@ io.coil-kt.coil3:coil-network-core-android:3.1.0=androidBenchmarkReleaseCompileC io.coil-kt.coil3:coil-network-core:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-network-okhttp-jvm:3.1.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-network-okhttp:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-svg-android:3.1.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +io.coil-kt.coil3:coil-svg:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil:3.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.github.peerless2012:ass-kt:0.5.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath io.github.peerless2012:ass-media:0.5.1=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml index adb9b767dc..f6ba9840a4 100644 --- a/androidApp/src/androidMain/AndroidManifest.xml +++ b/androidApp/src/androidMain/AndroidManifest.xml @@ -81,6 +81,29 @@ + + + + + + + + + + + + Coming soon + + + Sign in a TV + Sign in a TV + Enter the code on your TV\'s sign-in screen. + Code on your TV + Server + Your TV shows the server name it\'s signing in to. + Continue + Try %1$s + Sign in %1$s? + Sign in this TV? + Check that your TV shows + You\'ll sign it in to %1$s as %2$s. + You\'ll sign it in to %1$s with your account. + %1$s (%2$s) + Not you? Switch account + Switch account? + You\'ll be signed out of %1$s on this device, then asked which account to sign in with. The TV\'s code stays ready to approve. + this server + Sign out and switch + Not you? Sign out + Sign out? + You\'ll be signed out of %1$s on this device. Sign in with the account the TV should use, then approve it. The TV\'s code stays ready to approve. + Sign out + Couldn\'t confirm the account on %1$s. + Try again + Anyone using this TV can pick from your profiles. Profiles with a PIN stay locked. + Only approve a TV that\'s in front of you right now. + Requested from %1$s + %1$s · requested %2$s + Requested %1$s + just now + Sign in TV + Not now + Done. Your TV is signing in. + Your TV is signed in. + The TV stopped this sign-in. + This code expired before the TV signed in. + This sign-in was declined. + Approved. Check your TV to see that it signed in. + Sign-in declined. The TV was not signed in. + Done + Sign in + Enter the code shown on your TV. + Sign in before approving this TV. + We couldn\'t find that code. Check the code on your TV. + We couldn\'t find that code on %1$s. Check the code on your TV, or try another server. + This code expired. Your TV is showing a new one; scan it again. + That TV is already signed in. + This sign-in was declined. + The TV stopped this sign-in. Check the code on your TV. + Network error. Check your connection. + The sign-in provider can\'t be reached right now, so this device couldn\'t confirm your account. You\'re still signed in. Try again in a moment. + Couldn\'t sign in the TV. Try again. + Android TV + Apple TV + + + Set up %1$s + Sign %1$s in to your servers from this phone. + Set up + Sign in %1$s to %2$s? + + A TV nearby named “%1$s” says it\'s on its sign-in screen. You\'ll check its code before approving. + Sign in + Not now + Check that %1$s shows + Yes, this matches + Doesn\'t match + Signing in… + Continue on %1$s — allow this sign-in. + %1$s is signed in + Signed in to %1$s. + Sign-in didn\'t finish + Older TV apps show %1$s instead. + Nearby TV + Set up %1$s + TV setup complete + %1$s is ready. + Setup didn\'t finish + Setting up… + Continue on %1$s — allow this phone to set it up. + Connecting… + Waiting for %1$s to show its code. + Approving %1$s on %2$s. + %1$s signed in. + Please wait. + Choose servers + Sign %1$s in to… + Continue + Cancel + Close + Done + Try again diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/InMemoryPendingNativeSignInStore.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/InMemoryPendingNativeSignInStore.kt new file mode 100644 index 0000000000..882a8052ab --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/InMemoryPendingNativeSignInStore.kt @@ -0,0 +1,17 @@ +package org.siloserver.silo.android.auth + +/** A [PendingNativeSignInStore] for tests; the app keeps the flow in encrypted preferences. */ +class InMemoryPendingNativeSignInStore : PendingNativeSignInStore { + private var pending: PendingNativeSignIn? = null + override fun load(): PendingNativeSignIn? = pending + override fun save(pending: PendingNativeSignIn) { this.pending = pending } + override fun clear() { pending = null } +} + +/** An [AccountChoiceStore] for tests; the app keeps the requests in encrypted preferences. */ +class InMemoryAccountChoiceStore : AccountChoiceStore { + private val requested = java.util.concurrent.ConcurrentHashMap.newKeySet() + override fun request(serverEntryId: String) { requested += serverEntryId } + override fun isRequested(serverEntryId: String): Boolean = serverEntryId in requested + override fun clear(serverEntryId: String) { requested -= serverEntryId } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt new file mode 100644 index 0000000000..6265c6e40c --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt @@ -0,0 +1,248 @@ +package org.siloserver.silo.android.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondError +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.auth.AccountIdentities +import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.AccountIdentityLinkTicket +import org.siloserver.silo.model.auth.ExternalSignInCapabilities +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.model.auth.User +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.AccountSessionExpectation +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.ExternalSignInApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.AuthRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Where [RepositoryNativeSignInCompleter] sends a native flow's code: only to + * the saved server's base URL, and only while it is still at the origin the + * flow started on. The saved server keeps its address and id. + */ +class NativeSignInCompleterTest { + private val http = HttpClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }) + + @AfterTest fun tearDown() = http.close() + + private val user = User("1", "alice", "a@example.test", "user") + + private inner class Fixture(saved: ServerEntry) { + val registry = ListServerRegistry(saved) + val tokens = RecordingTokens(registry) + val api = RecordingApi(user) + val completer = RepositoryNativeSignInCompleter( + authRepository = AuthRepository(AuthApi(http, ApiV2Gate.Unrestricted), tokens, serverRegistry = registry), + api = api, + tokenManager = tokens, + ) + + suspend fun start() { + tokens.setServerUrl(registry.activeEntry.value!!.url) + tokens.activeServerId = registry.activeServerId.value + } + } + + private fun pending(startOrigin: String, purpose: NativeSignInPurpose = NativeSignInPurpose.SignIn, entryId: String) = + pending(purpose).copy(serverEntryId = entryId, startOrigin = startOrigin, verifiedServerId = "srv-1") + + @Test + fun aSignInRedeemsAtTheSavedBase() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://silo.example.test/silo", verifiedServerId = "srv-1")) + f.start() + val signedIn = f.completer.signIn(pending("https://silo.example.test", entryId = "pub"), "code-1") + assertEquals(user, assertIs>(signedIn).data) + assertEquals(listOf("https://silo.example.test/silo"), f.api.redeemedAt) + assertEquals(listOf("pub"), f.tokens.installedOn) + } + + /** A server saved by its LAN address signs in there and stays there. */ + @Test + fun aLanSavedServerSignsInAndStaysOnItsLanAddress() = runTest { + val lan = ServerEntry( + id = "lan", url = "http://192.168.1.10:8096", fetchedName = "Home", userOverrideName = "Den", + verifiedServerId = "srv-1", + ) + val f = Fixture(lan) + f.start() + assertIs>(f.completer.signIn(pending("http://192.168.1.10:8096", entryId = "lan"), "code-1")) + assertEquals(listOf("http://192.168.1.10:8096"), f.api.redeemedAt) + assertEquals(listOf(lan), f.registry.entries.value, "the saved server is unchanged") + assertEquals(listOf("lan"), f.tokens.installedOn) + } + + /** + * The saved address changed while the browser was open: the code isn't + * redeemed at the new address, which isn't where the flow started. + */ + @Test + fun aSavedServerOnAnotherOriginThanTheFlowRedeemsNothing() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://elsewhere.example.test", verifiedServerId = "srv-1")) + f.start() + val refused = assertIs(f.completer.signIn(pending("https://silo.example.test", entryId = "pub"), "c")) + assertEquals(NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInCoordinator.completionReason(refused)) + assertTrue(f.api.redeemedAt.isEmpty()) + assertTrue(f.tokens.installedOn.isEmpty()) + } + + /** + * The flow began signed out, then a password sign-in landed while the + * browser was open: the provider's code no longer speaks for this + * session and replaces nothing. + */ + @Test + fun aSignInSinceTheFlowBeganRedeemsNothing() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://silo.example.test", verifiedServerId = "srv-1")) + f.start() + f.tokens.loginSessionIds["pub"] = "password-login" + val refused = assertIs(f.completer.signIn(pending("https://silo.example.test", entryId = "pub"), "c")) + assertEquals(NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInCoordinator.completionReason(refused)) + assertTrue(f.api.redeemedAt.isEmpty()) + assertTrue(f.tokens.installedOn.isEmpty()) + } + + @Test + fun aFlowForAnotherSavedServerRedeemsNothing() = runTest { + val f = Fixture(ServerEntry(id = "pub", url = "https://silo.example.test", verifiedServerId = "srv-1")) + f.start() + assertIs(f.completer.signIn(pending("https://silo.example.test", entryId = "other"), "c")) + assertTrue(f.api.redeemedAt.isEmpty()) + } + + /** Linking from a LAN-saved server confirms the code at the LAN address, with the account's session. */ + @Test + fun linkingConfirmsAtTheSavedBase() = runTest { + val lan = ServerEntry(id = "lan", url = "http://192.168.1.10:8096", verifiedServerId = "srv-1") + val f = Fixture(lan) + f.start() + f.tokens.scope = AuthScopeSnapshot("lan", null, "http://192.168.1.10:8096", null, identityGeneration = 3, credentialEpoch = 4) + f.tokens.loginSessionIds["lan"] = "login-1" + val linking = pending("http://192.168.1.10:8096", NativeSignInPurpose.Link, entryId = "lan") + .copy(loginSessionId = "login-1") + assertIs>(f.completer.link(linking, "code-1")) + assertEquals(listOf("http://192.168.1.10:8096"), f.api.linkedAt) + assertEquals(listOf(lan), f.registry.entries.value) + + // Another login, no login, or a flow started on another origin confirms nothing. + assertIs(f.completer.link(linking.copy(loginSessionId = "login-2"), "code-1")) + assertIs(f.completer.link(linking.copy(loginSessionId = null), "code-1")) + assertIs(f.completer.link(linking.copy(startOrigin = "https://silo.example.test"), "code-1")) + assertEquals(1, f.api.linkedAt.size) + } + + /** Records where codes were redeemed. */ + private class RecordingApi(private val user: User) : ExternalSignInApi { + val redeemedAt = mutableListOf() + val linkedAt = mutableListOf() + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult { + redeemedAt += serverUrl + return ApiResult.Success(LoginResponse("access", "refresh", 3600, user)) + } + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult { + linkedAt += scope.serverUrl + return ApiResult.Success(Unit) + } + override suspend fun listProviders(serverUrl: String): ApiResult = TODO() + override suspend fun oauthCapabilities(serverUrl: String): ApiResult = TODO() + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = TODO() + } + + /** + * [TokenManagerImpl], recording which saved server each sign-in installed + * its session on. Like the app's manager, its session expectation names + * the active saved server, and its scope carries the address that server + * is saved at now. + */ + private class RecordingTokens( + private val registry: ServerRegistry, + private val inner: TokenManagerImpl = TokenManagerImpl(), + ) : TokenManager by inner { + val installedOn = mutableListOf() + var scope: AuthScopeSnapshot? = null + var activeServerId: String? = null + val loginSessionIds = mutableMapOf() + + override suspend fun loginSessionId(serverId: String): String? = loginSessionIds[serverId] + + override suspend fun captureAccountSessionExpectation(): AccountSessionExpectation? = + inner.captureAccountSessionExpectation()?.copy(serverId = activeServerId) + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot? = scope?.let { captured -> + registry.entries.value.firstOrNull { it.id == captured.serverId }?.let { captured.copy(serverUrl = it.url) } + } + + override suspend fun replaceAccountSession( + serverId: String?, + serverUrl: String?, + accessToken: String, + refreshToken: String, + expiresIn: Long, + profileId: String?, + profileToken: String?, + expectedIdentity: AccountSessionExpectation?, + ) { + installedOn += serverId + inner.replaceAccountSession(serverId, serverUrl, accessToken, refreshToken, expiresIn, profileId, profileToken, expectedIdentity) + } + } + + /** Saved servers keyed by address, like the app's registry. */ + private class ListServerRegistry(first: ServerEntry) : ServerRegistry { + override val entries = MutableStateFlow(listOf(first)) + override val activeServerId = MutableStateFlow(first.id) + private val active = MutableStateFlow(first) + override val activeEntry: StateFlow = active + + private fun edit(id: String, change: (ServerEntry) -> ServerEntry) { + entries.value = entries.value.map { if (it.id == id) change(it) else it } + active.value = entries.value.firstOrNull { it.id == activeServerId.value } + } + + override suspend fun addOrUpdate(url: String, fetchedName: String?): String { + val normalized = url.trimEnd('/') + val id = "id:$normalized" + if (entries.value.none { it.id == id }) entries.value = entries.value + ServerEntry(id = id, url = normalized, fetchedName = fetchedName) + return id + } + override suspend fun rename(serverId: String, userOverrideName: String?) = edit(serverId) { it.copy(userOverrideName = userOverrideName) } + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = edit(serverId) { it.copy(fetchedName = fetchedName) } + override suspend fun setVerifiedServerId(serverId: String, verifiedServerId: String?) = + edit(serverId) { it.copy(verifiedServerId = verifiedServerId) } + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) { + activeServerId.value = serverId + active.value = entries.value.firstOrNull { it.id == serverId } + } + override suspend fun touchActive() = Unit + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInLinkTransportTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInLinkTransportTest.kt new file mode 100644 index 0000000000..c6ae74ac75 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInLinkTransportTest.kt @@ -0,0 +1,199 @@ +package org.siloserver.silo.android.auth + +import android.app.Application +import android.content.Context +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.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.siloserver.silo.network.AndroidServerRegistry +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.EncryptedTokenManagerImpl +import org.siloserver.silo.network.SiloAuthPlugin +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.DefaultExternalSignInApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.AuthRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * A server saved at its LAN address signs in and links there, through the + * app's own registry, token manager, [SiloAuthPlugin] and API: the code and + * the account's bearer go to the LAN address, and the saved server keeps its + * address and id (no move, no second entry). + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class NativeSignInLinkTransportTest { + private class Sent(val url: String, val authorization: String?) + + private val sent = mutableListOf() + private var linkStatus = HttpStatusCode.NoContent + private val http = HttpClient( + MockEngine { request -> + sent += Sent(request.url.toString(), request.headers[HttpHeaders.Authorization]) + when { + request.url.encodedPath.endsWith("/auth/oauth/complete") -> respond( + """{"access_token":"sso-access","refresh_token":"sso-refresh","expires_in":3600,"next":"/",""" + + """"user":{"id":"1","username":"alice","email":"a@example.test","role":"user"}}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + linkStatus == HttpStatusCode.NoContent -> respond("", linkStatus) + else -> respond( + """{"type":"https://siloserver.org/docs/api/v2/problems/invalid_grant","title":"Bad request","status":400}""", + linkStatus, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } + }, + ) + + @AfterTest fun tearDown() = http.close() + + private inner class Fixture { + val prefs = RuntimeEnvironment.getApplication() + .getSharedPreferences("native-sign-in-link-transport", Context.MODE_PRIVATE) + .also { it.edit().clear().commit() } + var registry = AndroidServerRegistry(prefs) + lateinit var tokens: EncryptedTokenManagerImpl + lateinit var lanId: String + lateinit var completer: RepositoryNativeSignInCompleter + + suspend fun savedAtLan(signedIn: Boolean): AuthScopeSnapshot? { + lanId = registry.addOrUpdate(LAN) + registry.setVerifiedServerId(lanId, "srv-1") + registry.switchTo(lanId) + build() + tokens.switchActiveServer(lanId) + if (signedIn) tokens.saveTokens("lan-access", "lan-refresh", 3600) + return tokens.snapshotCurrentScope() + } + + /** A new process: a new registry and token manager over the same saved state. */ + fun recreate() { + registry = AndroidServerRegistry(prefs) + build() + } + + private fun build() { + tokens = EncryptedTokenManagerImpl(prefs, registry) + val client = http.config { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + completer = RepositoryNativeSignInCompleter( + authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens, serverRegistry = registry), + api = DefaultExternalSignInApi(client, ApiV2Gate.Unrestricted), + tokenManager = tokens, + ) + } + + fun flow(purpose: NativeSignInPurpose, loginSessionId: String? = null) = PendingNativeSignIn( + purpose = purpose, + serverEntryId = lanId, + verifiedServerId = "srv-1", + startOrigin = LAN, + appState = "state", + codeVerifier = "verifier", + providerName = "Keycloak", + startedAtEpochMs = 0L, + loginSessionId = loginSessionId, + ) + } + + @Test + fun aLanSavedServerSignsInAndStaysOnItsLanAddress() = runTest { + val f = Fixture() + f.savedAtLan(signedIn = false) + + assertIs>(f.completer.signIn(f.flow(NativeSignInPurpose.SignIn), "code-1")) + + assertEquals("$LAN/api/v2/auth/oauth/complete", sent.single().url) + val saved = f.registry.entries.value.single() + assertEquals(f.lanId, saved.id) + assertEquals(LAN, saved.url, "the saved address never changes") + assertEquals(f.lanId, f.registry.activeServerId.value) + assertEquals("sso-access", f.tokens.getAccessToken()) + } + + @Test + fun linkingConfirmsAtTheLanAddressWithTheAccountsBearer() = runTest { + val f = Fixture() + requireNotNull(f.savedAtLan(signedIn = true)) + + assertIs>(f.completer.link(f.flow(NativeSignInPurpose.Link, f.tokens.loginSessionId(f.lanId)), "code-1")) + + val confirmation = sent.single() + assertEquals("$LAN/api/v2/account/identities/link-complete", confirmation.url) + assertEquals("Bearer lan-access", confirmation.authorization) + assertEquals(LAN, f.registry.entries.value.single().url) + assertEquals("lan-access", f.tokens.getAccessToken(), "the session stays signed in") + } + + @Test + fun aRefusedLinkChangesNothing() = runTest { + val f = Fixture() + requireNotNull(f.savedAtLan(signedIn = true)) + linkStatus = HttpStatusCode.BadRequest + + val refused = assertIs( + f.completer.link(f.flow(NativeSignInPurpose.Link, f.tokens.loginSessionId(f.lanId)), "code-1"), + ) + + assertEquals("state_invalid", NativeSignInCoordinator.completionReason(refused)) + assertEquals(LAN, f.registry.entries.value.single().url) + assertEquals("lan-access", f.tokens.getAccessToken()) + } + + /** + * The system killed the app while the browser was open: a new token + * manager over the same saved credentials still confirms the link, since + * the login it began with is still signed in. + */ + @Test + fun aLinkFinishesAfterTheProcessIsRecreated() = runTest { + val f = Fixture() + requireNotNull(f.savedAtLan(signedIn = true)) + val linking = f.flow(NativeSignInPurpose.Link, requireNotNull(f.tokens.loginSessionId(f.lanId))) + + f.recreate() + + assertIs>(f.completer.link(linking, "code-1")) + assertEquals("$LAN/api/v2/account/identities/link-complete", sent.single().url) + } + + /** A password sign-in while the browser was open supersedes the provider flow. */ + @Test + fun aSignInSinceTheFlowBeganInstallsNothing() = runTest { + val f = Fixture() + f.savedAtLan(signedIn = false) + val flow = f.flow(NativeSignInPurpose.SignIn, f.tokens.loginSessionId(f.lanId)) + f.tokens.replaceAccountSession(serverId = f.lanId, accessToken = "pw-access", refreshToken = "pw-refresh", expiresIn = 3600) + + val refused = assertIs(f.completer.signIn(flow, "code-1")) + + assertEquals(NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInCoordinator.completionReason(refused)) + assertTrue(sent.isEmpty()) + assertEquals("pw-access", f.tokens.getAccessToken()) + } + + private companion object { + const val LAN = "http://192.168.1.10:8096" + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInPersistenceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInPersistenceTest.kt new file mode 100644 index 0000000000..11d0250592 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInPersistenceTest.kt @@ -0,0 +1,112 @@ +package org.siloserver.silo.android.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The on-disk native sign-in state that must survive process death: the + * system can kill the app while the browser is open, and a fresh store over + * the same preferences must still finish the flow. [SharedPrefsPendingNativeSignInStore.load] + * drops anything it can't decode, so a serialization regression would lose + * the callback silently; these tests catch that. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = android.app.Application::class) +class NativeSignInPersistenceTest { + private lateinit var prefs: SharedPreferences + + @BeforeTest + fun setUp() { + prefs = ApplicationProvider.getApplicationContext() + .getSharedPreferences("native_sign_in_test", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + } + + @Test + fun aPendingLinkSurvivesANewStoreWithEveryField() { + SharedPrefsPendingNativeSignInStore(prefs).save(LINK) + + // A new process builds a new store over the same preferences. + assertEquals(LINK, SharedPrefsPendingNativeSignInStore(prefs).load()) + } + + @Test + fun aPendingSignInSurvivesWithoutTheLinkFields() { + val signIn = LINK.copy(purpose = NativeSignInPurpose.SignIn, loginSessionId = null) + SharedPrefsPendingNativeSignInStore(prefs).save(signIn) + + assertEquals(signIn, SharedPrefsPendingNativeSignInStore(prefs).load()) + } + + /** A flow saved by the current shape, as it sits on disk, still decodes after an update. */ + @Test + fun theStoredShapeDecodes() { + prefs.edit().putString(PENDING_KEY, STORED_LINK_JSON).commit() + + assertEquals(LINK, SharedPrefsPendingNativeSignInStore(prefs).load()) + } + + @Test + fun clearRemovesThePendingFlow() { + val store = SharedPrefsPendingNativeSignInStore(prefs) + store.save(LINK) + store.clear() + + assertNull(store.load()) + assertNull(SharedPrefsPendingNativeSignInStore(prefs).load()) + assertFalse(prefs.contains(PENDING_KEY)) + } + + @Test + fun accountChoicesSurviveANewStoreAndClearOneServerAtATime() { + val store = SharedPrefsAccountChoiceStore(prefs) + store.request("entry-1") + store.request("entry-2") + + val reloaded = SharedPrefsAccountChoiceStore(prefs) + assertTrue(reloaded.isRequested("entry-1")) + assertTrue(reloaded.isRequested("entry-2")) + assertFalse(reloaded.isRequested("entry-3")) + + reloaded.clear("entry-1") + assertFalse(SharedPrefsAccountChoiceStore(prefs).isRequested("entry-1")) + assertTrue(SharedPrefsAccountChoiceStore(prefs).isRequested("entry-2")) + + reloaded.clear("entry-2") + assertFalse(SharedPrefsAccountChoiceStore(prefs).isRequested("entry-2")) + assertFalse(prefs.contains(ACCOUNT_CHOICE_KEY)) + } + + private companion object { + const val PENDING_KEY = "native_sign_in_pending" + const val ACCOUNT_CHOICE_KEY = "native_sign_in_account_choice" + + val LINK = PendingNativeSignIn( + purpose = NativeSignInPurpose.Link, + serverEntryId = "entry-1", + verifiedServerId = "server-1", + startOrigin = "https://silo.test", + appState = "app-state", + codeVerifier = "code-verifier", + providerName = "Keycloak", + startedAtEpochMs = 1_759_300_000_000, + loginSessionId = "login-1", + ) + + /** [LINK] as the current serializer writes it. */ + const val STORED_LINK_JSON = + """{"purpose":"Link","serverEntryId":"entry-1","verifiedServerId":"server-1",""" + + """"startOrigin":"https://silo.test","appState":"app-state","codeVerifier":"code-verifier",""" + + """"providerName":"Keycloak","startedAtEpochMs":1759300000000,"loginSessionId":"login-1"}""" + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInTest.kt new file mode 100644 index 0000000000..ab87a34ab2 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInTest.kt @@ -0,0 +1,569 @@ +package org.siloserver.silo.android.auth + +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.auth.User +import org.siloserver.silo.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeSignInProtocolTest { + @Test + fun challengeIsRfc7636S256() { + // RFC 7636 Appendix B. + assertEquals( + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + NativeSignInProtocol.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + ) + } + + @Test + fun verifierAndStateAreFreshUnreservedTokens() { + val unreserved = Regex("^[A-Za-z0-9._~-]+$") + val verifiers = List(20) { NativeSignInProtocol.newCodeVerifier() } + verifiers.forEach { + assertEquals(43, it.length) + assertTrue(unreserved.matches(it), it) + } + assertEquals(verifiers.size, verifiers.toSet().size) + val state = NativeSignInProtocol.newAppState() + assertTrue(unreserved.matches(state) && state.length in 1..512) + assertEquals(43, NativeSignInProtocol.challenge(verifiers.first()).length) + } + + @Test + fun startUrlAddsThePkceAndStateParameters() { + val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + assertEquals( + "https://silo.example.test/api/v2/auth/oauth/5/native/start" + + "?code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&app_state=abc", + NativeSignInProtocol.startUrl("https://silo.example.test/", "/api/v2/auth/oauth/5/native/start", "abc", verifier), + ) + val linking = NativeSignInProtocol.startUrl("https://h/silo", "/api/v2/auth/oauth/5/native/start?x=1#frag", "s", verifier, linkTicket = "t k") + assertEquals( + "https://h/silo/api/v2/auth/oauth/5/native/start?x=1&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + "&code_challenge_method=S256&app_state=s&link_ticket=t+k", + linking, + ) + assertFalse(assertNotNull(linking).contains(verifier), "the verifier itself never leaves the app") + } + + /** The start opens on the saved base and nowhere else: a path that names another host opens nothing. */ + @Test + fun startUrlNeverLeavesTheSavedBase() { + assertNull(NativeSignInProtocol.startUrl("https://silo.example.test", "//evil.example.test/x", "s", "v")) + assertNull(NativeSignInProtocol.startUrl("https://silo.example.test", "https://evil.example.test/x", "s", "v")) + assertNull(NativeSignInProtocol.startUrl("not a url", "/api/v2/auth/oauth/5/native/start", "s", "v")) + assertEquals( + "http://192.168.1.10:8096", + NativeSignInProtocol.origin(NativeSignInProtocol.startUrl("http://192.168.1.10:8096", "/api/v2/auth/oauth/5/native/start", "s", "v")), + ) + } + + @Test + fun startUrlCarriesAPrompt() { + val url = assertNotNull( + NativeSignInProtocol.startUrl("https://h", "/start", "s", "v", prompt = NativeSignInProtocol.PROMPT_SELECT_ACCOUNT), + ) + assertTrue(url.contains("&prompt=select_account"), url) + assertFalse(assertNotNull(NativeSignInProtocol.startUrl("https://h", "/start", "s", "v")).contains("prompt=")) + } + + @Test + fun originsCompareSchemeHostAndEffectivePort() { + assertEquals("https://silo.example.test", NativeSignInProtocol.origin("https://Silo.Example.test:443/api/v2/x?y=1")) + assertEquals("http://10.0.0.2:8096", NativeSignInProtocol.origin("http://10.0.0.2:8096/")) + assertEquals("http://silo.lan", NativeSignInProtocol.origin("http://silo.lan:80")) + assertNotEquals(NativeSignInProtocol.origin("https://silo.example.test"), NativeSignInProtocol.origin("http://silo.example.test")) + assertNotEquals(NativeSignInProtocol.origin("https://silo.example.test"), NativeSignInProtocol.origin("https://silo.example.test:8443")) + listOf(null, "", "silo.example.test", "ftp://silo.example.test", "https://user@silo.example.test", "https:///x", "ws://silo.lan") + .forEach { assertNull(NativeSignInProtocol.origin(it), "$it") } + } + + /** + * The same canonical origin as the rest of the app, which accepts an + * underscore host that `java.net.URI` reads as having none. (Hosts a + * browser rewrites differ; see the next test.) + */ + @Test + fun originsAgreeWithTheAppsCanonicalOrigin() { + assertEquals("http://silo_server:8096", NativeSignInProtocol.origin("http://silo_server:8096/api/v2/auth/oauth/5/native/start")) + listOf("http://silo_server:8096", "https://[fd00::1]:8920/x", "https://Silo.Example.test:443").forEach { + assertEquals(org.siloserver.silo.network.canonicalHttpOrigin(it), NativeSignInProtocol.origin(it), it) + } + } + + /** + * The server names in `iss` the `Host` the browser sent, which a browser + * writes in punycode and with a compressed IPv6 literal. A base saved in + * Unicode or with an uncompressed literal compares in that same form. + */ + @Test + fun originsWriteTheHostTheWayABrowserSendsIt() { + assertEquals("https://xn--bcher-kva.example", NativeSignInProtocol.origin("https://bücher.example/silo")) + assertEquals(NativeSignInProtocol.origin("https://xn--bcher-kva.example"), NativeSignInProtocol.origin("https://BÜCHER.example")) + assertEquals("http://[fd00::1]:8096", NativeSignInProtocol.origin("http://[fd00:0:0:0:0:0:0:1]:8096")) + assertEquals("http://[fd00::1]:8096", NativeSignInProtocol.origin("http://[FD00:0000::0001]:8096/")) + assertEquals("https://[2001:db8::1:0:0:1]", NativeSignInProtocol.origin("https://[2001:db8:0:0:1:0:0:1]:443")) + assertEquals("http://[2001:db8:0:1:1:1:1:1]", NativeSignInProtocol.origin("http://[2001:db8:0:1:1:1:1:1]")) + assertEquals("http://[::]", NativeSignInProtocol.origin("http://[0:0:0:0:0:0:0:0]")) + assertTrue(NativeSignInCoordinator.issuedBy("https://xn--bcher-kva.example", NativeSignInProtocol.origin("https://bücher.example")!!)) + assertTrue(NativeSignInCoordinator.issuedBy("http://[fd00::1]:8096", NativeSignInProtocol.origin("http://[fd00:0:0:0:0:0:0:1]:8096")!!)) + } +} + +class NativeSignInCallbackTest { + @Test + fun parsesOnlyTheExactAppRedirect() { + val parsed = assertNotNull( + NativeSignInCallback.parse("org.siloserver.silo:/auth/callback?code=c%2B1&state=s&server=srv-1"), + ) + assertEquals("c+1", parsed.code) + assertEquals("s", parsed.state) + assertEquals("srv-1", parsed.server) + assertNull(parsed.error) + assertFalse(parsed.link) + assertNull(parsed.iss) + assertEquals( + "https://silo.example.test", + assertNotNull(NativeSignInCallback.parse("org.siloserver.silo:/auth/callback?code=c&iss=https%3A%2F%2Fsilo.example.test")).iss, + ) + // A raw iss (some servers don't encode the colon and slashes) parses the same. + assertEquals( + "https://silo.example.test:8443", + assertNotNull(NativeSignInCallback.parse("org.siloserver.silo:/auth/callback?code=c&iss=https://silo.example.test:8443")).iss, + ) + + val error = assertNotNull(NativeSignInCallback.parse("org.siloserver.silo:/auth/callback?error=not_permitted&state=s&server=x")) + assertEquals("not_permitted", error.error) + assertNull(error.code) + assertTrue(assertNotNull(NativeSignInCallback.parse("org.siloserver.silo:/auth/callback?code=c&link=1")).link) + + listOf( + null, + "", + "org.siloserver.silo:/auth/other?code=c", + "org.siloserver.silo:/auth/callback/extra?code=c", + "org.siloserver.silo://auth/callback?code=c", + "org.siloserver.silo://evil.example/auth/callback?code=c", + "org.siloserver.silo:auth/callback?code=c", + "org.siloserver.silo:/auth/callback", + "silo:/auth/callback?code=c", + "https://silo.example.test/auth/callback?code=c", + ).forEach { assertNull(NativeSignInCallback.parse(it), "$it") } + } + + @Test + fun aRepeatedParameterKeepsTheFirstValue() { + val parsed = assertNotNull(NativeSignInCallback.parse("org.siloserver.silo:/auth/callback?state=a&state=b&code=c")) + assertEquals("a", parsed.state) + } + + @Test + fun secretsStayOutOfLogs() { + val parsed = assertNotNull(NativeSignInCallback.parse("org.siloserver.silo:/auth/callback?code=SECRET&state=STATE&server=x")) + assertFalse(parsed.toString().contains("SECRET")) + assertFalse(parsed.toString().contains("STATE")) + val pending = pending(NativeSignInPurpose.SignIn) + assertFalse(pending.toString().contains(pending.codeVerifier)) + assertFalse(pending.toString().contains(pending.appState)) + } +} + +class NativeSignInManifestTest { + @Test + fun phoneReceivesTheAppRedirectWithoutAutoVerify() { + val manifest = File("src/androidMain/AndroidManifest.xml").readText() + val activity = manifest.substringAfter("""android:name=".auth.NativeSignInCallbackActivity"""").substringBefore("") + assertTrue(activity.contains("""android:exported="true"""")) + assertTrue(activity.contains("""""")) + assertTrue(activity.contains("android.intent.category.BROWSABLE")) + assertFalse(activity.contains("autoVerify")) + } + + @Test + fun tvNeverClaimsTheAppRedirect() { + val tv = File("../androidTvApp/src/androidMain/AndroidManifest.xml").readText() + assertFalse(tv.contains("org.siloserver.silo\""), "TVs never run a provider sign-in") + } +} + +class NativeSignInCoordinatorTest { + private var now = 1_000_000L + + private class FakeCompleter : NativeSignInCompleter { + val signIns = mutableListOf>() + val links = mutableListOf>() + var signInResult: ApiResult = ApiResult.Success(User("1", "alice", "a@example.test", "user")) + var linkResult: ApiResult = ApiResult.Success(Unit) + val startOrigins = mutableListOf() + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult { + signIns += code to pending.codeVerifier + startOrigins += pending.startOrigin + return signInResult + } + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult { + links += code to pending.codeVerifier + return linkResult + } + } + + private fun coordinator( + store: PendingNativeSignInStore, + completer: NativeSignInCompleter, + scope: CoroutineScope, + ) = NativeSignInCoordinator(store, completer, scope, InMemoryAccountChoiceStore(), clock = { now }) + + private fun callback( + state: String?, + code: String? = "code-1", + server: String? = "srv-1", + error: String? = null, + link: Boolean = false, + iss: String? = "https://silo.example.test", + ) = NativeSignInCallback(code = code, state = state, server = server, error = error, link = link, iss = iss) + + private suspend fun NativeSignInCoordinator.start( + purpose: NativeSignInPurpose = NativeSignInPurpose.SignIn, + prompt: String? = null, + ): String { + val url = assertIs( + begin( + purpose = purpose, + serverEntryId = "entry-1", + serverUrl = "https://silo.example.test/", + verifiedServerId = "srv-1", + providerName = "Keycloak", + nativeStartPath = START_PATH, + linkTicket = if (purpose == NativeSignInPurpose.Link) "ticket" else null, + prompt = prompt, + ), + ).url + assertTrue(url.contains("code_challenge_method=S256")) + lastStartUrl = url + return url.substringAfter("app_state=").substringBefore('&') + } + + private var lastStartUrl = "" + + /** + * The relay: a hostile saved server's native start (on its own origin) + * redirects the browser to the real server's native start with this app's + * challenge and `app_state`. The real server's redirect carries a valid + * code and `state`, and `server` matches because the hostile server + * echoed the real id, but `iss` names where the real server's native + * start arrived, its own origin, not the saved one. Nothing is redeemed + * anywhere. + */ + @Test + fun aRelayedSignInComesBackWithAnotherIssuerAndIsDiscarded() = runTest { + val store = InMemoryPendingNativeSignInStore() + val completer = FakeCompleter() + val coordinator = coordinator(store, completer, this) + val start = assertIs( + coordinator.begin( + purpose = NativeSignInPurpose.SignIn, + serverEntryId = "hostile-entry", + serverUrl = "https://hostile.example.test", + verifiedServerId = "srv-real", + providerName = "Keycloak", + nativeStartPath = START_PATH, + ), + ) + val state = start.url.substringAfter("app_state=").substringBefore('&') + assertTrue(coordinator.finish(callback(state = state, server = "srv-real", iss = "https://real.example.test"))) + val failed = assertIs(coordinator.result.value) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, failed.reason) + assertEquals("This sign-in came back from a different server. Nothing was signed in.", failed.message) + assertTrue(completer.signIns.isEmpty() && completer.links.isEmpty()) + assertNull(store.load(), "the flow is spent") + } + + /** A sign-out while the code is being redeemed: the superseded flow reports nothing, not even Finishing. */ + @Test + fun aFlowDiscardedDuringRedemptionPublishesNothing() = runTest { + lateinit var coordinator: NativeSignInCoordinator + val completer = object : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult { + coordinator.discardPending() + return ApiResult.Error(0, "identity_changed", "The account or server changed.") + } + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = ApiResult.Success(Unit) + } + coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + + assertTrue(coordinator.finish(callback(state = coordinator.start()))) + + assertNull(coordinator.result.value) + } + + @Test + fun aRedirectWithoutAnIssuerIsDiscarded() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + assertTrue(coordinator.finish(callback(state = coordinator.start(), iss = null))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + // An error redirect needs it too. + assertTrue(coordinator.finish(callback(state = coordinator.start(), code = null, error = "not_permitted", iss = null))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + assertTrue(completer.signIns.isEmpty()) + } + + /** + * A server saved by its LAN address signs in there: the native start + * opens on the LAN base, the server answers with the LAN origin as `iss`, and the + * code is redeemed for the LAN entry. The public origin as `iss` is + * another origin than the one opened, so it is refused. + */ + @Test + fun aLanSavedServerSignsInOnItsLanAddress() = runTest { + val store = InMemoryPendingNativeSignInStore() + val completer = FakeCompleter() + val coordinator = coordinator(store, completer, this) + suspend fun begin() = assertIs( + coordinator.begin(NativeSignInPurpose.SignIn, "lan", "http://192.168.1.10:8096", "srv-1", "Keycloak", START_PATH), + ).url + val url = begin() + assertTrue(url.startsWith("http://192.168.1.10:8096/api/v2/auth/oauth/5/native/start?"), url) + assertEquals("http://192.168.1.10:8096", store.load()?.startOrigin) + assertTrue(coordinator.finish(callback(state = url.substringAfter("app_state=").substringBefore('&'), iss = "https://silo.example.test"))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + assertTrue(completer.startOrigins.isEmpty()) + + val again = begin().substringAfter("app_state=").substringBefore('&') + assertTrue(coordinator.finish(callback(state = again, iss = "http://192.168.1.10:8096"))) + assertEquals("lan", assertIs(coordinator.result.value).serverEntryId) + assertEquals(listOf("http://192.168.1.10:8096"), completer.startOrigins) + } + + /** A path the app can't place on the saved base starts nothing. */ + @Test + fun aStartThatCannotOpenOnTheSavedBaseIsRefused() = runTest { + val store = InMemoryPendingNativeSignInStore() + val coordinator = coordinator(store, FakeCompleter(), this) + assertIs( + coordinator.begin(NativeSignInPurpose.SignIn, "pub", "https://silo.example.test", "srv-1", "Keycloak", "//evil.example.test/x"), + ) + assertIs( + coordinator.begin(NativeSignInPurpose.SignIn, "pub", "not a url", "srv-1", "Keycloak", START_PATH), + ) + assertNull(store.load()) + } + + @Test + fun anIssuerOtherThanTheOpenedOriginIsRefusedWithoutRedeeming() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + assertTrue(coordinator.finish(callback(state = coordinator.start(), iss = "https://real.example.test"))) + assertEquals(NativeSignInMessages.ISSUER_MISMATCH, assertIs(coordinator.result.value).reason) + assertTrue(completer.signIns.isEmpty()) + + assertTrue(coordinator.finish(callback(state = coordinator.start(), iss = "https://SILO.example.test:443"))) + assertIs(coordinator.result.value) + assertEquals(1, completer.signIns.size) + } + + @Test + fun switchAccountAsksForAnotherAccountOnSignInOnly() = runTest { + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), FakeCompleter(), this) + assertFalse(coordinator.accountChoiceRequested("entry-1")) + coordinator.requestAccountChoice("entry-1") + assertTrue(coordinator.accountChoiceRequested("entry-1")) + assertFalse(coordinator.accountChoiceRequested("entry-2")) + + coordinator.start(NativeSignInPurpose.SignIn, prompt = NativeSignInProtocol.PROMPT_SELECT_ACCOUNT) + assertTrue(lastStartUrl.contains("prompt=select_account"), lastStartUrl) + coordinator.start(NativeSignInPurpose.Link, prompt = NativeSignInProtocol.PROMPT_SELECT_ACCOUNT) + assertFalse(lastStartUrl.contains("prompt="), "a linking flow never asks to pick an account") + + // Signing in ends it. + assertTrue(coordinator.finish(callback(state = coordinator.start()))) + assertIs(coordinator.result.value) + assertFalse(coordinator.accountChoiceRequested("entry-1")) + } + + @Test + fun aCallbackWithNoPendingFlowIsIgnored() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + assertFalse(coordinator.finish(callback(state = "anything"))) + assertNull(coordinator.result.value) + assertTrue(completer.signIns.isEmpty()) + } + + @Test + fun aForeignStateIsIgnoredAndTheRealRedirectStillFinishes() = runTest { + val store = InMemoryPendingNativeSignInStore() + val completer = FakeCompleter() + val coordinator = coordinator(store, completer, this) + val state = coordinator.start() + assertFalse(coordinator.finish(callback(state = "hijacker"))) + assertFalse(coordinator.finish(callback(state = null))) + assertNotNull(store.load(), "the pending flow waits for the real redirect") + assertNull(coordinator.result.value) + + assertTrue(coordinator.finish(callback(state = state))) + assertIs(coordinator.result.value) + assertEquals("code-1", completer.signIns.single().first) + assertEquals(43, completer.signIns.single().second.length) + assertNull(store.load(), "the flow is spent") + assertFalse(coordinator.finish(callback(state = state)), "a replayed redirect finds no flow") + assertEquals(1, completer.signIns.size) + } + + @Test + fun anExpiredFlowIsDropped() = runTest { + val store = InMemoryPendingNativeSignInStore() + val coordinator = coordinator(store, FakeCompleter(), this) + val state = coordinator.start() + now += NativeSignInProtocol.FLOW_LIFETIME_MS + 1 + assertFalse(coordinator.finish(callback(state = state))) + assertNull(store.load()) + } + + @Test + fun anotherServerIsRefusedWithoutRedeemingTheCode() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + val state = coordinator.start() + assertTrue(coordinator.finish(callback(state = state, server = "srv-other"))) + val failed = assertIs(coordinator.result.value) + assertEquals(NativeSignInMessages.WRONG_SERVER, failed.reason) + assertTrue(completer.signIns.isEmpty()) + + val again = coordinator.start() + assertTrue(coordinator.finish(callback(state = again, server = null))) + assertEquals(NativeSignInMessages.WRONG_SERVER, assertIs(coordinator.result.value).reason) + } + + @Test + fun serverReasonsBecomeReadableFailures() = runTest { + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), FakeCompleter(), this) + for (reason in listOf("not_permitted", "email_in_use", "identity_linked_elsewhere", "account_disabled", + "provider_unavailable", "state_invalid", "session_expired", "already_linked", "login_failed", "account_required", + "password_expired")) { + val state = coordinator.start() + assertTrue(coordinator.finish(callback(state = state, code = null, error = reason))) + val failed = assertIs(coordinator.result.value) + assertEquals(reason, failed.reason) + assertEquals(NativeSignInMessages.forReason(reason, "Keycloak"), failed.message) + coordinator.consume(failed) + assertNull(coordinator.result.value) + } + } + + @Test + fun aRedirectForTheOtherPurposeIsRefused() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + val state = coordinator.start(NativeSignInPurpose.SignIn) + assertTrue(coordinator.finish(callback(state = state, link = true))) + assertEquals("login_failed", assertIs(coordinator.result.value).reason) + assertTrue(completer.signIns.isEmpty() && completer.links.isEmpty()) + } + + @Test + fun linkingConfirmsTheCodeWithTheVerifier() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + val state = coordinator.start(NativeSignInPurpose.Link) + assertTrue(coordinator.finish(callback(state = state, link = true))) + assertEquals("Keycloak", assertIs(coordinator.result.value).providerName) + assertEquals("code-1", completer.links.single().first) + + completer.linkResult = ApiResult.Error(409, "identity_linked_elsewhere", "") + val second = coordinator.start(NativeSignInPurpose.Link) + coordinator.finish(callback(state = second, link = true)) + assertEquals("identity_linked_elsewhere", assertIs(coordinator.result.value).reason) + } + + @Test + fun redemptionFailuresMapToReasons() = runTest { + val completer = FakeCompleter() + val coordinator = coordinator(InMemoryPendingNativeSignInStore(), completer, this) + completer.signInResult = ApiResult.NetworkError(RuntimeException("offline")) + coordinator.finish(callback(state = coordinator.start())) + assertEquals(NativeSignInMessages.NETWORK, assertIs(coordinator.result.value).reason) + + // The provider couldn't re-check the session before a fresh-bearer request. + completer.signInResult = ApiResult.NetworkError( + org.siloserver.silo.network.SiloAuthUnavailableException( + org.siloserver.silo.network.SiloAuthUnavailableException.PROVIDER_UNAVAILABLE, + ), + ) + coordinator.finish(callback(state = coordinator.start())) + assertEquals("provider_unavailable", assertIs(coordinator.result.value).reason) + + val table = mapOf( + ApiResult.Error(400, "invalid_grant", "") to "state_invalid", + ApiResult.Error(401, "invalid_token", "") to "session_expired", + ApiResult.Error(403, "not_permitted", "") to "not_permitted", + ApiResult.Error(403, "permission_denied", "") to NativeSignInMessages.PERMISSION_DENIED, + ApiResult.Error(403, "account_disabled", "") to "account_disabled", + ApiResult.Error(409, "conflict", "") to "already_linked", + ApiResult.Error(409, "identity_linked_elsewhere", "") to "identity_linked_elsewhere", + ApiResult.Error(409, "email_in_use", "") to "email_in_use", + ApiResult.Error(409, "already_linked", "") to "already_linked", + ApiResult.Error(403, "account_required", "") to NativeSignInMessages.ACCOUNT_REQUIRED, + ApiResult.Error(503, "", "") to "provider_unavailable", + ApiResult.Error(0, "identity_changed", "") to NativeSignInMessages.ACCOUNT_CHANGED, + ApiResult.Error(500, "internal", "") to "login_failed", + ApiResult.Error(400, "password_expired", "") to "password_expired", + ApiResult.Error(429, "rate_limited", "") to NativeSignInMessages.RATE_LIMITED, + ApiResult.Error(429, "", "") to NativeSignInMessages.RATE_LIMITED, + ApiResult.Error(403, "", "") to "not_permitted", + ApiResult.Error(0, NativeSignInMessages.UPDATE_REQUIRED, "") to NativeSignInMessages.UPDATE_REQUIRED, + ) + table.forEach { (error, reason) -> assertEquals(reason, NativeSignInCoordinator.completionReason(error), "$error") } + } + + @Test + fun everyReasonHasItsOwnText() { + val generic = NativeSignInMessages.forReason("something_new", "Keycloak") + listOf("not_permitted", "email_in_use", "identity_linked_elsewhere", "account_disabled", "provider_unavailable", + "state_invalid", "session_expired", "already_linked", "password_expired", NativeSignInMessages.WRONG_SERVER, + NativeSignInMessages.ISSUER_MISMATCH, + NativeSignInMessages.ACCOUNT_REQUIRED, NativeSignInMessages.ACCOUNT_CHANGED, NativeSignInMessages.PERMISSION_DENIED, + NativeSignInMessages.NETWORK, NativeSignInMessages.NO_BROWSER, NativeSignInMessages.RATE_LIMITED, + NativeSignInMessages.UPDATE_REQUIRED).forEach { + assertNotEquals(generic, NativeSignInMessages.forReason(it, "Keycloak"), it) + assertFalse(NativeSignInMessages.forReason(it, "Keycloak").contains(it) && it.contains('_'), "no raw code in $it") + } + } +} + +internal const val START_PATH = "/api/v2/auth/oauth/5/native/start" + +internal fun pending(purpose: NativeSignInPurpose) = PendingNativeSignIn( + purpose = purpose, + serverEntryId = "entry-1", + verifiedServerId = "srv-1", + startOrigin = "https://silo.example.test", + appState = NativeSignInProtocol.newAppState(), + codeVerifier = NativeSignInProtocol.newCodeVerifier(), + providerName = "Keycloak", + startedAtEpochMs = 0L, +) + +/** The nearby-TV confirmation uses the same content model as silo-apple's CompanionPairingCard. */ +class CompanionConfirmCopyTest { + @Test + fun confirmationCopyMatchesTheIphoneApp() { + val strings = File("src/androidMain/res/values/strings.xml").readText() + listOf( + "companion_confirm_yes\">Yes, this matches<", + "sign_in_tv_profiles_note\">Anyone using this TV can pick from your profiles. Profiles with a PIN stay locked.<", + "sign_in_tv_warning\">Only approve a TV that\\'s in front of you right now.<", + "sign_in_tv_server_account\">You\\'ll sign it in to %1\$s as %2\$s.<", + "companion_offer_signin_body\">A TV nearby named “%1\$s” says it\\'s on its sign-in screen. You\\'ll check its code before approving.<", + "companion_confirm_older_tv\">Older TV apps show %1\$s instead.<", + ).forEach { assertTrue(strings.contains(it), it) } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt new file mode 100644 index 0000000000..99385a1ef2 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt @@ -0,0 +1,77 @@ +package org.siloserver.silo.android.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.feature.MetadataAiFeatureStore +import org.siloserver.silo.model.feature.RequestsFeatureStore +import org.siloserver.silo.model.profile.ActiveProfileStore +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.ProfileApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.MetadataAiRepository +import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.repository.RequestsRepository +import java.lang.reflect.Proxy +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * An explicit sign-out asks the server's next provider sign-in to offer + * another account (`prompt=select_account`): the provider's browser session + * outlives the Silo one, and would otherwise sign the same person straight + * back in. + */ +class SignOutTeardownTest { + private val http = HttpClient(MockEngine { respond("", HttpStatusCode.NoContent) }) + + @AfterTest fun tearDown() = http.close() + + @Test + fun signingOutAsksTheSignedOutServerForAnAccountChoice() = runTest { + val tokens = TokenManagerImpl() + val coordinator = NativeSignInCoordinator( + InMemoryPendingNativeSignInStore(), + idle(), + backgroundScope, + InMemoryAccountChoiceStore(), + ) + val registry = idle(mapOf("getActiveServerId" to MutableStateFlow("home"))) + val teardown = SignOutTeardown( + authRepository = AuthRepository(AuthApi(http, ApiV2Gate.Unrestricted), tokens), + playerSettingsStore = idle(), + libraryPlaybackPrefsStore = idle(), + overlayPrefsStore = idle(), + activeProfileStore = ActiveProfileStore(ProfileRepository(ProfileApi(http, ApiV2Gate.Unrestricted), tokens)), + cardPresentationStore = idle(), + seekIntervalStore = idle(), + titleArtStore = idle(), + requestsFeatureStore = RequestsFeatureStore(RequestsRepository(idle())), + metadataAiFeatureStore = MetadataAiFeatureStore(MetadataAiRepository(idle())), + serverRegistry = registry, + nativeSignIn = coordinator, + ) + assertFalse(coordinator.accountChoiceRequested("home")) + + teardown.signOut() + + assertTrue(coordinator.accountChoiceRequested("home")) + assertFalse(coordinator.accountChoiceRequested("cabin"), "only the server signed out of") + } + + /** Collaborators the sign-out doesn't depend on: they answer nothing and emit nothing. */ + private inline fun idle(values: Map = emptyMap()): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + values[method.name] ?: if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParserTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParserTest.kt index b9219d2cb3..07967cb842 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParserTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParserTest.kt @@ -100,4 +100,29 @@ class DeviceLoginRouteParserTest { ) assertNull(deviceLoginPairRouteOrNull("https://silo.example:0/device?code=A")) } + + @Test + fun `an app link without server is scoped by its url origin`() { + assertEquals( + "pair_device?code=48217730&serverOrigin=https%3A%2F%2Fhome.example%3A8443", + deviceLoginPairRouteOrNull("silo://device?url=https%3A%2F%2FHome.example%3A8443%2F&code=48217730"), + ) + // The default port normalizes away, as for an https link. + assertEquals( + "pair_device?code=48217730&serverOrigin=https%3A%2F%2Fhome.example", + deviceLoginPairRouteOrNull("silo://device?url=https%3A%2F%2Fhome.example%3A443&code=48217730"), + ) + } + + @Test + fun `an app link without server ignores an unreadable url`() { + assertEquals( + "pair_device?code=48217730", + deviceLoginPairRouteOrNull("silo://device?url=javascript%3Aalert(1)&code=48217730"), + ) + assertEquals( + "pair_device?code=48217730", + deviceLoginPairRouteOrNull("silo://device?url=https%3A%2F%2F&code=48217730"), + ) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginServerIdentityMatchTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginServerIdentityMatchTest.kt new file mode 100644 index 0000000000..ab7c6329a4 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginServerIdentityMatchTest.kt @@ -0,0 +1,99 @@ +package org.siloserver.silo.android.ui.navigation + +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.server.ServerEntry +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `silo://device?server=&url=&code=` links name their server + * by deployment identity. These pin how that identity picks a saved server. + */ +class DeviceLoginServerIdentityMatchTest { + private val home = ServerEntry(id = "a", url = "https://home.example", fetchedName = "Home") + private val lan = ServerEntry(id = "b", url = "http://192.168.1.5:8090", fetchedName = "Home (LAN)") + private val other = ServerEntry(id = "c", url = "https://other.example", fetchedName = "Other") + + private suspend fun match( + linkUrl: String?, + active: ServerEntry?, + ids: Map, + required: String = "S-HOME", + ) = deviceLoginServerMatchByIdentity( + requiredServerId = required, + linkUrl = linkUrl, + activeEntry = active, + entries = listOf(home, lan, other), + identityOf = { ids[it.id] }, + ) + + @Test + fun activeServerWithTheIdentityProceedsEvenAtAnotherAddress() = runTest { + // The link names the public URL; the phone saved the LAN address of the same server. + assertEquals( + DeviceLoginServerMatch.Active, + match("https://home.example", lan, mapOf("b" to "S-HOME", "a" to "S-HOME")), + ) + } + + @Test + fun anotherSavedServerWithTheIdentityOffersTheSwitch() = runTest { + assertEquals( + DeviceLoginServerMatch.SwitchRequired(lan), + match("https://public.example", other, mapOf("c" to "S-OTHER", "b" to "S-HOME")), + ) + } + + @Test + fun noSavedServerWithTheIdentityIsUnknownEvenWhenTheAddressMatches() = runTest { + // Same address, different deployment: identity wins over URL spelling. + assertEquals( + DeviceLoginServerMatch.UnknownServer("https://home.example"), + match("https://home.example", home, mapOf("a" to "S-ELSE", "b" to null, "c" to "S-OTHER")), + ) + } + + @Test + fun unverifiableServersFallBackToTheLinkOrigin() = runTest { + assertEquals( + DeviceLoginServerMatch.Active, + match("https://home.example", home, emptyMap()), + ) + assertEquals( + DeviceLoginServerMatch.SwitchRequired(home), + match("https://home.example", other, mapOf("c" to "S-OTHER")), + ) + } + + @Test + fun noServerYetProceedsThroughSetup() = runTest { + assertEquals(DeviceLoginServerMatch.Active, match("https://home.example", null, emptyMap())) + } + + @Test + fun parserCarriesTheServerIdentityAndBase() { + assertEquals( + "pair_device?code=48217730&serverId=S-HOME&serverUrl=https%3A%2F%2Fhome.example%2Fsilo", + deviceLoginPairRouteOrNull("silo://device?server=S-HOME&url=https%3A%2F%2Fhome.example%2Fsilo%2F&code=48217730"), + ) + // A url that isn't http(s) is dropped, not trusted. + assertEquals( + "pair_device?code=48217730&serverId=S-HOME", + deviceLoginPairRouteOrNull("silo://device?server=S-HOME&url=javascript%3Aalert(1)&code=48217730"), + ) + // The TV's QR link opens the app too when the OS delivers it. + assertEquals( + "pair_device?code=48217730&serverOrigin=https%3A%2F%2Fhome.example", + deviceLoginPairRouteOrNull("https://home.example/activate?code=48217730"), + ) + } + + @Test + fun aCodeLinkForAnotherSavedServerIsApprovedThereWithoutSwitching() { + val match = DeviceLoginServerMatch.SwitchRequired(lan) + assertEquals(lan, match.inPlaceApprovalServer(token = null, code = "48217730")) + // Only the active server's lookup accepts a token: that still needs the switch. + assertEquals(null, match.inPlaceApprovalServer(token = "tok", code = null)) + assertEquals(null, match.inPlaceApprovalServer(token = null, code = " ")) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/ApprovalAccountSwitchTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/ApprovalAccountSwitchTest.kt new file mode 100644 index 0000000000..f0bd2d6443 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/ApprovalAccountSwitchTest.kt @@ -0,0 +1,31 @@ +package org.siloserver.silo.android.ui.screens.auth + +import org.siloserver.silo.model.auth.SignInOptions +import org.siloserver.silo.model.auth.SignInProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * "Not you?" on the approval card always shows; its wording follows what the + * next sign-in offers (silo-apple `TVApprovalAccountSwitch`). + */ +class ApprovalAccountSwitchTest { + private val keycloak = SignInProvider( + id = "kc", + displayName = "Keycloak", + mode = SignInProvider.Mode.OAuth, + isDefault = false, + iconUrl = null, + installationId = "5", + nativeStartPath = "/api/v2/auth/oauth/5/native/start", + ) + + @Test + fun theWordingFollowsTheServersSignInOptions() { + assertEquals(ApprovalAccountSwitch.SignOut, ApprovalAccountSwitch.of(null), "options couldn't be read") + assertEquals(ApprovalAccountSwitch.SwitchAccount, ApprovalAccountSwitch.of(SignInOptions.PasswordOnly)) + val provider = SignInOptions(oauthProviders = listOf(keycloak), showPasswordForm = true, directoryProvider = null) + assertEquals(ApprovalAccountSwitch.SignOut, ApprovalAccountSwitch.of(provider)) + assertEquals(ApprovalAccountSwitch.ChooseAccount, ApprovalAccountSwitch.of(provider.copy(selectAccount = true))) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingSignInRequestTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingSignInRequestTest.kt new file mode 100644 index 0000000000..2c5184c346 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingSignInRequestTest.kt @@ -0,0 +1,30 @@ +package org.siloserver.silo.android.ui.screens.auth + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.siloserver.silo.viewmodel.DeviceApprovalServer +import org.siloserver.silo.viewmodel.DevicePairingError +import org.siloserver.silo.viewmodel.DevicePairingUiState + +/** "Sign in" after a 401 targets the server the code was looked up on, and keeps the code. */ +class DevicePairingSignInRequestTest { + private val home = DeviceApprovalServer("a", "https://home.example", "Home", isActive = true) + private val cabin = DeviceApprovalServer("b", "https://cabin.example", "Cabin") + + @Test + fun signInAfterA401OnAChosenNonActiveServerTargetsThatServerWithTheTypedCode() { + val state = DevicePairingUiState( + code = "48217730", + servers = listOf(home, cabin), + selectedServerId = "b", + error = DevicePairingError.SignInFirst, + ) + assertEquals("b" to "48217730", state.signInRequest()) + } + + @Test + fun noTypedCodeCarriesNone() { + val state = DevicePairingUiState(servers = listOf(home), selectedServerId = "a") + assertEquals("a" to null, state.signInRequest()) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginV2ConsumerTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginV2ConsumerTest.kt index 576e5b2f8e..d847e141a8 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginV2ConsumerTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginV2ConsumerTest.kt @@ -15,7 +15,20 @@ import kotlinx.coroutines.test.* import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.TokenManagerImpl import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.android.auth.InMemoryAccountChoiceStore +import org.siloserver.silo.android.auth.InMemoryPendingNativeSignInStore +import org.siloserver.silo.android.auth.NativeSignInCompleter +import org.siloserver.silo.android.auth.NativeSignInCoordinator +import org.siloserver.silo.android.auth.PendingNativeSignIn +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.api.DefaultExternalSignInApi +import org.siloserver.silo.network.api.DefaultServerIdentityApi import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.ExternalSignInRepository +import org.siloserver.silo.repository.ServerIdentityRepository +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.* @OptIn(ExperimentalCoroutinesApi::class) @@ -32,7 +45,18 @@ class LoginV2ConsumerTest { respond("""{"access_token":"access","refresh_token":"refresh","expires_in":3600,"user":{"id":"1","username":"user","email":"u@example.test","role":"user"}}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) }) { install(ContentNegotiation) { json(SiloJson) } } - val vm = LoginViewModel(AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens)) + // Provider discovery on its own client: this server lists none. + val discovery = HttpClient(MockEngine { respond("", HttpStatusCode.NotFound) }) { + install(ContentNegotiation) { json(SiloJson) } + } + val vm = LoginViewModel( + AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + ExternalSignInRepository(DefaultExternalSignInApi(discovery, ApiV2Gate.Unrestricted)), + noServers, + ServerIdentityRepository(noServers, DefaultServerIdentityApi(discovery)), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoCompleter, backgroundScope, InMemoryAccountChoiceStore()), + tokens, + ) try { vm.onUsernameChanged("user"); vm.onPasswordChanged("password") vm.onLoginClick(); vm.onLoginClick(); entered.await() @@ -42,8 +66,28 @@ class LoginV2ConsumerTest { assertEquals(1, calls); assertEquals(!switchServer, settled.loginSuccess) assertEquals(if (switchServer) null else "access", tokens.getAccessToken()) if (switchServer) assertNotNull(settled.error) - } finally { vm.viewModelScope.cancel(); client.close() } + } finally { vm.viewModelScope.cancel(); client.close(); discovery.close() } } } finally { Dispatchers.resetMain() } } + + private object NoCompleter : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String) = error("no provider sign-in here") + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = error("no linking here") + } + + /** No saved servers: the password path under test uses the token manager's URL alone. */ + private val noServers = object : ServerRegistry { + override val entries = MutableStateFlow(emptyList()) + override val activeServerId = MutableStateFlow(null) + override val activeEntry = MutableStateFlow(null) + override suspend fun addOrUpdate(url: String, fetchedName: String?) = error("unused") + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt new file mode 100644 index 0000000000..757bb46e73 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt @@ -0,0 +1,471 @@ +package org.siloserver.silo.android.ui.screens.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +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.respondError +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.siloserver.silo.android.auth.InMemoryAccountChoiceStore +import org.siloserver.silo.android.auth.InMemoryPendingNativeSignInStore +import org.siloserver.silo.android.auth.NativeSignInCallback +import org.siloserver.silo.android.auth.NativeSignInCompleter +import org.siloserver.silo.android.auth.NativeSignInCoordinator +import org.siloserver.silo.android.auth.NativeSignInMessages +import org.siloserver.silo.android.auth.NativeSignInPurpose +import org.siloserver.silo.android.auth.NativeSignInStart +import org.siloserver.silo.android.auth.PendingNativeSignIn +import org.siloserver.silo.model.auth.AccountIdentities +import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.AccountIdentityLinkTicket +import org.siloserver.silo.model.auth.ExternalSignInCapabilities +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.model.auth.User +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.ExternalSignInApi +import org.siloserver.silo.network.api.ServerConnections +import org.siloserver.silo.network.api.ServerIdentityApi +import org.siloserver.silo.network.api.ServerIdentityProbe +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.ExternalSignInRepository +import org.siloserver.silo.repository.ServerIdentityRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The phone sign-in screen's provider paths: discovery, starting a flow, and the flow's result. */ +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelSsoTest { + private val dispatcher = StandardTestDispatcher() + + @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest fun tearDown() = Dispatchers.resetMain() + + private val keycloak = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + private val native = OAuthHandshakeCapabilities(available = true, native = true, linking = true, selectAccount = true) + + private class Fixture( + scope: CoroutineScope, + serverUrl: String, + var providers: ApiResult, + var handshake: ApiResult, + var identity: ServerIdentityProbe, + ) { + val tokens = TokenManagerImpl() + val registry = OneServerRegistry(ServerEntry(id = "a", url = serverUrl)) + val store = InMemoryPendingNativeSignInStore() + val coordinator = NativeSignInCoordinator(store, SignsIn(), scope, InMemoryAccountChoiceStore()) + private val http = HttpClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }) + val authRepository = AuthRepository(AuthApi(http, ApiV2Gate.Unrestricted), tokens) + val api = object : ExternalSignInApi { + override suspend fun listProviders(serverUrl: String) = providers + override suspend fun oauthCapabilities(serverUrl: String) = handshake + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = TODO() + } + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = identity + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + + suspend fun viewModel(): LoginViewModel { + tokens.setServerUrl(registry.activeEntry.value!!.url) + return LoginViewModel( + authRepository = authRepository, + externalSignIn = ExternalSignInRepository(api), + serverRegistry = registry, + serverIdentities = ServerIdentityRepository(registry, identityApi), + nativeSignIn = coordinator, + tokenManager = tokens, + ) + } + + fun close() = http.close() + } + + private fun TestScope.fixture( + serverUrl: String = "https://silo.example.test", + providers: List = listOf(keycloak), + identity: ServerIdentityProbe = ServerIdentityProbe.Identity("srv-1"), + ) = Fixture( + scope = backgroundScope, + serverUrl = serverUrl, + providers = ApiResult.Success(SignInProviders(providers, passwordLogin = true)), + handshake = ApiResult.Success(native), + identity = identity, + ) + + private fun LoginViewModel.close() = viewModelScope.cancel() + + @Test + fun aNetworkFailureOffersTheFormAndARetry() = runTest(dispatcher) { + val f = fixture() + f.providers = ApiResult.NetworkError(RuntimeException("offline")) + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.optionsUnavailable) + assertTrue(vm.uiState.value.showPasswordForm) + assertTrue(vm.uiState.value.providers.isEmpty()) + + f.providers = ApiResult.Success(SignInProviders(listOf(keycloak), passwordLogin = true)) + vm.loadOptions() + advanceUntilIdle() + assertFalse(vm.uiState.value.optionsUnavailable) + assertEquals(listOf(keycloak), vm.uiState.value.providers) + vm.close(); f.close() + } + + @Test + fun aServerWithoutDiscoveryIsPasswordOnlyWithoutARetry() = runTest(dispatcher) { + val f = fixture() + f.providers = ApiResult.Error(404, "not_found", "") + val vm = f.viewModel() + advanceUntilIdle() + assertFalse(vm.uiState.value.optionsUnavailable) + assertTrue(vm.uiState.value.showPasswordForm) + vm.close(); f.close() + } + + @Test + fun anUnconfirmedIdentityStartsNothing() = runTest(dispatcher) { + val f = fixture(identity = ServerIdentityProbe.Unreachable) + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertEquals("Couldn't confirm this server's identity. Check your connection and try again.", vm.uiState.value.error) + assertNull(vm.uiState.value.browserLaunch) + assertNull(vm.uiState.value.providerBusy) + assertNull(f.store.load()) + vm.close(); f.close() + } + + @Test + fun aServerErrorDuringDiscoveryOffersTheFormAndARetry() = runTest(dispatcher) { + val f = fixture() + f.providers = ApiResult.Error(502, "", "bad gateway") + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.optionsUnavailable) + assertTrue(vm.uiState.value.showPasswordForm) + vm.close(); f.close() + } + + @Test + fun noPasswordFormWhileDiscoveryRuns() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + assertNull(vm.uiState.value.options) + assertFalse(vm.uiState.value.showPasswordForm) + advanceUntilIdle() + assertTrue(vm.uiState.value.showPasswordForm) + vm.close(); f.close() + } + + /** + * A server saved by its LAN address: the native start opens on the LAN + * base the app saved, and the flow is bound to the LAN origin. + */ + @Test + fun aLanSavedServerOpensTheNativeStartOnItsLanAddress() = runTest(dispatcher) { + val f = fixture(serverUrl = "http://192.168.1.10:8096") + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertTrue( + vm.uiState.value.browserLaunch.orEmpty().startsWith("http://192.168.1.10:8096/api/v2/auth/oauth/5/native/start?"), + vm.uiState.value.browserLaunch, + ) + assertNull(vm.uiState.value.error) + assertEquals("http://192.168.1.10:8096", f.store.load()?.startOrigin) + assertEquals(listOf("http://192.168.1.10:8096"), f.registry.entries.value.map { it.url }) + vm.close(); f.close() + } + + @Test + fun useADifferentAccountAsksTheProviderToOfferAnother() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.offersAccountChoice) + vm.onUseDifferentAccount() + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + vm.onBrowserLaunchHandled(opened = true) + vm.close() + + // Several providers: the screen asks which one first. + val other = keycloak.copy(id = "plugin:6:oidc", displayName = "Authentik", installationId = "6") + val two = fixture(providers = listOf(keycloak, other)) + val chooser = two.viewModel() + advanceUntilIdle() + chooser.onUseDifferentAccount() + assertTrue(chooser.uiState.value.choosingAccountProvider) + assertNull(chooser.uiState.value.browserLaunch) + chooser.onAccountProviderChosen(other) + advanceUntilIdle() + assertFalse(chooser.uiState.value.choosingAccountProvider) + assertTrue(chooser.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + chooser.close(); two.close() + + // Not offered where the server doesn't take it. + f.handshake = ApiResult.Success(native.copy(selectAccount = false)) + val older = f.viewModel() + advanceUntilIdle() + assertFalse(older.uiState.value.offersAccountChoice) + older.onUseDifferentAccount() + advanceUntilIdle() + assertNull(older.uiState.value.browserLaunch) + older.close(); f.close() + } + + /** After an explicit sign-out, not after a session that expired. */ + @Test + fun anExplicitSignOutAsksForAnAccountOnce() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertFalse(vm.uiState.value.browserLaunch.orEmpty().contains("prompt="), "an expired session never asks") + vm.onBrowserLaunchHandled(opened = true) + + f.coordinator.requestAccountChoice("a") + vm.onProviderClick(keycloak) + advanceUntilIdle() + val state = vm.uiState.value.browserLaunch.orEmpty().also { assertTrue(it.contains("prompt=select_account")) } + .substringAfter("app_state=").substringBefore('&') + vm.onBrowserLaunchHandled(opened = true) + f.coordinator.finish( + NativeSignInCallback(code = "c", state = state, server = "srv-1", error = null, link = false, iss = "https://silo.example.test"), + ) + advanceUntilIdle() + assertFalse(f.coordinator.accountChoiceRequested("a"), "signing in ends it") + vm.close(); f.close() + } + + @Test + fun switchAccountAddsSelectAccountWhenTheServerTakesIt() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertFalse(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=")) + vm.onBrowserLaunchHandled(opened = true) + + f.coordinator.requestAccountChoice("a") + vm.onProviderClick(keycloak) + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + vm.onBrowserLaunchHandled(opened = true) + vm.close() + + // A server that doesn't advertise it never gets it. + f.handshake = ApiResult.Success(native.copy(selectAccount = false)) + val older = f.viewModel() + advanceUntilIdle() + older.onProviderClick(keycloak) + advanceUntilIdle() + assertFalse(older.uiState.value.browserLaunch.orEmpty().contains("prompt=")) + older.close(); f.close() + } + + /** + * "Not you? Switch account" where the provider takes `select_account`: + * the login screen starts that provider's sign-in by itself, once. + */ + @Test + fun switchAccountStartsTheOnlyProvidersSignInByItself() = runTest(dispatcher) { + val f = fixture() + f.coordinator.requestAccountChoice("a", autoStart = true) + val vm = f.viewModel() + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account"), vm.uiState.value.browserLaunch) + vm.onBrowserLaunchHandled(opened = true) + vm.close() + + val again = f.viewModel() + advanceUntilIdle() + assertNull(again.uiState.value.browserLaunch, "only once per request") + again.close() + + // A plain sign-out asks for an account but opens nothing by itself. + f.coordinator.requestAccountChoice("a") + val plain = f.viewModel() + advanceUntilIdle() + assertNull(plain.uiState.value.browserLaunch) + plain.close(); f.close() + + // Several providers: the person picks one. + val other = keycloak.copy(id = "plugin:6:oidc", displayName = "Authentik", installationId = "6") + val two = fixture(providers = listOf(keycloak, other)) + two.coordinator.requestAccountChoice("a", autoStart = true) + val chooser = two.viewModel() + advanceUntilIdle() + assertNull(chooser.uiState.value.browserLaunch) + chooser.close(); two.close() + } + + /** The screen went away before the browser opened: the next login screen starts it instead. */ + @Test + fun anAutoStartTheScreenNeverOpenedIsHandedBack() = runTest(dispatcher) { + val f = fixture() + f.coordinator.requestAccountChoice("a", autoStart = true) + val store = ViewModelStore() + val built = f.viewModel() + val vm = ViewModelProvider( + store, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = built as T + }, + )[LoginViewModel::class.java] + advanceUntilIdle() + assertTrue(vm.uiState.value.browserLaunch.orEmpty().contains("prompt=select_account")) + store.clear() + assertTrue(f.coordinator.consumeAutoStart("a"), "handed back") + + // Not after someone signed in there. + f.coordinator.requestAccountChoice("a", autoStart = true) + f.coordinator.clearAccountChoice("a") + assertFalse(f.coordinator.consumeAutoStart("a")) + f.coordinator.restoreAutoStart("a") + assertFalse(f.coordinator.consumeAutoStart("a")) + f.close() + } + + @Test + fun resultsForAnotherServerAreIgnored() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + val start = f.coordinator.begin( + NativeSignInPurpose.SignIn, "b", "https://silo.example.test", "srv-1", "Keycloak", keycloak.nativeStartPath!!, + ) + val state = assertIs(start).url.substringAfter("app_state=").substringBefore('&') + f.coordinator.finish( + NativeSignInCallback( + code = null, state = state, server = "srv-1", error = "not_permitted", link = false, iss = "https://silo.example.test", + ), + ) + advanceUntilIdle() + assertNull(vm.uiState.value.error, "another saved server's failure isn't this screen's") + vm.close(); f.close() + } + + @Test + fun aSignInResultRoutesOnlyWhileItsSessionIsInstalled() = runTest(dispatcher) { + val f = fixture() + val vm = f.viewModel() + advanceUntilIdle() + suspend fun finishOne() { + val start = f.coordinator.begin( + NativeSignInPurpose.SignIn, "a", "https://silo.example.test", "srv-1", "Keycloak", keycloak.nativeStartPath!!, + ) + val state = assertIs(start).url.substringAfter("app_state=").substringBefore('&') + f.coordinator.finish( + NativeSignInCallback(code = "c", state = state, server = "srv-1", error = null, link = false, iss = "https://silo.example.test"), + ) + advanceUntilIdle() + } + // Signed out again before the result arrived: nowhere to go. + finishOne() + assertFalse(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.isLoading) + + f.tokens.saveTokens("access", "refresh", 3600) + finishOne() + assertTrue(vm.uiState.value.loginSuccess, "this screen's server is active and its session installed") + vm.onLoginSuccessConsumed() + + // Another saved server became active meanwhile: not this screen's to route. + f.registry.activeServerId.value = "other" + finishOne() + assertFalse(vm.uiState.value.loginSuccess) + vm.close(); f.close() + } + + @Test + fun passwordRefusalsFromADirectoryHaveTheirOwnText() { + assertEquals( + NativeSignInMessages.forReason("email_in_use", "Directory"), + passwordLoginMessage(org.siloserver.silo.network.ApiResult.Error(409, "email_in_use", "raw"), directoryName = "Directory"), + ) + assertEquals( + NativeSignInMessages.forReason("identity_linked_elsewhere", "Directory"), + passwordLoginMessage(ApiResult.Error(409, "identity_linked_elsewhere", "raw"), directoryName = "Directory"), + ) + assertEquals( + NativeSignInMessages.forReason(NativeSignInMessages.RATE_LIMITED), + passwordLoginMessage(ApiResult.Error(429, "", "raw")), + ) + } + + /** Signs in whatever it is given; the tests look at routing, not redemption. */ + private class SignsIn : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult = + ApiResult.Success(User("1", "alice", "a@example.test", "user")) + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = ApiResult.Success(Unit) + } + + private class OneServerRegistry(entry: ServerEntry) : ServerRegistry { + private val active = MutableStateFlow(entry) + override val entries: StateFlow> = MutableStateFlow(listOf(entry)) + override val activeServerId = MutableStateFlow(entry.id) + override val activeEntry: StateFlow = active + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = active.value!!.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt index 7bd4ccf5d5..74bf2e7a59 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -26,7 +26,16 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import org.siloserver.silo.android.auth.InMemoryAccountChoiceStore +import org.siloserver.silo.android.auth.InMemoryPendingNativeSignInStore +import org.siloserver.silo.android.auth.NativeSignInCompleter +import org.siloserver.silo.android.auth.NativeSignInCoordinator +import org.siloserver.silo.android.auth.SignOutTeardown import org.siloserver.silo.common.player.AudiobookSettingsStore +import org.siloserver.silo.model.feature.MetadataAiFeatureStore +import org.siloserver.silo.model.feature.RequestsFeatureStore +import org.siloserver.silo.repository.MetadataAiRepository +import org.siloserver.silo.repository.RequestsRepository import org.siloserver.silo.common.settings.CardPresentationUiState import org.siloserver.silo.domain.settings.ProfileSettingsController import org.siloserver.silo.model.profile.ActiveProfileStore @@ -130,11 +139,10 @@ class SettingsViewModelOfflinePreferencesTest { override suspend fun listProfiles() = ApiResult.Success(listOf(Profile(id = "p1", name = "Test", subtitleMode = "auto"))) }) profiles.refresh() + val authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens) val vm = SettingsViewModel( - authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + authRepository = authRepository, playerSettingsStore = idleStore(), - libraryPlaybackPrefsStore = idleStore(), - overlayPrefsStore = idleStore(), activeProfileStore = profiles, notificationsRepository = NotificationsRepository(NotificationsV2Api(client, tokens, ApiV2Gate.Unrestricted)), profileSettings = ProfileSettingsController(SettingsRepository(api)), @@ -143,6 +151,25 @@ class SettingsViewModelOfflinePreferencesTest { "getState" to MutableStateFlow(SeekIntervalState()), "getLastError" to MutableStateFlow(null), )), + signOutTeardown = SignOutTeardown( + authRepository = authRepository, + playerSettingsStore = idleStore(), + libraryPlaybackPrefsStore = idleStore(), + overlayPrefsStore = idleStore(), + activeProfileStore = profiles, + cardPresentationStore = idleStore(), + seekIntervalStore = idleStore(), + titleArtStore = idleStore(), + requestsFeatureStore = RequestsFeatureStore(RequestsRepository(idleStore())), + metadataAiFeatureStore = MetadataAiFeatureStore(MetadataAiRepository(idleStore())), + serverRegistry = idleStore(mapOf("getActiveServerId" to MutableStateFlow(null))), + nativeSignIn = NativeSignInCoordinator( + InMemoryPendingNativeSignInStore(), + idleStore(), + backgroundScope, + InMemoryAccountChoiceStore(), + ), + ), audiobookSettingsStore = AudiobookSettingsStore(RuntimeEnvironment.getApplication(), { null }), ) try { diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt new file mode 100644 index 0000000000..81de2ee820 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt @@ -0,0 +1,155 @@ +package org.siloserver.silo.android.ui.screens.settings + +import org.siloserver.silo.android.ui.screens.auth.passwordLoginMessage +import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.api.DefaultExternalSignInApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SignInSettingsTest { + private val local = SignInProvider("local", "Silo account", SignInProvider.Mode.Credentials, true, null, null, null) + private val ldap = SignInProvider("plugin:6:ldap", "Directory", SignInProvider.Mode.Credentials, false, null, "6", null) + private val oidc = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + private val identity = AccountIdentity("4", "5", "plugin:5:oidc", "Keycloak", "alice", "", "", "2026-01-02T03:04:05Z", null) + private val all = OAuthHandshakeCapabilities(available = true, native = true, linking = true) + + @Test + fun aServerWithoutIdentitiesShowsNoSection() { + val section = SignInSettingsViewModel.sectionOf(false, listOf(identity), listOf(local, oidc, ldap), all, credentialsLinking = true) + assertTrue(section.identities.isEmpty() && section.connectable.isEmpty() && section.directory == null) + } + + @Test + fun oidcIsConnectableThroughTheBrowserUntilLinked() { + val unlinked = SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, oidc), all, credentialsLinking = true) + assertEquals(listOf(oidc), unlinked.connectable) + val linked = SignInSettingsViewModel.sectionOf(true, listOf(identity), listOf(local, oidc), all, credentialsLinking = true) + assertTrue(linked.connectable.isEmpty()) + assertEquals(listOf(identity), linked.identities) + val noAppLinking = SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(oidc), all.copy(linking = false), credentialsLinking = true) + assertTrue(noAppLinking.connectable.isEmpty()) + } + + @Test + fun ldapIsConnectableOnlyWithCredentialsLinking() { + assertEquals(ldap, SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, ldap), all, credentialsLinking = true).directory) + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, ldap), all, credentialsLinking = false).directory) + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local), all, credentialsLinking = true).directory, "the local provider is not a directory") + val ldapIdentity = identity.copy(installationId = "6") + assertNull(SignInSettingsViewModel.sectionOf(true, listOf(ldapIdentity), listOf(ldap), all, credentialsLinking = true).directory) + } + + /** `credentials_linking` comes from the external sign-in document, not the OAuth handshake. */ + @Test + fun directoryLinkingNeedsNoOAuthHandshake() { + assertEquals( + ldap, + SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(local, ldap), OAuthHandshakeCapabilities.None, credentialsLinking = true) + .directory, + ) + } + + @Test + fun canUnlinkDecidesDisconnect() { + val only = SignInSettingsViewModel.sectionOf(true, listOf(identity), listOf(oidc), all, credentialsLinking = true, canUnlink = false) + assertEquals(false, only.canUnlink) + val free = SignInSettingsViewModel.sectionOf(true, listOf(identity), listOf(oidc), all, credentialsLinking = true, canUnlink = true) + assertEquals(true, free.canUnlink) + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(oidc), all, credentialsLinking = true, canUnlink = false).canUnlink) + // With can_unlink the confirmation no longer hedges. + assertFalse(SignInSettingsViewModel.disconnectConfirmBody(identity, canUnlink = true).contains("another way to sign in")) + assertTrue(SignInSettingsViewModel.ONLY_SIGN_IN_METHOD.contains("only way to sign in")) + } + + @Test + fun directoryRefusalsHaveTheirOwnText() { + val codes = listOf( + 422 to DefaultExternalSignInApi.WRONG_PASSWORD, + 422 to DefaultExternalSignInApi.DIRECTORY_REFUSED, + 409 to "local_password_required", + 403 to "not_permitted", + 403 to "account_disabled", + 403 to "password_expired", + 403 to "permission_denied", + 409 to "identity_linked_elsewhere", + 409 to "already_linked", + 404 to "not_found", + 503 to "provider_unavailable", + 429 to "rate_limited", + ) + val texts = codes.map { (status, code) -> + SignInSettingsViewModel.directoryLinkMessage(ApiResult.Error(status, code, "server detail"), "Directory") + } + assertEquals(texts.size, texts.toSet().size, texts.joinToString("\n")) + texts.forEach { assertFalse(it.contains("server detail"), "the server's detail is never shown") } + assertTrue(texts[1].contains("Directory")) + // Typing mistakes and throttling keep the dialog open; the rest close it. + assertTrue(SignInSettingsViewModel.keepsPromptOpen(ApiResult.Error(422, DefaultExternalSignInApi.WRONG_PASSWORD, ""))) + assertTrue(SignInSettingsViewModel.keepsPromptOpen(ApiResult.Error(429, "rate_limited", ""))) + assertFalse(SignInSettingsViewModel.keepsPromptOpen(ApiResult.Error(409, "identity_linked_elsewhere", ""))) + } + + @Test + fun linkTicketAndDisconnectRefusals() { + assertEquals( + "That password is incorrect.", + SignInSettingsViewModel.linkTicketMessage(ApiResult.Error(422, DefaultExternalSignInApi.WRONG_PASSWORD, ""), "Keycloak"), + ) + assertTrue(SignInSettingsViewModel.linkTicketMessage(ApiResult.Error(409, "conflict", ""), "Keycloak").contains("no password")) + assertTrue(SignInSettingsViewModel.linkTicketMessage(ApiResult.Error(403, "permission_denied", ""), "Keycloak").contains("session")) + assertTrue( + SignInSettingsViewModel.disconnectMessage(ApiResult.Error(409, "last_sign_in_method", ""), identity) + .contains("only way to sign in"), + ) + assertTrue(SignInSettingsViewModel.disconnectMessage(ApiResult.Error(404, "not_found", ""), identity).contains("already")) + } + + @Test + fun connectSaysThePasswordStopsWorkingAndDisconnectDoesNotPromiseIt() { + assertTrue(SignInSettingsViewModel.connectDescription("Keycloak").contains("instead of your password")) + assertTrue(SignInSettingsViewModel.connectPrompt("Keycloak").contains("instead of your password")) + assertTrue(SignInSettingsViewModel.connectDirectoryDescription("Directory").contains("Directory username and password instead")) + assertTrue(SignInSettingsViewModel.connectDirectoryPrompt("Directory").contains("username and password instead")) + val body = SignInSettingsViewModel.disconnectConfirmBody(identity) + assertFalse(body.contains("such as your password"), body) + assertTrue(body.contains("another way to sign in"), body) + } + + @Test + fun sharedRefusalsReadTheSameInEveryOperation() { + val tooMany = ApiResult.Error(429, "rate_limited", "") + assertEquals( + SignInSettingsViewModel.linkTicketMessage(tooMany, "Keycloak"), + SignInSettingsViewModel.disconnectMessage(tooMany, identity), + ) + val denied = ApiResult.Error(403, "permission_denied", "") + assertEquals( + SignInSettingsViewModel.linkTicketMessage(denied, "Keycloak"), + SignInSettingsViewModel.directoryLinkMessage(denied, "Keycloak"), + ) + } + + @Test + fun passwordLoginNamesLocalLoginDisabled() { + assertEquals( + "Password sign-in is turned off on this server. Sign in with Keycloak instead.", + passwordLoginMessage(ApiResult.Error(403, "local_login_disabled", ""), "Keycloak"), + ) + assertEquals( + "Password sign-in is turned off on this server.", + passwordLoginMessage(ApiResult.Error(403, "local_login_disabled", "")), + ) + assertEquals("Invalid username or password", passwordLoginMessage(ApiResult.Error(401, "invalid_credentials", ""))) + assertTrue(passwordLoginMessage(ApiResult.Error(403, "password_expired", "")).contains("expired")) + assertTrue(passwordLoginMessage(ApiResult.Error(503, "provider_unavailable", "")).contains("can't be reached")) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 22737609bc..c04ea09d14 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -319,9 +319,15 @@ val androidTvModule = module { // TLS-PSK socket lifecycle; the receiver is the transport-agnostic state // machine. A later step wires the UI to PairingReceiver.status. single { + val identityApi = get() org.siloserver.silo.common.pairing.PairingReceiver( authPort = org.siloserver.silo.common.pairing.RegistryPairingAuthPort(get(), get(), get(), get()), - deviceLogin = org.siloserver.silo.common.pairing.DeviceLoginRepositoryPort(get()), + // Its own device-login state machine: the sign-in screen runs its + // own device code while it advertises, and the two must not share + // one repository's state. + deviceLogin = org.siloserver.silo.common.pairing.DeviceLoginRepositoryPort( + org.siloserver.silo.repository.DeviceLoginRepository(get()), + ), identityProvider = { org.siloserver.silo.common.pairing.PairingDeviceIdentity( name = tvDeviceName(), @@ -329,28 +335,16 @@ val androidTvModule = module { .stable(androidContext()), ) }, - // Always `setup`: advertising only runs while the server-setup - // screen is showing, and Apple is authoritative for the wire — - // silo-apple's TVPairingAdvertiser hardcodes st=setup and its - // companion card FILTERS to state == .setup, so a registry-based - // `login` (always true after a sign-out, since the registry keeps - // entries) made the TV invisible to phones exactly when the user - // needed set-up-with-phone again. - receiverStateProvider = { org.siloserver.silo.pairing.PairingReceiverState.Setup }, + identityProbe = { url -> identityApi.probeIdentity(url) }, ) } + // The advertised state comes from the screen that starts it: server setup + // advertises st=setup, the sign-in screen st=login with its server's + // identity (srv) so only phones holding that server offer it. single { org.siloserver.silo.common.pairing.TvPairingAdvertiser( context = androidContext(), receiver = get(), - // Always `setup`: advertising only runs while the server-setup - // screen is showing, and Apple is authoritative for the wire — - // silo-apple's TVPairingAdvertiser hardcodes st=setup and its - // companion card FILTERS to state == .setup, so a registry-based - // `login` (always true after a sign-out, since the registry keeps - // entries) made the TV invisible to phones exactly when the user - // needed set-up-with-phone again. - receiverStateProvider = { org.siloserver.silo.pairing.PairingReceiverState.Setup }, ) } single { SiloCastNsdAdvertiser(androidContext()) } @@ -377,7 +371,21 @@ val androidTvModule = module { viewModel { TvServerSetupViewModel(get(), get()) } viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSetupViewModel(get()) } viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSignupViewModel(get()) } - viewModel { TvLoginViewModel(get(), get(), get()) } + viewModel { params -> + TvLoginViewModel( + authRepository = get(), + tokenManager = get(), + // Per-screen device-login state; see the PairingReceiver above. + deviceLogin = org.siloserver.silo.repository.DeviceLoginRepository(get()), + serverRegistry = get(), + serverIdentities = get(), + sessionExpired = params.getOrNull() ?: false, + externalSignIn = get(), + // st=login rollout gate (silo-apple PairingProtocol.advertisesSignInTVs): + // debug builds only until both phone apps that accept login TVs ship. + advertisesSignIn = BuildConfig.DEBUG, + ) + } viewModel { TvProfileSelectionViewModel(get(), get()) } viewModel { org.siloserver.silo.tv.ui.screens.profiles.TvCreateProfileViewModel(get()) } viewModel { params -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 69f2b7d921..35d8bf9384 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -541,7 +541,7 @@ fun TvAppNavigation( // preserved so they don't have to re-enter the URL). LaunchedEffect(Unit) { tokenManager.sessionExpired.collect { - navController.navigate(TvRoute.Login().route) { + navController.navigate(TvRoute.Login(sessionExpired = true).route) { // Clear the entire back stack so the user can't press Back // to return to a screen that has no credentials to render. popUpTo(0) { inclusive = true } @@ -678,12 +678,18 @@ fun TvAppNavigation( type = NavType.BoolType defaultValue = false }, + navArgument(TvRoute.Login.ARG_SESSION_EXPIRED) { + type = NavType.BoolType + defaultValue = false + }, ), ) { backStack -> val signupEnabled = backStack.arguments?.getBoolean(TvRoute.Login.ARG_SIGNUP_ENABLED) ?: false + val sessionExpired = backStack.arguments?.getBoolean(TvRoute.Login.ARG_SESSION_EXPIRED) ?: false TvSelectToShowImeHost { TvLoginScreen( signupEnabled = signupEnabled, + sessionExpired = sessionExpired, onCreateAccount = { navController.navigate(TvRoute.Signup.route) }, // Point this TV at a different server — drop Login so Back from // setup can't return to a credential form with no server bound. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt index b9823247cc..96df17c10c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt @@ -26,11 +26,16 @@ sealed class TvRoute(val route: String) { /** Invite-code signup, reachable from Login when the server allows signups. */ data object Signup : TvRoute("signup") - data class Login(val signupEnabled: Boolean = false) : - TvRoute("login?signupEnabled=$signupEnabled") { + /** + * [sessionExpired]: arrived because the session ended elsewhere (refresh + * failed); the screen explains it with a "You were signed out" banner. + */ + data class Login(val signupEnabled: Boolean = false, val sessionExpired: Boolean = false) : + TvRoute("login?signupEnabled=$signupEnabled&sessionExpired=$sessionExpired") { companion object { - const val ROUTE = "login?signupEnabled={signupEnabled}" + const val ROUTE = "login?signupEnabled={signupEnabled}&sessionExpired={sessionExpired}" const val ARG_SIGNUP_ENABLED = "signupEnabled" + const val ARG_SESSION_EXPIRED = "sessionExpired" } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/QrCodePanel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/QrCodePanel.kt index eb74e95ab2..247f3a3532 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/QrCodePanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/QrCodePanel.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.zxing.BarcodeFormat @@ -33,6 +35,8 @@ fun QrCodePanel( foreground: Color = Color.Black, background: Color = Color.White, modifier: Modifier = Modifier, + /** Spoken label; the QR is otherwise invisible to TalkBack. */ + description: String? = null, ) { val matrix = remember(content) { val writer = QRCodeWriter() @@ -47,7 +51,14 @@ fun QrCodePanel( Box( modifier = modifier .size(size) - .background(background), + .background(background) + .then( + if (description != null) { + Modifier.semantics { contentDescription = description } + } else { + Modifier + }, + ), contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt index 104d302b0e..4d67ae425e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.screens.auth +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -22,9 +23,9 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable @@ -36,57 +37,84 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts -import org.siloserver.silo.tv.ui.focus.TvFocusLog +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.InputMode import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.VerbatimTtsAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily 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.text.withAnnotation +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.siloserver.silo.repository.DeviceLoginRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.common.pairing.PairingReceiver +import org.siloserver.silo.common.pairing.PairingReceiverStatus +import org.siloserver.silo.common.pairing.TvPairingAdvertiser +import org.siloserver.silo.model.auth.DeviceCodeFormat import org.siloserver.silo.tv.R import org.siloserver.silo.tv.ui.components.AuroraEyebrow import org.siloserver.silo.tv.ui.components.AuroraGhostButton import org.siloserver.silo.tv.ui.components.AuroraJourneyProgress import org.siloserver.silo.tv.ui.components.AuroraPrimaryButton import org.siloserver.silo.tv.ui.components.AuroraStepRow -import org.siloserver.silo.tv.ui.components.auroraGlass -import org.siloserver.silo.tv.ui.components.auroraPanel import org.siloserver.silo.tv.ui.components.TvAuroraBackdrop import org.siloserver.silo.tv.ui.components.TvAuroraVariant -import org.siloserver.silo.tv.ui.components.TvHeroActionPill -import org.siloserver.silo.tv.ui.components.TvPillVariant +import org.siloserver.silo.tv.ui.components.TvAuthFormDefaults +import org.siloserver.silo.tv.ui.components.auroraGlass +import org.siloserver.silo.tv.ui.components.auroraPanel import org.siloserver.silo.tv.ui.components.rememberTvImeAwareFormScrollState import org.siloserver.silo.tv.ui.components.tvImeAwareFieldContext -import org.siloserver.silo.tv.ui.components.tvShowImeOnSelect -import org.siloserver.silo.tv.ui.components.TvAuthFormDefaults import org.siloserver.silo.tv.ui.components.tvOutlinedTextFieldColors +import org.siloserver.silo.tv.ui.components.tvShowImeOnSelect +import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.siloserver.silo.tv.ui.focus.TvFocusLog +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved import org.siloserver.silo.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel /** - * Sign-in form — compact, TOP-anchored so the username/password fields stay - * above the on-screen IME. See [TvServerSetupScreen] for the rationale: on - * Android TV the soft keyboard eats the lower half of the viewport, so any - * centered form hides its inputs. + * TV sign-in: every way in is visible at once — scan the QR, type + * `/activate` and the code, or open Silo on a nearby phone — with the + * password one click away. One copy deck with Apple TV (server spec "TV + * device sign-in UX"). The code renews itself while the screen is visible; + * there is no countdown. + * + * TOP-anchored so the username/password fields stay above the on-screen IME: + * on Android TV the soft keyboard eats the lower half of the viewport. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -95,98 +123,194 @@ fun TvLoginScreen( onCreateAccount: () -> Unit = {}, onChangeServer: () -> Unit = {}, signupEnabled: Boolean = false, - viewModel: TvLoginViewModel = koinViewModel(), + sessionExpired: Boolean = false, + viewModel: TvLoginViewModel = koinViewModel(parameters = { parametersOf(sessionExpired) }), + pairingReceiver: PairingReceiver = koinInject(), + pairingAdvertiser: TvPairingAdvertiser = koinInject(), ) { val state by viewModel.uiState.collectAsState() - val deviceState by viewModel.deviceLoginState.collectAsState() + val device by viewModel.deviceSignIn.collectAsState() + val advertisement by viewModel.pairingAdvertisement.collectAsState() + val pairingStatus by pairingReceiver.status.collectAsState() val usernameFocus = remember { FocusRequester() } val passwordFocus = remember { FocusRequester() } val usePasswordFocus = remember { FocusRequester() } + val changeServerFocus = remember { FocusRequester() } + val actionFocus = remember { FocusRequester() } val signInFocus = remember { FocusRequester() } val createAccountFocus = remember { FocusRequester() } val backToPhoneFocus = remember { FocusRequester() } - val changeServerFocus = remember { FocusRequester() } + val formChangeServerFocus = remember { FocusRequester() } val formScrollState = rememberTvImeAwareFormScrollState() - // Phone-first IA (mirrors tvOS TVLoginView): the QR device-login leads, and - // the username/password form is one focus-step away behind "Use a password - // instead". Nothing to type on the remote unless the viewer opts in. + // Phone-first IA (mirrors tvOS TVLoginView): the device code leads, and the + // username/password form is one focus-step away. A server without device + // sign-in goes straight to the form. var showPasswordForm by remember { mutableStateOf(false) } + // An OIDC-only server (local passwords off, no directory) takes no + // password from this TV: device sign-in alone. + val passwordFormVisible = (showPasswordForm || device.passwordOnly) && state.passwordAvailable + // Password sign-in turned off while the form was open (the options are + // re-read on return to this screen): the TV falls back to the code, and + // the form doesn't come back by itself if passwords are turned on again. + LaunchedEffect(state.passwordAvailable) { + if (!state.passwordAvailable) showPasswordForm = false + } + + // Stop polling in the background (or under the screensaver); poll at once, + // renewing the code if it expired, when the screen is visible again. + LifecycleStartEffect(viewModel) { + viewModel.onStart() + onStopOrDispose { viewModel.onStop() } + } + + // A signed-out TV advertises st=login with its server's identity so a + // nearby phone that holds the same server can sign it in (#419). Only + // while the screen is visible, like polling: nobody can answer the + // consent prompt of a TV in the background. + LifecycleStartEffect(advertisement) { + val token = advertisement?.let(pairingAdvertiser::start) + onStopOrDispose { token?.let(pairingAdvertiser::stop) } + } + val isActivePairing = pairingStatus.isActivePairing + + // The sign-in screen and a nearby phone's session can both finish the + // same sign-in; route on once. + var routed by remember { mutableStateOf(false) } + val routeOnce = { + if (!routed) { + routed = true + onLoginSuccess() + } + } + LaunchedEffect(pairingStatus) { + if (pairingStatus is PairingReceiverStatus.Completed) { + delay(1_800) + routeOnce() + } + } LaunchedEffect(state.loginSuccess) { if (state.loginSuccess) { + // A nearby phone that approved this code still has to hear the + // result and say done; leaving first closes its session, which it + // reports as a failure. Completed routes on by itself after its + // dwell. Bounded, so a phone that goes quiet can't hold the TV here. + val settled = withTimeoutOrNull(NEARBY_RESULT_WAIT_MS) { + pairingReceiver.status.first { + it !is PairingReceiverStatus.AwaitingApproval && it !is PairingReceiverStatus.SignedIn + } + } + // Consumed only now: it is this effect's key, so clearing it + // before the wait would cancel the wait and the fallback route. viewModel.onLoginSuccessConsumed() - onLoginSuccess() + if (settled !is PairingReceiverStatus.Completed) routeOnce() + } + } + + // "Use your phone instead" returns to a fresh code, not one that may have + // gone stale behind the form. + val backToPhone = { + showPasswordForm = false + viewModel.restartDeviceLogin() + } + // Back steps out of the password form and the nearby-phone panel instead + // of leaving the app. + BackHandler(enabled = passwordFormVisible && !device.passwordOnly && !isActivePairing, onBack = backToPhone) + BackHandler(enabled = isActivePairing, onBack = pairingReceiver::cancelActiveSession) + + // Focus moves to the state's action when the code needs the person + // ("Try again" / "Show a new code"), to "Change server" when only that + // helps, otherwise to the one local action, "Sign in with a password". + // "Can't reach" and "Too many requests" keep retrying by themselves, so + // their "Try again" doesn't take focus (Apple TV parity). + val focusTarget = when { + passwordFormVisible -> "username" + device.status.actionTakesFocus() -> "action" + device.status == TvSignInStatus.UpdateRequired -> "changeServer" + device.status == TvSignInStatus.Unreachable || device.status == TvSignInStatus.TooManyRequests -> null + state.passwordAvailable -> "usePassword" + else -> "changeServer" + } + // Which claim target holds focus. Checking "the screen has focus" is not + // enough: after a state change focus is still on the old button, and the + // claim must move it to the state's action. + var focusedKey by remember { mutableStateOf(null) } + val trackFocus: (String) -> Modifier = { key -> + Modifier.onFocusChanged { state -> + if (state.hasFocus) focusedKey = key else if (focusedKey == key) focusedKey = null } } - // Default focus follows the active surface: the password form focuses the - // username field; the phone-first surface focuses the "Use a password - // instead" affordance so the remote never lands on a non-actionable QR. - var loginSurfaceHasFocus by remember { mutableStateOf(false) } - // Snapshot-backed: recomposes (and re-keys the claim below) when the viewer - // switches between pointer and key input. val inputMode = LocalInputModeManager.current.inputMode - LaunchedEffect(showPasswordForm, inputMode) { - // Acquisition on both branches: the surface has just swapped, so - // nothing on it holds focus yet. A dropped claim on the phone-first - // branch strands the remote on a QR code that cannot be actioned. - // + LaunchedEffect(focusTarget, inputMode, isActivePairing) { // Touch/mouse exception: nothing is auto-focused for pointer users // (product call 2026-08-14) — a programmatic claim on a text field in - // touch mode pops the IME despite showKeyboardOnFocus=false, and - // buttons refuse focus in touch mode anyway, so the claim would only - // burn its retry budget. Keying this effect on the input mode re-runs - // the claim the moment a key press flips the mode back, so the D-pad - // always has somewhere to land. - if (inputMode == InputMode.Touch) { - TvFocusLog.d { "login: claim skipped (touch mode, form=$showPasswordForm)" } + // touch mode pops the IME, and buttons refuse focus in touch mode. + if (inputMode == InputMode.Touch || isActivePairing || focusTarget == null) { + TvFocusLog.d { "login: claim skipped (touch=${inputMode == InputMode.Touch}, pairing=$isActivePairing, target=$focusTarget)" } return@LaunchedEffect } - val target = if (showPasswordForm) usernameFocus else usePasswordFocus - TvFocusLog.d { - "login: claiming ${if (showPasswordForm) "username field" else "'use password' button"} (mode=$inputMode)" + val target = when (focusTarget) { + "username" -> usernameFocus + "action" -> actionFocus + "changeServer" -> changeServerFocus + else -> usePasswordFocus } + TvFocusLog.d { "login: claiming $focusTarget (mode=$inputMode)" } val result = requestFocusUntilObserved( maxAttempts = TvContentInitialFocusMaxAttempts, awaitAttempt = { withFrameNanos { } }, requestFocus = target::requestFocus, - isFocused = { loginSurfaceHasFocus }, + isFocused = { focusedKey == focusTarget }, ) TvFocusLog.d { "login: claim result=$result" } - // The form arrives quiet — the viewer summons the keyboard with SELECT - // or a click. The legacy text field pops the IME on a focus arrival no - // matter what showKeyboardOnFocus says (unsupported on this overload, - // see tvShowImeOnSelect), so suppression lives in that modifier, which - // every field in this flow carries. No screen-level hide needed here. } + val changeServer = { + viewModel.onChangeServer() + onChangeServer() + } + val serverName = state.serverName ?: state.serverHost.orEmpty() + Box( modifier = Modifier .fillMaxSize() - // Either branch's target lives under this root, so "focus is on the - // login surface" is the criterion both claims are protecting. - .onFocusChanged { loginSurfaceHasFocus = it.hasFocus } .imePadding(), ) { TvAuroraBackdrop(variant = TvAuroraVariant.SignIn) + + if (isActivePairing) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(Spacing.xl), + contentAlignment = Alignment.Center, + ) { + ActivePairingPanel( + status = pairingStatus, + onCancel = pairingReceiver::cancelActiveSession, + onContinue = routeOnce, + onAllow = pairingReceiver::allowPendingServer, + onDeny = pairingReceiver::denyPendingServer, + onUseAlternate = pairingReceiver::useAlternateAddress, + onRetryAddress = pairingReceiver::retryPushedAddress, + signIn = true, + modifier = Modifier.widthIn(max = 440.dp), + ) + } + return@Box + } + Column( - horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .align(Alignment.TopCenter) .fillMaxSize() // The scroll is an IME/odd-surface safety valve only. At the // reference TV surface (1920x1080 @ 320dpi = 960x540dp) BOTH // branches must measure shorter than the viewport, because a - // scrolled column takes the brand mark and the SERVER/ACCOUNT/ - // PROFILE step chrome off the top with no D-pad way back — the - // header is not focusable, so nothing can scroll it into view. - // The credential branch is budgeted for this in - // [CredentialFormCard]; keep it that way. + // scrolled column takes the header chrome off the top with no + // D-pad way back. .verticalScroll(formScrollState) - // Vertical padding sits at the overscan floor (Spacing - // .safeAreaVertical) on both branches — dipping under it to buy - // room for a too-tall form just trades a scroll for a bezel - // clip on real hardware. .padding( top = Spacing.safeAreaVertical, bottom = Spacing.safeAreaVertical, @@ -206,102 +330,326 @@ fun TvLoginScreen( ) } - Spacer(modifier = Modifier.height(Spacing.sm)) + if (state.sessionExpired && serverName.isNotBlank()) { + Spacer(modifier = Modifier.height(Spacing.sm)) + SessionExpiredBanner(serverName) + } + // A password refused because password sign-in is off closes the + // form, the only place its error shows: say it here instead. + if (state.passwordTurnedOff) { + Spacer(modifier = Modifier.height(Spacing.sm)) + StatusBanner(stringResource(R.string.tv_signin_error_local_login_disabled)) + } - AuroraEyebrow(text = "Account") - Spacer(modifier = Modifier.height(Spacing.md)) + Spacer(modifier = Modifier.height(Spacing.sm)) - if (showPasswordForm) { + if (passwordFormVisible) { + AuroraEyebrow( + text = stringResource(R.string.tv_signin_eyebrow), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(Spacing.md)) CredentialFormCard( state = state, + passwordOnly = device.passwordOnly, usernameFocus = usernameFocus, + usernameFocusTracking = trackFocus("username"), passwordFocus = passwordFocus, signInFocus = signInFocus, createAccountFocus = createAccountFocus, backToPhoneFocus = backToPhoneFocus, - changeServerFocus = changeServerFocus, + changeServerFocus = formChangeServerFocus, onUsernameChanged = viewModel::onUsernameChanged, onPasswordChanged = viewModel::onPasswordChanged, onLoginClick = viewModel::onLoginClick, signupEnabled = signupEnabled, onCreateAccount = onCreateAccount, - onBackToPhone = { showPasswordForm = false }, - onChangeServer = onChangeServer, - // Wider than the old 400dp: the 960dp-wide surface has - // horizontal room to spare, and spending it lets the three - // secondary actions share one row instead of stacking - // three deep down the 540dp axis. - modifier = Modifier.width(520.dp), + onBackToPhone = backToPhone, + onChangeServer = changeServer, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(520.dp), ) } else { Row( - horizontalArrangement = Arrangement.spacedBy(44.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .widthIn(max = 840.dp) - .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(40.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth(), ) { - PhoneSignInHero( - state = deviceState, - modifier = Modifier.width(430.dp), - ) - - QrLoginCard( - state = deviceState, - onRetry = viewModel::restartDeviceLogin, - onUsePassword = { showPasswordForm = true }, - onChangeServer = onChangeServer, + SignInHero( + serverName = serverName, + activateText = device.code?.activateText ?: state.serverActivateText.orEmpty(), usePasswordFocus = usePasswordFocus, - modifier = Modifier.width(320.dp), + changeServerFocus = changeServerFocus, + usePasswordTracking = trackFocus("usePassword"), + changeServerTracking = trackFocus("changeServer"), + onUsePassword = { showPasswordForm = true }, + onChangeServer = changeServer, + showPasswordAction = state.passwordAvailable, + modifier = Modifier.weight(1f), + ) + DeviceCodePanel( + device = device, + serverHost = state.serverHost.orEmpty(), + actionFocus = actionFocus, + actionTracking = trackFocus("action"), + onAction = viewModel::restartDeviceLogin, ) } } } - } } +/** The "Try again" / "Show a new code" action a status offers, if any. */ +private fun TvSignInStatus.action(): Int? = when (this) { + TvSignInStatus.CouldntFinish, + TvSignInStatus.Failed, + TvSignInStatus.Unreachable, + TvSignInStatus.TooManyRequests, + -> R.string.tv_signin_action_try_again + TvSignInStatus.Denied, + TvSignInStatus.Paused, + -> R.string.tv_signin_action_new_code + else -> null +} + +/** States that wait for the person, so their action takes focus. */ +private fun TvSignInStatus.actionTakesFocus(): Boolean = when (this) { + TvSignInStatus.CouldntFinish, + TvSignInStatus.Denied, + TvSignInStatus.Paused, + TvSignInStatus.Failed, + -> true + else -> false +} + +@Composable +private fun SessionExpiredBanner(serverName: String) = + StatusBanner(stringResource(R.string.tv_signin_session_expired, serverName)) + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun StatusBanner(text: String) { + Text( + text = text, + style = TvLoginTextStyles.Body, + color = Color.White, + modifier = Modifier + .fillMaxWidth() + .auroraGlass(12.dp) + .padding(horizontal = Spacing.md, vertical = Spacing.sm) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) +} + /** - * Left-hand hero for the phone-first sign-in: eyebrow already sits above; this - * is the headline, the lede, the three numbered steps, and a live "waiting" - * status while the device-login session is pending. Mirrors tvOS - * `TVLoginView.heroColumn`. + * Left column: title, the three ways in, the nearby-phone hint, and the two + * local actions. Mirrors tvOS `TVLoginView.heroColumn`. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun PhoneSignInHero( - state: DeviceLoginRepository.DeviceLoginState, +private fun SignInHero( + serverName: String, + activateText: String, + usePasswordFocus: FocusRequester, + changeServerFocus: FocusRequester, + usePasswordTracking: Modifier, + changeServerTracking: Modifier, + onUsePassword: () -> Unit, + onChangeServer: () -> Unit, modifier: Modifier = Modifier, + showPasswordAction: Boolean = true, ) { Column( - verticalArrangement = Arrangement.spacedBy(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = modifier, ) { + AuroraEyebrow(text = stringResource(R.string.tv_signin_eyebrow)) Text( - text = "Scan. Confirm.\nStart watching.", + text = stringResource(R.string.tv_signin_title, serverName), style = TvLoginTextStyles.Hero, color = MaterialTheme.colorScheme.onBackground, ) + AuroraStepRow(number = 1, text = stringResource(R.string.tv_signin_step_scan)) + AuroraStepRow(number = 2, text = stringResource(R.string.tv_signin_step_url, activateText)) + AuroraStepRow(number = 3, text = stringResource(R.string.tv_signin_step_approve)) Text( - text = "Open your phone's Camera and point it at the code. " + - "You won't need to type a password on your TV.", + text = stringResource(R.string.tv_signin_nearby_hint), style = TvLoginTextStyles.Body, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(Spacing.sm)) - AuroraStepRow(number = 1, text = "Scan with your phone's camera") - AuroraStepRow(number = 2, text = "Confirm the matching number") - AuroraStepRow(number = 3, text = "Approve on your phone — you're in") + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + if (showPasswordAction) { + AuroraGhostButton( + label = stringResource(R.string.tv_signin_action_password), + onClick = onUsePassword, + fontSize = 17.sp, + horizontalPadding = 16.dp, + verticalPadding = 8.dp, + modifier = Modifier + .then(usePasswordTracking) + .focusRequester(usePasswordFocus) + .focusProperties { right = changeServerFocus }, + ) + } + AuroraGhostButton( + label = stringResource(R.string.tv_signin_action_change_server), + onClick = onChangeServer, + fontSize = 17.sp, + horizontalPadding = 16.dp, + verticalPadding = 8.dp, + modifier = Modifier + .then(changeServerTracking) + .focusRequester(changeServerFocus) + .focusProperties { if (showPasswordAction) left = usePasswordFocus }, + ) + } + } +} - if (state is DeviceLoginRepository.DeviceLoginState.Awaiting) { - Spacer(Modifier.height(Spacing.sm)) +/** + * Right panel: the QR (at least a third of the screen height, dark on white, + * 4-module quiet zone), the code in large monospace grouped 4+4, the typed-URL + * form, and a polite live status line. No countdown: codes renew in place. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun DeviceCodePanel( + device: TvDeviceSignInUi, + serverHost: String, + actionFocus: FocusRequester, + actionTracking: Modifier, + onAction: () -> Unit, +) { + val qrSize = maxOf(MinQrSize, (LocalConfiguration.current.screenHeightDp / 3).dp) + val code = device.code + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + modifier = Modifier + .width(qrSize + 72.dp) + .auroraGlass(15.dp) + .padding(horizontal = 18.dp, vertical = 14.dp), + ) { + if (code != null) { + val qrHost = code.activateText.removeSuffix("/activate") + QrCodePanel( + content = code.qrContent, + size = qrSize, + description = stringResource(R.string.tv_signin_qr_description, qrHost, code.spokenCode), + ) + SignInCodeText( + code = code.userCode, + spokenCode = code.spokenCode, + style = TvSignInCodeStyle, + ) Text( - text = "Waiting for approval…", + text = code.activateText, style = TvLoginTextStyles.Body, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(qrSize) + .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(8.dp)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(8.dp)), + ) { + if (device.status == TvSignInStatus.GettingCode) { + CircularProgressIndicator( + color = Color.White.copy(alpha = 0.7f), + strokeWidth = 3.dp, + modifier = Modifier.size(36.dp), + ) + } + } + } + + Text( + text = device.statusText(serverHost), + style = TvLoginTextStyles.Status, + color = if (device.status.isProblem()) MaterialTheme.colorScheme.error else Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + + device.status.action()?.let { label -> + // Ghost style at a compact size so "Show a new code" fits the + // panel on one line; focus fills it like the other actions. + AuroraGhostButton( + label = stringResource(label), + onClick = onAction, + fontSize = 18.sp, + horizontalPadding = 14.dp, + verticalPadding = 8.dp, + modifier = Modifier + .fillMaxWidth() + .then(actionTracking) + .focusRequester(actionFocus), + ) + } + } +} + +private fun TvSignInStatus.isProblem(): Boolean = when (this) { + TvSignInStatus.CouldntFinish, + TvSignInStatus.Denied, + TvSignInStatus.Unreachable, + TvSignInStatus.TooManyRequests, + TvSignInStatus.Failed, + TvSignInStatus.UpdateRequired, + -> true + else -> false +} + +@Composable +private fun TvDeviceSignInUi.statusText(serverHost: String): String = when (status) { + TvSignInStatus.GettingCode -> stringResource(R.string.tv_signin_status_getting_code) + TvSignInStatus.Waiting -> stringResource(R.string.tv_signin_status_waiting) + TvSignInStatus.Opened -> stringResource(R.string.tv_signin_status_opened) + TvSignInStatus.NewCode -> stringResource(R.string.tv_signin_status_new_code) + TvSignInStatus.Unreachable -> stringResource(R.string.tv_signin_status_unreachable, serverHost) + TvSignInStatus.TooManyRequests -> stringResource(R.string.tv_signin_status_too_many) + TvSignInStatus.SignedIn -> accountName?.let { stringResource(R.string.tv_signin_status_signed_in_as, it) } + ?: stringResource(R.string.tv_signin_status_signed_in) + TvSignInStatus.CouldntFinish -> stringResource(R.string.tv_signin_status_couldnt_finish) + TvSignInStatus.Denied -> stringResource(R.string.tv_signin_status_denied) + TvSignInStatus.Paused -> stringResource(R.string.tv_signin_status_paused) + TvSignInStatus.Failed -> stringResource(R.string.tv_signin_status_failed) + TvSignInStatus.UpdateRequired -> stringResource(R.string.tv_signin_status_update_required) +} + +/** + * The sign-in code as ONE element TalkBack reads character by character + * (`TtsSpan.TYPE_VERBATIM`), so `4821 7730` is never read as a number. + */ +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalTextApi::class) +@Composable +internal fun SignInCodeText( + code: String, + spokenCode: String, + style: TextStyle, + modifier: Modifier = Modifier, + label: String? = null, +) { + val annotated = remember(code) { + buildAnnotatedString { + withAnnotation(VerbatimTtsAnnotation(DeviceCodeFormat.normalize(code))) { append(code) } } } + val description = label?.let { "$it, $spokenCode" } + Text( + text = annotated, + style = style, + color = Color.White, + textAlign = TextAlign.Center, + modifier = modifier.semantics(mergeDescendants = true) { + if (description != null) contentDescription = description + }, + ) } @OptIn(ExperimentalTvMaterial3Api::class) @@ -322,11 +670,49 @@ private fun BrandHeader() { } } -@OptIn(ExperimentalTvMaterial3Api::class) +/** + * [providerName] is the server's browser sign-in provider: an account that + * signs in with it has no password here, so a refused password points to the + * phone. [directoryName] names the directory (LDAP) the form also reaches. + */ +@Composable +private fun TvLoginError.message(providerName: String?, directoryName: String?): String = when (this) { + TvLoginError.UsernameRequired -> stringResource(R.string.tv_signin_error_username_required) + TvLoginError.PasswordRequired -> stringResource(R.string.tv_signin_error_password_required) + TvLoginError.InvalidCredentials -> if (providerName != null) { + stringResource(R.string.tv_signin_error_invalid_credentials_provider, providerName) + } else { + stringResource(R.string.tv_signin_error_invalid_credentials) + } + TvLoginError.EmailInUse -> stringResource( + R.string.tv_signin_error_email_in_use, + directoryName ?: stringResource(R.string.tv_signin_provider_fallback), + ) + TvLoginError.IdentityLinkedElsewhere -> stringResource( + R.string.tv_signin_error_identity_linked_elsewhere, + directoryName ?: stringResource(R.string.tv_signin_provider_fallback), + ) + TvLoginError.RateLimited -> stringResource(R.string.tv_signin_error_rate_limited) + TvLoginError.AccountRequired -> stringResource(R.string.tv_signin_error_account_required) + TvLoginError.AccountDisabled -> stringResource(R.string.tv_signin_error_account_disabled) + TvLoginError.Network -> stringResource(R.string.tv_signin_error_network) + TvLoginError.LocalLoginDisabled -> stringResource(R.string.tv_signin_error_local_login_disabled) + TvLoginError.NotPermitted -> stringResource(R.string.tv_signin_error_not_permitted) + TvLoginError.PasswordExpired -> stringResource(R.string.tv_signin_error_password_expired) + TvLoginError.ProviderUnavailable -> stringResource(R.string.tv_signin_error_provider_unavailable) + TvLoginError.IdentityChanged -> stringResource(R.string.tv_signin_error_identity_changed) + TvLoginError.SaveFailed -> stringResource(R.string.tv_signin_error_save_failed) + TvLoginError.CleanupIncomplete -> stringResource(R.string.tv_signin_error_cleanup_incomplete) + is TvLoginError.Server -> message ?: stringResource(R.string.tv_signin_error_login_failed) +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable private fun CredentialFormCard( state: TvLoginUiState, + passwordOnly: Boolean, usernameFocus: FocusRequester, + usernameFocusTracking: Modifier, passwordFocus: FocusRequester, signInFocus: FocusRequester, createAccountFocus: FocusRequester, @@ -345,33 +731,32 @@ private fun CredentialFormCard( // Height budget, not taste: this card plus the screen chrome above it has // to measure under 540dp (the 1920x1080 @ 320dpi TV surface) with the 24dp // overscan inset intact, or the root Column starts scrolling and the header - // chrome leaves the screen unreachably. Current budget with the safe area, - // brand row, eyebrow and this card is ~462dp. Before you add a row here or - // relax a gap, spend that ~78dp of headroom knowingly. + // chrome leaves the screen unreachably. Column( verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = modifier .auroraPanel(20.dp) .padding(horizontal = 24.dp, vertical = 14.dp), ) { - // Title only — the "ACCOUNT" eyebrow above the card already carries the - // step context, so the explanatory subtitle was a line of height the - // 540dp budget could not afford. Text( - text = "Sign in", + text = stringResource(R.string.tv_signin_form_title), style = TvLoginTextStyles.Title, color = MaterialTheme.colorScheme.onBackground, ) + if (passwordOnly) { + Text( + text = stringResource(R.string.tv_signin_password_only), + style = TvLoginTextStyles.Body, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - // Username — a mono uppercase caption labels each field, matching the - // server-setup card; the Material floating label is dropped so nothing - // floats oversized in the border notch. Column( verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier.tvImeAwareFieldContext(), ) { Text( - text = "USERNAME", + text = stringResource(R.string.tv_signin_form_username), style = TvLoginTextStyles.InputLabel, color = Color.White.copy(alpha = 0.52f), ) @@ -389,6 +774,8 @@ private fun CredentialFormCard( modifier = Modifier .fillMaxWidth() .height(TvAuthFormDefaults.FieldHeight) + .semantics { contentType = ContentType.Username } + .then(usernameFocusTracking) .tvShowImeOnSelect() .focusRequester(usernameFocus), colors = tvOutlinedTextFieldColors(), @@ -400,108 +787,106 @@ private fun CredentialFormCard( modifier = Modifier.tvImeAwareFieldContext(), ) { Text( - text = "PASSWORD", + text = stringResource(R.string.tv_signin_form_password), style = TvLoginTextStyles.InputLabel, color = Color.White.copy(alpha = 0.52f), ) - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - OutlinedTextField( - value = state.password, - onValueChange = onPasswordChanged, - singleLine = true, - visualTransformation = if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton( - onClick = { passwordVisible = !passwordVisible }, - enabled = !state.isLoading, - ) { - Icon( - imageVector = if (passwordVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = if (passwordVisible) "Hide password" else "Show password", - ) + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChanged, + singleLine = true, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton( + onClick = { passwordVisible = !passwordVisible }, + enabled = !state.isLoading, + ) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = stringResource( + if (passwordVisible) R.string.tv_signin_form_hide_password else R.string.tv_signin_form_show_password, + ), + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + showKeyboardOnFocus = false, + ), + keyboardActions = KeyboardActions( + onDone = { + if (canSubmitTvCredentialLogin(state.username, state.password, state.isLoading)) { + onLoginClick() } }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onDone = { - if (canSubmitTvCredentialLogin(state.username, state.password, state.isLoading)) { - onLoginClick() - } - }, - ), - enabled = !state.isLoading, - textStyle = TvLoginTextStyles.Field, - modifier = Modifier - .weight(1f) - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(passwordFocus), - colors = tvOutlinedTextFieldColors(), - ) - } + ), + enabled = !state.isLoading, + textStyle = TvLoginTextStyles.Field, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .semantics { contentType = ContentType.Password } + .tvShowImeOnSelect() + .focusRequester(passwordFocus), + colors = tvOutlinedTextFieldColors(), + ) } - if (state.error != null) { + state.error?.let { error -> Text( - text = state.error!!, + text = error.message(state.providerName, state.directoryName), style = TvLoginTextStyles.Error, color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Signing in…" else "Sign In", - icon = Icons.AutoMirrored.Filled.Login, - onClick = onLoginClick, - focusRequester = signInFocus, - focusHalo = false, - filledAtRest = false, - neutralFocusFill = true, - enabled = !state.isLoading, - modifier = Modifier - .focusProperties { - // Explicit chain, so it must name every stop: skipping - // straight to "Back to phone sign-in" left Create - // Account unreachable by remote on signup-enabled - // servers (the intervening label Text is not focusable, - // so there is no default search to fall back on). - down = if (signupEnabled) createAccountFocus else backToPhoneFocus - } - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), + // An account that signs in with the server's provider has no password + // here (silo-apple parity). The 401 message says the same, so the hint + // steps aside while that message shows. + state.providerName?.takeIf { state.error != TvLoginError.InvalidCredentials }?.let { provider -> + Text( + text = stringResource(R.string.tv_signin_form_provider_hint, provider), + style = TvLoginTextStyles.Body, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Secondary actions on ONE row, not stacked. Three full-width ghost - // buttons cost ~120dp of the 540dp viewport; side by side they cost - // ~33dp, which is most of what buys this card its headroom. Create - // Account appears only when the server reports public signup is enabled - // (the ServerSetup probe forwards that flag through the Login route). - // - // Labels are sized to survive an equal-weight third of the 472dp card - // interior without wrapping — a wrapped label grows the row's height - // and puts the budget back over. "Phone sign-in" is the short form of - // "Back to phone sign-in" for that reason. + val firstSecondary = when { + signupEnabled -> createAccountFocus + !passwordOnly -> backToPhoneFocus + else -> changeServerFocus + } + AuroraPrimaryButton( + label = stringResource(if (state.isLoading) R.string.tv_signin_form_submitting else R.string.tv_signin_form_submit), + icon = Icons.AutoMirrored.Filled.Login, + onClick = onLoginClick, + focusRequester = signInFocus, + focusHalo = false, + filledAtRest = false, + neutralFocusFill = true, + enabled = !state.isLoading, + modifier = Modifier + // Explicit chain: the label Texts are not focusable, so there + // is no default search to fall back on. + .focusProperties { down = firstSecondary } + .fillMaxWidth() + .height(TvAuthFormDefaults.PrimaryButtonHeight), + ) + + // Secondary actions on ONE row, not stacked: three full-width ghost + // buttons would cost ~120dp of the 540dp viewport. Row( horizontalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.fillMaxWidth(), ) { if (signupEnabled) { AuroraGhostButton( - label = "Create Account", + label = stringResource(R.string.tv_signin_form_create_account), onClick = onCreateAccount, fontSize = TvLoginSecondaryActionFontSize, horizontalPadding = TvLoginSecondaryActionPadding, @@ -509,36 +894,32 @@ private fun CredentialFormCard( modifier = Modifier .focusRequester(createAccountFocus) .focusProperties { - // Explicit chain, matching the primary button's: - // the label Texts around these controls are not - // focusable, so there is no default search to fall - // back on if a link is left implicit. up = signInFocus - right = backToPhoneFocus + right = if (passwordOnly) changeServerFocus else backToPhoneFocus + } + .weight(1f), + ) + } + if (!passwordOnly) { + // Back to the device code (a fresh one if the old one lapsed). + AuroraGhostButton( + label = stringResource(R.string.tv_signin_form_use_phone), + onClick = onBackToPhone, + fontSize = TvLoginSecondaryActionFontSize, + horizontalPadding = TvLoginSecondaryActionPadding, + verticalPadding = 8.dp, + modifier = Modifier + .focusRequester(backToPhoneFocus) + .focusProperties { + up = signInFocus + if (signupEnabled) left = createAccountFocus + right = changeServerFocus } .weight(1f), ) } - // Return to the phone-first surface (the QR pairing remains live). - AuroraGhostButton( - label = "Phone sign-in", - onClick = onBackToPhone, - fontSize = TvLoginSecondaryActionFontSize, - horizontalPadding = TvLoginSecondaryActionPadding, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(backToPhoneFocus) - .focusProperties { - up = signInFocus - if (signupEnabled) left = createAccountFocus - right = changeServerFocus - } - .weight(1f), - ) - // Bail out to server setup to point this TV at a different server — - // mirrors tvOS TVLoginView. AuroraGhostButton( - label = "Change server", + label = stringResource(R.string.tv_signin_action_change_server), onClick = onChangeServer, fontSize = TvLoginSecondaryActionFontSize, horizontalPadding = TvLoginSecondaryActionPadding, @@ -547,7 +928,11 @@ private fun CredentialFormCard( .focusRequester(changeServerFocus) .focusProperties { up = signInFocus - left = backToPhoneFocus + left = when { + !passwordOnly -> backToPhoneFocus + signupEnabled -> createAccountFocus + else -> signInFocus + } } .weight(1f), ) @@ -555,6 +940,18 @@ private fun CredentialFormCard( } } +/** The sign-in code in large monospace, grouped 4+4; shared with the nearby-phone panel. */ +internal val TvSignInCodeStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, + letterSpacing = 2.sp, +) + +/** A QR at least a third of the 540dp reference surface. */ +private val MinQrSize: Dp = 180.dp + /** * Type and inset for the sign-in card's side-by-side secondary actions. Branch * -local on purpose: [TvAuthFormDefaults] is shared with server setup, sign-up @@ -585,6 +982,13 @@ private object TvLoginTextStyles { letterSpacing = 0.sp, ) + val Status = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ) + val Field = TextStyle( fontWeight = FontWeight.Normal, fontSize = 17.sp, @@ -595,7 +999,7 @@ private object TvLoginTextStyles { /** Mono uppercase caption that labels each input — mirrors server setup. */ val InputLabel = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 19.sp, @@ -608,214 +1012,7 @@ private object TvLoginTextStyles { lineHeight = 20.sp, letterSpacing = 0.sp, ) - - val Button = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) } -/** - * Live QR pane bound to the device-login state machine. Renders one of five - * branches based on [state]: - * - * - Idle / Initiating → spinner copy + empty 320dp box (matches the QR's - * final footprint so the layout doesn't reflow when the matrix lands). - * - Awaiting → the actual QR (encoded `verification_uri_complete`) plus - * the short `user_code` underneath as a typing fallback. - * - Approved → "Signed in!" — short-lived, the screen-level - * `LaunchedEffect(loginSuccess)` navigates away. - * - Failed → message + "Try again" pill that fires [onRetry]. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun QrLoginCard( - state: DeviceLoginRepository.DeviceLoginState, - onRetry: () -> Unit, - onUsePassword: () -> Unit, - onChangeServer: () -> Unit, - usePasswordFocus: FocusRequester, - modifier: Modifier = Modifier, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .auroraGlass(15.dp) - .padding(24.dp), - ) { - Text( - text = "Scan with Camera", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground, - ) - when (state) { - DeviceLoginRepository.DeviceLoginState.Idle, - DeviceLoginRepository.DeviceLoginState.Initiating -> { - Box( - modifier = Modifier - .size(150.dp) - .background( - Color.White.copy(alpha = 0.06f), - RoundedCornerShape(8.dp), - ) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.10f), - shape = RoundedCornerShape(8.dp), - ), - ) - Text( - text = "Loading pairing code…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - is DeviceLoginRepository.DeviceLoginState.Awaiting -> { - QrCodePanel( - content = state.session.verificationUriComplete, - size = 150.dp, - ) - MatchCodeTiles(code = state.session.matchCode) - } - is DeviceLoginRepository.DeviceLoginState.Approved -> { - Text( - text = "Signed in!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - is DeviceLoginRepository.DeviceLoginState.Failed -> { - Text( - text = state.message ?: "Sign-in failed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - TvHeroActionPill( - label = "Try again", - icon = Icons.Default.Refresh, - variant = TvPillVariant.Hollow, - onClick = onRetry, - ) - } - } - - Box( - modifier = Modifier - .width(150.dp) - .height(1.dp) - .background(Color.White.copy(alpha = 0.10f)), - ) - - // Compact 18sp spec matching the password card's stacked buttons — - // at the default 22sp the longer label wraps and the card outgrows - // the 540dp viewport (see the screen-root padding note). - AuroraGhostButton( - label = "Sign in with a password", - onClick = onUsePassword, - fontSize = 18.sp, - horizontalPadding = 18.dp, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(usePasswordFocus) - .fillMaxWidth(), - ) - AuroraGhostButton( - label = "Use another server", - onClick = onChangeServer, - fontSize = 18.sp, - horizontalPadding = 18.dp, - verticalPadding = 8.dp, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -/** - * Match-code confirmation tiles — "CONFIRM THIS CODE" over the server-issued - * code, one monospaced tile per character. Mirrors tvOS - * `TVLoginView.matchCodeTiles`; word/number separators render as a thin dash. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun MatchCodeTiles(code: String, modifier: Modifier = Modifier) { - if (code.isBlank()) return - val tileWidthDp = matchCodeTileWidthDp(code) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = modifier, - ) { - Text( - text = "CONFIRM THIS CODE", - style = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - letterSpacing = 3.sp, - ), - color = Color.White.copy(alpha = 0.6f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(MATCH_CODE_TILE_GAP_DP.dp)) { - code.uppercase().forEach { ch -> - val isSep = ch == '-' || ch == ' ' - if (isSep) { - Text( - text = "–", - style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold), - color = Color.White.copy(alpha = 0.4f), - modifier = Modifier.width(MATCH_CODE_SEPARATOR_WIDTH_DP.dp), - ) - } else { - Box( - modifier = Modifier - .size(width = tileWidthDp.dp, height = 30.dp) - .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(6.dp)) - .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = ch.toString(), - style = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - ), - color = MaterialTheme.colorScheme.onBackground, - ) - } - } - } - } - } -} - -internal const val MATCH_CODE_TILE_WIDTH_DP = 24 -internal const val MATCH_CODE_SEPARATOR_WIDTH_DP = 10 -internal const val MATCH_CODE_TILE_GAP_DP = 2 -internal const val MATCH_CODE_CONTENT_WIDTH_DP = 252 - -internal fun matchCodeTileWidthDp(code: String): Int { - val tileCount = code.count { ch -> ch != '-' && ch != ' ' } - if (tileCount == 0) return MATCH_CODE_TILE_WIDTH_DP - - val separatorCount = code.length - tileCount - val gapWidth = (code.length - 1).coerceAtLeast(0) * MATCH_CODE_TILE_GAP_DP - val availableTileWidth = ( - MATCH_CODE_CONTENT_WIDTH_DP - - separatorCount * MATCH_CODE_SEPARATOR_WIDTH_DP - - gapWidth - ).coerceAtLeast(tileCount) - return minOf(MATCH_CODE_TILE_WIDTH_DP, availableTileWidth / tileCount) -} - -internal fun matchCodeRowWidthDp(code: String): Int { - if (code.isEmpty()) return 0 - val tileWidth = matchCodeTileWidthDp(code) - val characterWidth = code.sumOf { ch -> - if (ch == '-' || ch == ' ') MATCH_CODE_SEPARATOR_WIDTH_DP else tileWidth - } - return characterWidth + (code.length - 1) * MATCH_CODE_TILE_GAP_DP -} +/** How long a finished sign-in waits for a nearby phone's session to wrap up before routing on. */ +private const val NEARBY_RESULT_WAIT_MS = 5_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt index c357b73569..a909211653 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt @@ -1,70 +1,267 @@ package org.siloserver.silo.tv.ui.screens.auth +import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.siloserver.silo.common.pairing.NearbySignInCodeSource +import org.siloserver.silo.common.pairing.NearbySignInOutcome +import org.siloserver.silo.common.pairing.PairingAdvertisement +import org.siloserver.silo.pairing.PairingFailureCode +import org.siloserver.silo.model.auth.DeviceCodeFormat import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse import org.siloserver.silo.network.AccountSessionChangedException import org.siloserver.silo.network.AccountSessionExpectation import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.DeviceLoginClock import org.siloserver.silo.repository.DeviceLoginRepository +import org.siloserver.silo.repository.DeviceSignInMachine +import org.siloserver.silo.repository.DeviceSignInState +import org.siloserver.silo.repository.ExternalSignInRepository +import org.siloserver.silo.model.auth.PasswordLoginFailure +import org.siloserver.silo.repository.ServerIdentityRepository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** The form's error for a refused password sign-in. */ +internal fun tvLoginError(result: ApiResult.Error): TvLoginError = when { + // AuthRepository's code for a sign-in whose account or server changed underneath it. + result.error == "identity_changed" -> TvLoginError.IdentityChanged + else -> when (PasswordLoginFailure.of(result.code, result.error)) { + PasswordLoginFailure.InvalidCredentials -> TvLoginError.InvalidCredentials + PasswordLoginFailure.LocalLoginDisabled -> TvLoginError.LocalLoginDisabled + PasswordLoginFailure.NotPermitted -> TvLoginError.NotPermitted + PasswordLoginFailure.PasswordExpired -> TvLoginError.PasswordExpired + PasswordLoginFailure.AccountDisabled -> TvLoginError.AccountDisabled + PasswordLoginFailure.ProviderUnavailable -> TvLoginError.ProviderUnavailable + PasswordLoginFailure.EmailInUse -> TvLoginError.EmailInUse + PasswordLoginFailure.IdentityLinkedElsewhere -> TvLoginError.IdentityLinkedElsewhere + PasswordLoginFailure.RateLimited -> TvLoginError.RateLimited + PasswordLoginFailure.AccountRequired -> TvLoginError.AccountRequired + PasswordLoginFailure.Other -> TvLoginError.Server(result.message.takeIf { it.isNotBlank() }) + } +} + +/** Why the password form can't proceed; the screen maps each to `strings.xml`. */ +sealed interface TvLoginError { + data object UsernameRequired : TvLoginError + data object PasswordRequired : TvLoginError + data object InvalidCredentials : TvLoginError + data object AccountDisabled : TvLoginError + data object Network : TvLoginError + + /** Password sign-in is off on this server (it signs in through a provider). */ + data object LocalLoginDisabled : TvLoginError + + /** The directory accepted the password but doesn't admit this account. */ + data object NotPermitted : TvLoginError + + /** The directory says the password has expired. */ + data object PasswordExpired : TvLoginError + + /** The directory (LDAP) can't be reached. */ + data object ProviderUnavailable : TvLoginError + + /** A first directory sign-in found another account with the same email. */ + data object EmailInUse : TvLoginError + + /** The directory identity is linked to another account. */ + data object IdentityLinkedElsewhere : TvLoginError + data object RateLimited : TvLoginError + + /** No account matches and the server doesn't create one (`account_required`). */ + data object AccountRequired : TvLoginError + data object IdentityChanged : TvLoginError + data object SaveFailed : TvLoginError + data object CleanupIncomplete : TvLoginError + + /** The server's own explanation, or null for a generic failure. */ + data class Server(val message: String?) : TvLoginError +} data class TvLoginUiState( val username: String = "", val password: String = "", val isLoading: Boolean = false, - val error: String? = null, + val error: TvLoginError? = null, val loginSuccess: Boolean = false, + /** Display name of the server this TV signs in to, for the title and banner. */ + val serverName: String? = null, + /** Host shown in "Can't reach …" copy. */ + val serverHost: String? = null, + /** `/activate` in typed-URL form, shown before the first code arrives. */ + val serverActivateText: String? = null, + /** Arrived here because the session expired elsewhere: show the banner. */ + val sessionExpired: Boolean = false, + /** + * Whether the server takes a password from this TV: the local provider or + * a directory (LDAP), which the server reaches without a provider field. + * False only when discovery says no listed provider takes one (an OIDC + * server with local passwords off); the TV then offers device sign-in alone. + */ + val passwordAvailable: Boolean = true, + /** + * The server's browser sign-in provider (OIDC), when it lists one: its + * accounts have no password, so a refused password points to the phone. + */ + val providerName: String? = null, + /** The directory (LDAP) provider the password form also reaches, when enabled. */ + val directoryName: String? = null, +) { + /** + * A password was refused because password sign-in is off, and the form + * closed because of it: the screen says so outside the form, the only + * other place the refusal could show. + */ + val passwordTurnedOff: Boolean get() = error == TvLoginError.LocalLoginDisabled && !passwordAvailable +} + +/** What the device-code panel shows. */ +enum class TvSignInStatus { + GettingCode, + Waiting, + Opened, + NewCode, + Unreachable, + TooManyRequests, + SignedIn, + CouldntFinish, + Denied, + Paused, + Failed, + UpdateRequired, +} + +data class TvSignInCode( + /** Grouped for display: `4821 7730`. */ + val userCode: String, + /** `verification_uri_complete`, encoded into the QR. */ + val qrContent: String, + /** `silo.example.com/activate`, the typed-URL form. */ + val activateText: String, + /** One character per word, for the screen reader. */ + val spokenCode: String, +) + +data class TvDeviceSignInUi( + /** Null while there is no live code: the QR slot shows a spinner. */ + val code: TvSignInCode? = null, + val status: TvSignInStatus = TvSignInStatus.GettingCode, + /** Account shown in "Signed in as …". */ + val accountName: String? = null, + /** The server has no device sign-in: go straight to the password form. */ + val passwordOnly: Boolean = false, ) /** * Two parallel sign-in flows share this ViewModel: * - * 1. **Credential** — [AuthRepository.login] returns a [org.siloserver.silo.model.auth.User] - * and silently stores the tokens inside [TokenManager]. - * 2. **Device-login (QR)** — [DeviceLoginRepository] runs an OAuth-style poll - * loop; on [DeviceLoginRepository.DeviceLoginState.Approved] we lift the - * returned access/refresh tokens into [TokenManager] so the rest of the - * auth machinery (start-destination, profile gating) sees the same world. + * 1. **Credential** — [AuthRepository.loginForTokens] then an atomic + * [TokenManager.replaceAccountSession]. + * 2. **Device code (QR)** — a [DeviceSignInMachine] shows a code, renews it + * while the screen is visible, pauses after about an hour, and names + * failures. On approval we lift the tokens into [TokenManager]. * - * Both flows run in parallel; whichever completes first explicitly - * cancels the other so a late winner can't overwrite tokens just - * saved by the loser: - * - Device-login `Approved` → [handleDeviceLoginApproved] flips - * `loginSuccess`. The `deviceLoginJob` coroutine terminates - * naturally once [DeviceLoginRepository.begin] returns its terminal - * state, so no explicit cancel is needed on this side. - * - Credential success → [onLoginClick] cancels `deviceLoginJob` - * before flipping `loginSuccess`, so a late-arriving device-login - * `Approved` can't clobber the freshly-saved credential tokens. + * Whichever completes first wins ([tryCompleteAuth]); the other is cancelled + * so a late winner can't overwrite tokens just saved by the loser. An approval + * that arrives while a password save is in flight is held, and used if that + * save fails. * - * The screen-side `LaunchedEffect(loginSuccess)` then routes forward. + * Lifecycle: the screen calls [onStop] / [onStart] so polling stops in the + * background and resumes (polling at once, renewing if expired) on return. + * Stopping never interrupts a poll in flight or the approval handoff: the + * server hands out an approval's tokens once. An abandoned code is withdrawn + * on the server (when it supports cancel) on leaving, retrying, switching + * server or signing in with a password. + * + * In `login` pairing mode this is also the LAN receiver's + * [NearbySignInCodeSource]: a nearby phone approves the code shown here. */ class TvLoginViewModel( private val authRepository: AuthRepository, private val tokenManager: TokenManager, private val deviceLogin: DeviceLoginRepository, -) : ViewModel() { + private val serverRegistry: ServerRegistry? = null, + private val serverIdentities: ServerIdentityRepository? = null, + sessionExpired: Boolean = false, + /** Outlives the screen, for the best-effort cancel call on leaving. */ + private val backgroundScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + private val clock: DeviceLoginClock = DeviceLoginClock.System, + /** Provider discovery, for whether the password form is offered. */ + private val externalSignIn: ExternalSignInRepository? = null, + /** + * Rollout gate for sign-in TVs, mirroring silo-apple + * `PairingProtocol.advertisesSignInTVs`: whether this screen advertises + * `st=login`. Both phone apps must accept `login` TVs before either TV + * advertises one; a phone that predates them offers a sign-in TV the + * setup chooser and pushes servers without an identity, which this TV + * refuses. The app turns it on for debug builds only; turn it on for + * release once the iOS and Android phone apps that handle `login` ship. + */ + private val advertisesSignIn: Boolean = false, +) : ViewModel(), NearbySignInCodeSource { - private val _uiState = MutableStateFlow(TvLoginUiState()) + private val _uiState = MutableStateFlow(TvLoginUiState(sessionExpired = sessionExpired)) val uiState: StateFlow = _uiState.asStateFlow() - /** Surfaced to the screen so the QR pane can render the live session. */ - val deviceLoginState: StateFlow = deviceLogin.state + private val _deviceSignIn = MutableStateFlow(TvDeviceSignInUi()) + val deviceSignIn: StateFlow = _deviceSignIn.asStateFlow() + + /** + * What this screen advertises to nearby phones: `st=login` with the + * server's verified identity. Null until the identity is known, or when + * it can't be: phones match a signed-out TV only by identity. + * Always null while the [advertisesSignIn] rollout gate is off. + */ + private val _pairingAdvertisement = MutableStateFlow(null) + val pairingAdvertisement: StateFlow = _pairingAdvertisement.asStateFlow() + + private var machine: DeviceSignInMachine? = null + /** + * The sign-in context the machine's codes were started under. A resumed + * run polls only while it is unchanged: an approval for a code started + * before a sign-in, sign-out or server switch must not be saved over it. + */ + private var machineExpectation: AccountSessionExpectation? = null + private var machineObserver: Job? = null private var deviceLoginJob: Job? = null private var credentialLoginJob: Job? = null + private var newCodeJob: Job? = null + private var signInOptionsJob: Job? = null + + /** Saves an approval's tokens and routes on; [onStop] never cancels it. */ + private var approvalJob: Job? = null private var authCompleted = false + private var visible = true + + /** [onStop] ran since the last [onStart]: the next start re-reads the sign-in options. */ + private var lastDeviceCode: String? = null + + /** A device approval that arrived while a password save held the sign-in. */ + private var heldApproval: Pair? = null + + /** The code on screen now, for a nearby phone; null while none is live. */ + private val liveCode = MutableStateFlow(null) init { + loadServerContext() startDeviceLogin() } @@ -75,25 +272,30 @@ class TvLoginViewModel( if (_uiState.value.isLoading) return val s = _uiState.value if (s.username.isBlank()) { - _uiState.update { it.copy(error = "Username is required") } + _uiState.update { it.copy(error = TvLoginError.UsernameRequired) } return } if (s.password.isBlank()) { - _uiState.update { it.copy(error = "Password is required") } + _uiState.update { it.copy(error = TvLoginError.PasswordRequired) } return } _uiState.update { it.copy(isLoading = true, error = null) } credentialLoginJob?.cancel() credentialLoginJob = viewModelScope.launch { - val expected = tokenManager.captureAccountSessionExpectation() ?: return@launch + val expected = captureExpectation() + if (expected == null) { + _uiState.update { it.copy(isLoading = false, error = TvLoginError.IdentityChanged) } + return@launch + } when (val result = authRepository.loginForTokens(s.username, s.password, expected)) { is ApiResult.Success -> { if (!tryCompleteAuth()) { _uiState.update { it.copy(isLoading = false) } return@launch } - deviceLoginJob?.cancel() + // Polling carries on until the password session commits: if + // the save fails, the code on screen still works. try { tokenManager.replaceAccountSession( expectedIdentity = expected, @@ -105,12 +307,22 @@ class TvLoginViewModel( throw cancelled } catch (_: AccountSessionChangedException) { handleIdentityChanged() + resumeDeviceSignIn() return@launch } catch (_: Throwable) { handleSessionPersistenceFailure( accessToken = result.data.accessToken, refreshToken = result.data.refreshToken, ) + if (!authCompleted) { + // This screen's own save moved the generation without + // committing anything: the code on screen still belongs + // to this sign-in, so it carries on under the new one. + if (machineExpectation?.serverUrl == expected.serverUrl) machineExpectation = captureExpectation() + resumeDeviceSignIn() + } else { + passwordWon() + } return@launch } // Tokens are committed outside persistSession here, so refresh the @@ -118,8 +330,10 @@ class TvLoginViewModel( authRepository.onSessionCommitted() if (tokenManager.captureAccountSessionExpectation()?.generation != expected.generation + 1) { handleIdentityChanged() + resumeDeviceSignIn() return@launch } + passwordWon() _uiState.update { it.copy(isLoading = false, loginSuccess = true) } } is ApiResult.Error -> { @@ -127,76 +341,235 @@ class TvLoginViewModel( _uiState.update { it.copy(isLoading = false) } return@launch } - val msg = when (result.code) { - 401 -> "Invalid username or password" - 403 -> "Account is disabled" - else -> result.message.ifBlank { "Login failed" } - } - _uiState.update { it.copy(isLoading = false, error = msg) } + val error = tvLoginError(result) + _uiState.update { it.copy(isLoading = false, error = error) } + // The server refused passwords: re-read what it offers, so + // the form goes away once password sign-in is off. + if (error == TvLoginError.LocalLoginDisabled) refreshSignInOptions() } is ApiResult.NetworkError -> { if (authCompleted) { _uiState.update { it.copy(isLoading = false) } return@launch } - _uiState.update { - it.copy(isLoading = false, error = "Network error. Check your connection.") - } + _uiState.update { it.copy(isLoading = false, error = TvLoginError.Network) } } } } } /** - * Starts (or restarts) the device-login state machine. Cancels any prior - * job so a retry doesn't race two pollers against the same UI. On the - * terminal [DeviceLoginRepository.DeviceLoginState.Approved] state we - * persist tokens and flip `loginSuccess` — the screen then routes forward - * from its `LaunchedEffect`. + * The screen went to the background (or the screensaver started): stop + * polling, keep the code. A poll in flight finishes first and an approval + * being saved completes, since the server hands out its tokens once. + */ + fun onStop() { + visible = false + machine?.requestStop() + } + + /** + * The screen is visible again: poll now, renewing the code if it expired + * meanwhile, and re-read the server's sign-in options (an admin may have + * turned password sign-in off while the TV sat in the background). + */ + fun onStart() { + if (!visible && !authCompleted) refreshSignInOptions() + if (visible && deviceLoginJob?.isActive == true) return + visible = true + // A password sign-in already won (possibly while in the background): + // its code was withdrawn, and no new one may be asked for. + if (authCompleted) return + val current = machine + if (current == null) { + // Still reading the sign-in context: that run creates the machine + // and, now that the screen is visible again, starts it. + if (deviceLoginJob?.isActive == true) return + startDeviceLogin() + return + } + // Approved is settled too: the approval job finishes the handoff on its own. + if (current.isSettled) return + current.noteActivity() + runMachine(current) + } + + /** + * "Try again" / "Show a new code": withdraw the old code and start fresh. + * After a failed save the server already consumed the old request, so a + * new code is the only way on (#422). */ + fun restartDeviceLogin() { + if (authCompleted) return + // Also "Use your phone instead" from the password form: the options + // may have changed while the person was typing. + refreshSignInOptions() + abandonDeviceCode() + machine?.restart() + _deviceSignIn.value = TvDeviceSignInUi() + startDeviceLogin() + } + + /** "Change server": withdraw the code before this screen goes away. */ + fun onChangeServer() { + abandonDeviceCode() + } + private fun startDeviceLogin() { deviceLoginJob?.cancel() deviceLoginJob = viewModelScope.launch { - val expected = tokenManager.captureAccountSessionExpectation() ?: return@launch - deviceLogin.beginAt( - serverUrl = expected.serverUrl, - deviceName = android.os.Build.MODEL, - // Same spelling as the X-Silo-Device-Platform header this app - // sends, so one device reports one platform string everywhere. - devicePlatform = "android-tv", - ) - val terminal = deviceLogin.state.value - if (terminal is DeviceLoginRepository.DeviceLoginState.Approved) { - handleDeviceLoginApproved(terminal.response, expected) + val expected = captureExpectation() + if (expected == null) { + // No sign-in context to bind the approval to (#421): say so and + // offer "Try again" rather than spin on "Getting a code" forever. + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.Failed) + return@launch } + val current = machine?.takeIf { it.serverUrl == expected.serverUrl } + ?: DeviceSignInMachine( + repository = deviceLogin, + serverUrl = expected.serverUrl, + deviceName = deviceName(), + // Same spelling as the X-Silo-Device-Platform header this app + // sends, so one device reports one platform string everywhere. + devicePlatform = "android-tv", + clock = clock, + ).also { created -> + machine = created + observe(created) + } + machineExpectation = expected + // Stopped before the machine existed (onStop had nothing to stop): + // keep it idle until onStart runs it. + if (!visible) return@launch + runUntilSettled(current, expected) } } - fun restartDeviceLogin() { - deviceLogin.reset() - startDeviceLogin() + private fun runMachine(current: DeviceSignInMachine) { + // A run still finishing its last call after onStop is let finish (its + // answer may carry the tokens), then this run picks up from there. + // Cancelling this run (restart, change server, a password win, leaving) + // cancels the one it waits for too, so no older run outlives it. + val previous = deviceLoginJob + val next = viewModelScope.launch { + previous?.join() + if (current.isSettled) return@launch + val expected = captureExpectation() + val bound = machineExpectation + if (expected == null || expected.serverUrl != current.serverUrl || bound?.isSameSession(expected) != true) { + // "Try again" withdraws the code and starts one under the current context. + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.Failed) + return@launch + } + // Stopped again while waiting (onStop, onStart, onStop during one + // call): that onStop's requestStop landed on the previous run, not + // on this one, so check here, with no suspension before run(). + if (!visible) return@launch + runUntilSettled(current, expected) + } + next.invokeOnCompletion { cause -> if (cause is CancellationException) previous?.cancel() } + deviceLoginJob = next + } + + private suspend fun runUntilSettled(current: DeviceSignInMachine, expected: AccountSessionExpectation) { + current.run() + val terminal = current.state.value + if (terminal is DeviceSignInState.Approved) launchApproval(terminal.response, expected) + } + + /** The approval handoff runs in its own job so leaving the foreground can't cut it short. */ + private fun launchApproval(response: DeviceLoginPollResponse, expected: AccountSessionExpectation) { + if (approvalJob?.isActive == true) return + approvalJob = viewModelScope.launch { handleDeviceLoginApproved(response, expected) } + } + + private fun observe(current: DeviceSignInMachine) { + machineObserver?.cancel() + machineObserver = viewModelScope.launch { + current.state.collect { state -> publish(state) } + } + } + + private fun publish(state: DeviceSignInState) { + when (state) { + is DeviceSignInState.ShowingCode -> liveCode.value = state.session + is DeviceSignInState.Unreachable -> liveCode.value = state.session + is DeviceSignInState.TooManyRequests -> liveCode.value = state.session + DeviceSignInState.GettingCode -> liveCode.value = null + // An approval keeps its code until the handoff says how it ended. + else -> Unit + } + val previous = _deviceSignIn.value + // The approval is handled (and its outcome shown) by + // handleDeviceLoginApproved; never claim "Signed in" before the + // session is actually saved (#422). + if (state is DeviceSignInState.Approved) return + if (previous.status == TvSignInStatus.SignedIn || previous.status == TvSignInStatus.CouldntFinish) return + _deviceSignIn.value = when (state) { + DeviceSignInState.GettingCode -> TvDeviceSignInUi(status = TvSignInStatus.GettingCode) + is DeviceSignInState.ShowingCode -> { + val renewedNow = state.renewed && lastDeviceCode != null && lastDeviceCode != state.session.deviceCode + lastDeviceCode = state.session.deviceCode + if (renewedNow) announceNewCode() + val status = when { + state.opened -> TvSignInStatus.Opened + renewedNow || (previous.status == TvSignInStatus.NewCode && newCodeJob?.isActive == true) -> + TvSignInStatus.NewCode + else -> TvSignInStatus.Waiting + } + TvDeviceSignInUi(code = state.session.display(), status = status) + } + DeviceSignInState.Denied -> TvDeviceSignInUi(status = TvSignInStatus.Denied) + DeviceSignInState.Paused -> TvDeviceSignInUi(status = TvSignInStatus.Paused) + is DeviceSignInState.Unreachable -> + TvDeviceSignInUi(code = state.session?.display(), status = TvSignInStatus.Unreachable) + is DeviceSignInState.TooManyRequests -> + TvDeviceSignInUi(code = state.session?.display(), status = TvSignInStatus.TooManyRequests) + DeviceSignInState.NoDeviceSignIn -> TvDeviceSignInUi(status = TvSignInStatus.Failed, passwordOnly = true) + DeviceSignInState.UpdateRequired -> TvDeviceSignInUi(status = TvSignInStatus.UpdateRequired) + DeviceSignInState.Failed -> TvDeviceSignInUi(status = TvSignInStatus.Failed) + is DeviceSignInState.Approved -> previous + } + } + + /** "New code" is a brief, polite announcement, then the status reads "Waiting" again. */ + private fun announceNewCode() { + newCodeJob?.cancel() + newCodeJob = viewModelScope.launch { + delay(NEW_CODE_ANNOUNCEMENT_MS) + _deviceSignIn.update { ui -> + if (ui.status == TvSignInStatus.NewCode) ui.copy(status = TvSignInStatus.Waiting) else ui + } + } } /** - * Mirrors [AuthRepository.login]'s post-API token-save: the repository - * normally calls [TokenManager.saveTokens] for the caller, but the - * device-login flow returns its tokens directly to the repository's - * StateFlow instead — so we lift them into [TokenManager] here so - * everything downstream (MainTvActivity.resolveStartDestination, - * authenticated API calls) sees the same world as a credential login. + * Lift the approved tokens into [TokenManager] so everything downstream + * (start destination, authenticated calls) sees the same world as a + * credential login. On failure the panel says so with "Try again" — never + * "Signed in" (#422). */ private suspend fun handleDeviceLoginApproved(response: DeviceLoginPollResponse, expected: AccountSessionExpectation) { val accessToken = response.accessToken val refreshToken = response.refreshToken - // Repository already guards against null tokens (Failed.MissingTokens), - // but be defensive — we should never silently flip loginSuccess without - // tokens actually landing in storage. if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { - _uiState.update { it.copy(isLoading = false) } + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } + val current = runCatching { tokenManager.captureAccountSessionExpectation() }.getOrNull() if (!tryCompleteAuth()) { - _uiState.update { it.copy(isLoading = false) } + // A password save holds the sign-in. The server won't hand these + // tokens out again, so keep them in case that save fails. + heldApproval = response to expected + return + } + // An approval for a sign-in context that has since changed can't be + // saved: release the claim before it cancels a password attempt made + // in the new context. Nothing suspends between this check and the claim. + if (!expected.isSameSession(current)) { + authCompleted = false + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } credentialLoginJob?.cancel() @@ -211,9 +584,13 @@ class TvLoginViewModel( throw cancelled } catch (_: AccountSessionChangedException) { handleIdentityChanged() + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } catch (_: Throwable) { handleSessionPersistenceFailure(accessToken, refreshToken) + if (!_uiState.value.loginSuccess) { + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) + } return } // Tokens are committed outside persistSession here, so refresh the @@ -221,8 +598,15 @@ class TvLoginViewModel( authRepository.onSessionCommitted() if (tokenManager.captureAccountSessionExpectation()?.generation != expected.generation + 1) { handleIdentityChanged() + _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } + _deviceSignIn.value = TvDeviceSignInUi( + status = TvSignInStatus.SignedIn, + accountName = response.user?.username?.takeIf { it.isNotBlank() }, + ) + // Let "Signed in as …" register before the profile picker replaces it. + delay(SIGNED_IN_DWELL_MS) _uiState.update { it.copy(isLoading = false, loginSuccess = true) } } @@ -230,8 +614,7 @@ class TvLoginViewModel( // Only release this screen's attempt. Credentials now belong to the new identity. authCompleted = false _uiState.update { - it.copy(isLoading = false, loginSuccess = false, - error = "The account or server changed. Start sign-in again.") + it.copy(isLoading = false, loginSuccess = false, error = TvLoginError.IdentityChanged) } } @@ -249,21 +632,149 @@ class TvLoginViewModel( it.copy( isLoading = false, loginSuccess = committed, - error = if (committed) { - "Signed in, but local diagnostics cleanup did not finish." - } else { - "Unable to save this session. Try again." - }, + error = if (committed) TvLoginError.CleanupIncomplete else TvLoginError.SaveFailed, ) } } + /** The password session committed: the code on screen must not be approvable any more. */ + private fun passwordWon() { + heldApproval = null + abandonDeviceCode() + } + + /** The password save failed without committing: carry on with the device code. */ + private fun resumeDeviceSignIn() { + if (authCompleted) return + val held = heldApproval + if (held != null) { + heldApproval = null + launchApproval(held.first, held.second) + return + } + val current = machine ?: return + if (visible && !current.isSettled && deviceLoginJob?.isActive != true) runMachine(current) + } + + override suspend fun codeForNearbyApproval(): DeviceLoginStartResponse? = + withTimeoutOrNull(NEARBY_CODE_WAIT_MS) { + combine(liveCode, _deviceSignIn) { code, ui -> code to ui.status } + .first { (code, status) -> (code != null && status in SHOWING_CODE) || status in ENDS_NEARBY_WAIT } + .let { (code, status) -> code.takeIf { status in SHOWING_CODE } } + } + + override suspend fun nearbyApprovalOutcome(deviceCode: String): NearbySignInOutcome = + combine(liveCode, _deviceSignIn) { code, ui -> nearbyOutcome(ui.status, code, deviceCode) } + .filterNotNull() + .first() + + private fun nearbyOutcome( + status: TvSignInStatus, + live: DeviceLoginStartResponse?, + deviceCode: String, + ): NearbySignInOutcome? = when (status) { + TvSignInStatus.SignedIn -> NearbySignInOutcome.SignedIn + TvSignInStatus.CouldntFinish -> NearbySignInOutcome.Failed(PairingFailureCode.AuthFailed) + TvSignInStatus.Denied -> NearbySignInOutcome.Failed(PairingFailureCode.Denied) + TvSignInStatus.UpdateRequired -> NearbySignInOutcome.Failed(PairingFailureCode.UpdateRequired) + TvSignInStatus.Paused, TvSignInStatus.Failed -> NearbySignInOutcome.Failed(PairingFailureCode.Expired) + TvSignInStatus.GettingCode, + TvSignInStatus.Waiting, + TvSignInStatus.Opened, + TvSignInStatus.NewCode, + TvSignInStatus.Unreachable, + TvSignInStatus.TooManyRequests, + -> if (live?.deviceCode == deviceCode) null else NearbySignInOutcome.Failed(PairingFailureCode.Expired) + } + private fun tryCompleteAuth(): Boolean { if (authCompleted) return false authCompleted = true return true } + /** + * The sign-in context, read again once if the first read races an + * identity transition (sign-out, expiry or server switch landing as this + * screen opens) rather than giving up silently (#421). + */ + private suspend fun captureExpectation(): AccountSessionExpectation? { + repeat(EXPECTATION_ATTEMPTS) { attempt -> + val expected = runCatching { tokenManager.captureAccountSessionExpectation() }.getOrNull() + if (expected != null) return expected + if (attempt < EXPECTATION_ATTEMPTS - 1) delay(EXPECTATION_RETRY_MS) + } + return null + } + + private fun abandonDeviceCode() { + deviceLoginJob?.cancel() + deviceLoginJob = null + liveCode.value = null + machine?.abandon(backgroundScope) + } + + private fun loadServerContext() { + val registry = serverRegistry ?: return + viewModelScope.launch { + val entry = registry.activeEntry.value ?: return@launch + _uiState.update { + it.copy( + serverName = entry.displayName, + serverHost = DeviceCodeFormat.host(entry.url), + serverActivateText = DeviceCodeFormat.activateText(entry.url.trimEnd('/') + "/activate", ""), + ) + } + refreshSignInOptions() + // Advertise st=login only with a verified identity: phones match a + // signed-out TV to their saved servers by identity, never by URL. + if (!advertisesSignIn) return@launch + val identity = serverIdentities?.identityOf(entry, refresh = true) ?: return@launch + _pairingAdvertisement.value = PairingAdvertisement.login( + serverIdentity = identity, + serverUrl = entry.url, + source = this@TvLoginViewModel, + ) + } + } + + /** + * Reads which sign-in methods the active server offers and whether the + * password form stays. Unknown options (a network failure) change nothing. + */ + private fun refreshSignInOptions() { + val discovery = externalSignIn ?: return + val entry = serverRegistry?.activeEntry?.value ?: return + signInOptionsJob?.cancel() + signInOptionsJob = viewModelScope.launch { + val options = discovery.signInOptions(entry.url) ?: return@launch + if (serverRegistry.activeEntry.value?.id != entry.id) return@launch + _uiState.update { + val passwordAvailable = options.showPasswordForm + it.copy( + passwordAvailable = passwordAvailable, + // The form's errors go with it when it closes, except the + // "turned off" refusal the screen then shows outside it; + // that one goes once the form can come back. + error = when { + passwordAvailable == it.passwordAvailable -> it.error + passwordAvailable -> null + else -> it.error.takeIf { error -> error == TvLoginError.LocalLoginDisabled } + }, + providerName = options.oauthProviders.firstOrNull()?.displayName, + directoryName = options.directoryProvider?.displayName, + ) + } + } + } + + private fun DeviceLoginStartResponse.display() = TvSignInCode( + userCode = DeviceCodeFormat.display(userCode), + qrContent = verificationUriComplete, + activateText = DeviceCodeFormat.activateText(verificationUri, verificationUriComplete), + spokenCode = DeviceCodeFormat.spoken(userCode), + ) + /** Called by the screen from `LaunchedEffect(loginSuccess)` after routing. */ fun onLoginSuccessConsumed() { _uiState.update { it.copy(loginSuccess = false) } @@ -272,6 +783,30 @@ class TvLoginViewModel( override fun onCleared() { deviceLoginJob?.cancel() credentialLoginJob?.cancel() + // Leaving the screen: an abandoned code must not stay approvable. + machine?.abandon(backgroundScope) super.onCleared() } + + private companion object { + const val EXPECTATION_ATTEMPTS = 3 + const val EXPECTATION_RETRY_MS = 250L + const val NEW_CODE_ANNOUNCEMENT_MS = 5_000L + const val SIGNED_IN_DWELL_MS = 1_000L + + /** How long a nearby phone waits for a code that is still being fetched. */ + const val NEARBY_CODE_WAIT_MS = 15_000L + + val SHOWING_CODE = setOf(TvSignInStatus.Waiting, TvSignInStatus.Opened, TvSignInStatus.NewCode) + val ENDS_NEARBY_WAIT = setOf( + TvSignInStatus.SignedIn, + TvSignInStatus.CouldntFinish, + TvSignInStatus.Denied, + TvSignInStatus.Paused, + TvSignInStatus.Failed, + TvSignInStatus.UpdateRequired, + ) + + fun deviceName(): String = Build.MODEL?.trim()?.takeIf { it.isNotEmpty() } ?: "Android TV" + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairDeviceScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairDeviceScreen.kt index 0d40f633e5..fda7a779be 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairDeviceScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairDeviceScreen.kt @@ -21,6 +21,9 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.siloserver.silo.tv.R +import org.siloserver.silo.viewmodel.DevicePairingError import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -170,13 +173,13 @@ fun TvPairDeviceScreen( state.error?.let { error -> Spacer(Modifier.height(18.dp)) Text( - text = error, + text = error.message(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold, ) - if (error.startsWith("Sign in")) { + if (error == DevicePairingError.SignInFirst) { Spacer(Modifier.height(12.dp)) Button(onClick = onSignIn) { Text("Sign In") } } @@ -274,7 +277,12 @@ fun TvPairDeviceScreen( @Composable private fun PairingDetails(lookup: DeviceLoginLookupResponse) { Spacer(Modifier.height(18.dp)) - DetailRow(label = "Match", value = lookup.matchCode.orEmpty()) + // The code the other device shows, grouped 4+4; the match words are no + // longer shown anywhere. + DetailRow( + label = "Code", + value = lookup.userCode?.let(org.siloserver.silo.model.auth.DeviceCodeFormat::display).orEmpty(), + ) DetailRow(label = "Device", value = lookup.deviceName.orEmpty()) DetailRow(label = "Platform", value = lookup.devicePlatform.orEmpty()) lookup.ipAddressHint?.takeIf { it.isNotBlank() }?.let { DetailRow(label = "IP", value = it) } @@ -306,3 +314,18 @@ private fun DetailRow(label: String, value: String) { ) } } + +@Composable +private fun DevicePairingError.message(): String = when (this) { + DevicePairingError.EnterCode -> stringResource(R.string.tv_pair_error_enter_code) + DevicePairingError.SignInFirst -> stringResource(R.string.tv_pair_error_sign_in_first) + DevicePairingError.NotFound -> stringResource(R.string.tv_pair_error_not_found) + is DevicePairingError.NotFoundOn -> stringResource(R.string.tv_pair_error_not_found_on, serverName) + DevicePairingError.Expired -> stringResource(R.string.tv_pair_error_expired) + DevicePairingError.AlreadySignedIn -> stringResource(R.string.tv_pair_error_already_signed_in) + DevicePairingError.Declined -> stringResource(R.string.tv_pair_error_declined) + DevicePairingError.Canceled -> stringResource(R.string.tv_pair_error_canceled) + DevicePairingError.Network -> stringResource(R.string.tv_pair_error_network) + DevicePairingError.ProviderUnavailable -> stringResource(R.string.tv_pair_error_provider_unavailable) + is DevicePairingError.Server -> message ?: stringResource(R.string.tv_pair_error_generic) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt new file mode 100644 index 0000000000..57b53ff3ad --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt @@ -0,0 +1,420 @@ +package org.siloserver.silo.tv.ui.screens.auth + +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.Row +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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 +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +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.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import org.siloserver.silo.common.pairing.PairingReceiverStatus +import org.siloserver.silo.model.auth.DeviceCodeFormat +import org.siloserver.silo.pairing.PairingFailureCode +import org.siloserver.silo.tv.R +import org.siloserver.silo.tv.ui.components.AuroraEyebrow +import org.siloserver.silo.tv.ui.components.AuroraGhostButton +import org.siloserver.silo.tv.ui.components.AuroraPrimaryButton +import org.siloserver.silo.tv.ui.components.auroraGlass +import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.theme.Spacing + +/** Whether the receiver is in a phone session the TV should show in place of its own screen. */ +internal val PairingReceiverStatus.isActivePairing: Boolean + get() = this is PairingReceiverStatus.Connected || + this is PairingReceiverStatus.ConsentRequested || + this is PairingReceiverStatus.Pairing || + this is PairingReceiverStatus.Unreachable || + this is PairingReceiverStatus.AwaitingApproval || + this is PairingReceiverStatus.SignedIn || + this is PairingReceiverStatus.Completed || + this is PairingReceiverStatus.Failed + +/** + * The nearby-phone flow on the TV, shown in place of server setup or the + * sign-in screen while a phone or tablet is connected. [signIn] is true on the + * sign-in screen (`st=login`): the TV already has its server and only the + * account is being signed in. People compare the TV's sign-in code; the match + * words are never shown. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun ActivePairingPanel( + status: PairingReceiverStatus, + onCancel: () -> Unit, + onContinue: () -> Unit, + onAllow: () -> Unit, + onDeny: () -> Unit, + onUseAlternate: () -> Unit, + onRetryAddress: () -> Unit, + signIn: Boolean = false, + modifier: Modifier = Modifier, +) { + val primaryFocus = remember { FocusRequester() } + var panelHasFocus by remember { mutableStateOf(false) } + // Every state with a decision puts focus on it; a prompt whose button + // never takes focus can't be answered from a remote at all. + val focusKey = when (status) { + is PairingReceiverStatus.ConsentRequested -> "consent" + is PairingReceiverStatus.Unreachable -> "unreachable" + is PairingReceiverStatus.Failed -> "failed" + is PairingReceiverStatus.Completed -> "completed" + else -> null + } + LaunchedEffect(focusKey) { + if (focusKey != null) { + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = primaryFocus::requestFocus, + isFocused = { panelHasFocus }, + ) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.md), + modifier = modifier + .onFocusChanged { panelHasFocus = it.hasFocus } + .fillMaxWidth(), + ) { + when (status) { + PairingReceiverStatus.Connected -> { + AuroraEyebrow(text = stringResource(if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect)) + PanelTitle(stringResource(R.string.tv_pairing_connected_title)) + WaitingDots() + PanelDetail( + stringResource( + if (signIn) R.string.tv_pairing_connected_detail_signin else R.string.tv_pairing_connected_detail, + ), + ) + AuroraGhostButton(label = stringResource(R.string.tv_pairing_cancel), onClick = onCancel) + } + is PairingReceiverStatus.ConsentRequested -> { + AuroraEyebrow(text = stringResource(if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect)) + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_consent_title_signin else R.string.tv_pairing_consent_title), + ) + PanelDetail(stringResource(R.string.tv_pairing_consent_detail, status.serverName)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + modifier = Modifier.padding(top = Spacing.sm), + ) { + AuroraPrimaryButton( + label = stringResource(R.string.tv_pairing_allow), + onClick = onAllow, + focusRequester = primaryFocus, + modifier = Modifier + .width(180.dp) + .height(60.dp), + ) + AuroraGhostButton( + label = stringResource(R.string.tv_pairing_dont_allow), + onClick = onDeny, + modifier = Modifier + .width(180.dp) + .height(60.dp), + ) + } + } + is PairingReceiverStatus.Pairing -> { + AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_almost)) + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_starting_title_signin else R.string.tv_pairing_starting_title), + ) + ServerNameLabel(status.serverName, signIn) + WaitingDots(compact = true) + PanelDetail(stringResource(R.string.tv_pairing_starting_detail)) + AuroraGhostButton(label = stringResource(R.string.tv_pairing_cancel), onClick = onCancel) + } + is PairingReceiverStatus.Unreachable -> { + AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_almost)) + PanelTitle(stringResource(R.string.tv_pairing_unreachable_title, status.serverName)) + val providerName = status.providerName + PanelDetail( + if (providerName != null) { + stringResource(R.string.tv_pairing_unreachable_provider, status.serverName, providerName) + } else { + stringResource( + R.string.tv_pairing_unreachable_public, + status.serverName, + DeviceCodeFormat.host(status.serverURL), + ) + }, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + val alternate = status.alternateUrl + if (alternate != null) { + AuroraPrimaryButton( + label = stringResource(R.string.tv_pairing_use_alternate, DeviceCodeFormat.host(alternate)), + onClick = onUseAlternate, + focusRequester = primaryFocus, + modifier = Modifier.width(320.dp), + ) + AuroraGhostButton( + label = stringResource(R.string.tv_pairing_try_again), + onClick = onRetryAddress, + modifier = Modifier.width(320.dp), + ) + } else { + AuroraPrimaryButton( + label = stringResource(R.string.tv_pairing_try_again), + onClick = onRetryAddress, + focusRequester = primaryFocus, + modifier = Modifier.width(320.dp), + ) + } + AuroraGhostButton( + label = stringResource(R.string.tv_pairing_cancel), + onClick = onCancel, + modifier = Modifier.width(320.dp), + ) + } + } + is PairingReceiverStatus.AwaitingApproval -> { + AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_almost)) + PanelTitle( + stringResource( + if (status.automatic) R.string.tv_pairing_confirm_title_automatic else R.string.tv_pairing_confirm_title, + ), + ) + SignInCodeCard(userCode = status.userCode) + olderPhonesMatchWords(status)?.let { words -> + Text( + text = stringResource(R.string.tv_pairing_confirm_older_phones, words), + style = TvPairingTextStyles.Caption, + color = Color.White.copy(alpha = 0.48f), + textAlign = TextAlign.Center, + ) + } + ServerNameLabel(status.serverName, signIn) + WaitingDots(compact = true) + PanelDetail( + stringResource( + if (status.automatic) R.string.tv_pairing_confirm_detail_automatic else R.string.tv_pairing_confirm_detail, + ), + ) + AuroraGhostButton(label = stringResource(R.string.tv_pairing_cancel), onClick = onCancel) + } + is PairingReceiverStatus.SignedIn -> { + AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_done)) + SuccessMark() + PanelTitle( + if (status.serverCount <= 1) { + stringResource(R.string.tv_pairing_signed_in) + } else { + stringResource(R.string.tv_pairing_signed_in_count, status.serverCount) + }, + ) + WaitingDots(compact = true) + PanelDetail(stringResource(R.string.tv_pairing_finishing)) + } + is PairingReceiverStatus.Completed -> { + AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_done)) + SuccessMark() + PanelTitle(stringResource(R.string.tv_pairing_completed_title)) + PanelDetail( + if (status.serverNames.isEmpty()) { + stringResource(R.string.tv_pairing_completed_none) + } else { + stringResource(R.string.tv_pairing_completed_named, status.serverNames.joinToString(", ")) + }, + ) + AuroraPrimaryButton( + label = stringResource(R.string.tv_pairing_continue), + icon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onContinue, + focusRequester = primaryFocus, + modifier = Modifier.width(320.dp), + ) + } + is PairingReceiverStatus.Failed -> { + AuroraEyebrow(text = stringResource(if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect)) + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_failed_title_signin else R.string.tv_pairing_failed_title), + ) + Text( + text = stringResource(status.code.messageRes(), status.serverName), + style = TvPairingTextStyles.Detail, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + AuroraPrimaryButton( + label = stringResource(R.string.tv_pairing_try_again), + onClick = onCancel, + focusRequester = primaryFocus, + modifier = Modifier.width(320.dp), + ) + } + else -> Unit + } + } +} + +/** + * The match words for the "Older phones show ..." line: only when a person + * compares codes (not an automatic attempt) and the words are known, in + * capitals like the phones show them. Remove with that line. + */ +internal fun olderPhonesMatchWords(status: PairingReceiverStatus.AwaitingApproval): String? = + status.matchCode?.trim()?.takeIf { !status.automatic && it.isNotEmpty() }?.uppercase() + +private fun PairingFailureCode.messageRes(): Int = when (this) { + PairingFailureCode.Denied -> R.string.tv_pairing_failed_denied + PairingFailureCode.Expired -> R.string.tv_pairing_failed_expired + PairingFailureCode.Unreachable -> R.string.tv_pairing_failed_unreachable + PairingFailureCode.IdentityMismatch -> R.string.tv_pairing_failed_identity + PairingFailureCode.UpdateRequired -> R.string.tv_pairing_failed_update + PairingFailureCode.AuthFailed -> R.string.tv_pairing_failed_generic +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PanelTitle(text: String) { + Text( + text = text, + style = TvPairingTextStyles.Title, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PanelDetail(text: String) { + Text( + text = text, + style = TvPairingTextStyles.Detail, + color = Color.White.copy(alpha = 0.72f), + textAlign = TextAlign.Center, + ) +} + +/** + * The TV's sign-in code, grouped 4+4, the same code the phone asks the person + * to check. Read to TalkBack character by character. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SignInCodeCard(userCode: String) { + if (userCode.isBlank()) return + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + modifier = Modifier + .fillMaxWidth() + .auroraGlass(12.dp) + .padding(horizontal = Spacing.md, vertical = Spacing.md), + ) { + SignInCodeText( + code = DeviceCodeFormat.display(userCode), + spokenCode = DeviceCodeFormat.spoken(userCode), + label = stringResource(R.string.tv_signin_code_label), + style = TvSignInCodeStyle, + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ServerNameLabel(serverName: String, signIn: Boolean) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + Text( + text = stringResource(if (signIn) R.string.tv_pairing_server_label_signin else R.string.tv_pairing_server_label), + style = TvPairingTextStyles.Label, + color = Color.White.copy(alpha = 0.48f), + ) + Text( + text = serverName, + style = TvPairingTextStyles.Headline, + color = Color.White, + ) + } +} + +@Composable +private fun WaitingDots(compact: Boolean = false) { + val dotSize = if (compact) 8.dp else 12.dp + Row(horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 12.dp)) { + repeat(3) { + Box( + modifier = Modifier + .size(dotSize) + .background(Color.White.copy(alpha = 0.82f), RoundedCornerShape(999.dp)), + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SuccessMark() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(74.dp) + .background(Color(0xFF22C55E).copy(alpha = 0.18f), RoundedCornerShape(999.dp)) + .border(2.dp, Color(0xFF22C55E).copy(alpha = 0.68f), RoundedCornerShape(999.dp)), + ) { + Text( + text = "✓", + style = TvPairingTextStyles.SuccessMark, + color = Color(0xFF86EFAC), + ) + } +} + +private object TvPairingTextStyles { + val Title = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 30.sp) + val Detail = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp) + val Caption = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp) + val Headline = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 26.sp) + val Label = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 2.sp, + ) + val SuccessMark = TextStyle(fontWeight = FontWeight.Bold, fontSize = 38.sp, lineHeight = 38.sp) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt index 00b48fa055..e857e02426 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -75,6 +76,7 @@ import androidx.tv.material3.Text import kotlinx.coroutines.delay import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.pairing.PairingAdvertisement import org.siloserver.silo.common.pairing.PairingReceiver import org.siloserver.silo.common.pairing.PairingReceiverStatus import org.siloserver.silo.common.pairing.TvPairingAdvertiser @@ -134,9 +136,11 @@ fun TvServerSetupScreen( // so a phone running Silo can push the server URL + drive device-login, // sparing the viewer from typing a URL on the remote. Advertising stops // when the screen leaves the composition. + var advertiserToken by remember { mutableStateOf(0L) } DisposableEffect(Unit) { - pairingAdvertiser.start() - onDispose { pairingAdvertiser.stop() } + val token = pairingAdvertiser.start(PairingAdvertisement.Setup) + advertiserToken = token + onDispose { pairingAdvertiser.stop(token) } } // Snapshot-backed: re-keys the claim when the viewer switches between // pointer and key input. @@ -168,7 +172,7 @@ fun TvServerSetupScreen( LaunchedEffect(pairingStatus) { if (pairingStatus is PairingReceiverStatus.Completed) { delay(1_800) - pairingAdvertiser.stop() + pairingAdvertiser.stop(advertiserToken) onPairedSignIn() } } @@ -235,11 +239,13 @@ fun TvServerSetupScreen( status = pairingStatus, onCancel = pairingReceiver::cancelActiveSession, onContinue = { - pairingAdvertiser.stop() + pairingAdvertiser.stop(advertiserToken) onPairedSignIn() }, onAllow = pairingReceiver::allowPendingServer, onDeny = pairingReceiver::denyPendingServer, + onUseAlternate = pairingReceiver::useAlternateAddress, + onRetryAddress = pairingReceiver::retryPushedAddress, // tvOS renders pairing states directly on the Aurora backdrop // in an 880pt content column. Shield uses a 2x density, so // 440dp produces the same 880px maximum footprint. @@ -280,7 +286,7 @@ fun TvServerSetupScreen( color = Color.White, ) Text( - text = "Use your phone, or enter the server address with the remote.", + text = stringResource(R.string.tv_setup_subtitle), style = TvServerSetupTextStyles.PairingDetail, color = Color.White.copy(alpha = 0.72f), textAlign = TextAlign.Center, @@ -375,7 +381,7 @@ private fun PhoneSetupCard( ) { // Top-leading pill, matching tvOS TVServerSetupView.phoneCard. Text( - text = "RECOMMENDED · USE PHONE", + text = stringResource(R.string.tv_setup_phone_pill), style = TvServerSetupTextStyles.Pill, color = Color.White.copy(alpha = 0.70f), modifier = Modifier @@ -398,7 +404,7 @@ private fun PhoneSetupCard( @Composable private fun PhoneSetupBody(modifier: Modifier = Modifier) { // Beacon centered, copy left-aligned beneath it — mirrors tvOS - // TVServerSetupView.phoneCard (iPhone → phone). + // TVServerSetupView.phoneCard, with its neutral "phone or tablet" copy. Column( verticalArrangement = Arrangement.spacedBy(10.dp), modifier = modifier.fillMaxWidth(), @@ -410,13 +416,12 @@ private fun PhoneSetupBody(modifier: Modifier = Modifier) { ) Text( - text = "Looking for a phone…", + text = stringResource(R.string.tv_setup_phone_looking), style = TvServerSetupTextStyles.Headline, color = Color.White, ) Text( - text = "Open Silo on a phone connected to the same Wi-Fi. Accept the " + - "setup card and Silo will securely bring over the server and account.", + text = stringResource(R.string.tv_setup_phone_detail), style = TvServerSetupTextStyles.PairingDetail, color = Color.White.copy(alpha = 0.72f), maxLines = 4, @@ -676,294 +681,6 @@ private fun SearchingBeacon(modifier: Modifier = Modifier) { } } -private val PairingReceiverStatus.isActivePairing: Boolean - get() = this is PairingReceiverStatus.Connected || - this is PairingReceiverStatus.ConsentRequested || - this is PairingReceiverStatus.Pairing || - this is PairingReceiverStatus.AwaitingApproval || - this is PairingReceiverStatus.SignedIn || - this is PairingReceiverStatus.Completed || - this is PairingReceiverStatus.Failed - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun ActivePairingPanel( - status: PairingReceiverStatus, - onCancel: () -> Unit, - onContinue: () -> Unit, - onAllow: () -> Unit = {}, - onDeny: () -> Unit = {}, - modifier: Modifier = Modifier, -) { - val allowFocusRequester = remember { FocusRequester() } - var consentHasFocus by remember { mutableStateOf(false) } - LaunchedEffect(status) { - if (status is PairingReceiverStatus.ConsentRequested) { - // A consent prompt whose Allow button never takes focus cannot be - // answered from a remote at all. - requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = allowFocusRequester::requestFocus, - isFocused = { consentHasFocus }, - ) - } - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = modifier - .onFocusChanged { consentHasFocus = it.hasFocus } - .fillMaxWidth(), - ) { - when (status) { - PairingReceiverStatus.Connected -> { - AuroraEyebrow(text = "Step 01 — Connect") - Text( - text = "Phone connected", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - WaitingDots() - Text( - text = "On your phone, choose which servers this TV should sign in to.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraGhostButton(label = "Cancel", onClick = onCancel) - } - is PairingReceiverStatus.ConsentRequested -> { - AuroraEyebrow(text = "Step 01 — Connect") - Text( - text = "Allow this setup?", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - Text( - text = "A nearby phone wants to sign this TV in to ${status.serverName}.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.padding(top = Spacing.sm), - ) { - AuroraPrimaryButton( - label = "Allow", - onClick = onAllow, - focusRequester = allowFocusRequester, - modifier = Modifier - .width(180.dp) - .height(60.dp), - ) - AuroraGhostButton( - label = "Don\u2019t allow", - onClick = onDeny, - modifier = Modifier - .width(180.dp) - .height(60.dp), - ) - } - } - is PairingReceiverStatus.Pairing -> { - AuroraEyebrow(text = "Almost there") - Text( - text = "Setting up this TV", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - ServerNameLabel(status.serverName) - WaitingDots(compact = true) - Text( - text = "Starting secure sign-in…", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraGhostButton(label = "Cancel", onClick = onCancel) - } - is PairingReceiverStatus.AwaitingApproval -> { - AuroraEyebrow(text = "Almost there") - Text( - text = "Confirm on your phone", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - MatchCodeCard(code = status.matchCode) - ServerNameLabel(status.serverName) - WaitingDots(compact = true) - Text( - text = "Make sure your phone shows this same code before approving sign-in.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraGhostButton(label = "Cancel", onClick = onCancel) - } - is PairingReceiverStatus.SignedIn -> { - AuroraEyebrow(text = "All set") - SuccessMark() - Text( - text = if (status.serverCount <= 1) "Signed in" else "Signed in to ${status.serverCount} servers", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - WaitingDots(compact = true) - Text( - text = "Finishing up on your phone…", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - } - is PairingReceiverStatus.Completed -> { - AuroraEyebrow(text = "All set") - SuccessMark() - Text( - text = "You’re all set", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - Text( - text = completedSummary(status.serverNames), - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - ) - AuroraPrimaryButton( - label = "Continue", - icon = Icons.AutoMirrored.Filled.ArrowForward, - onClick = onContinue, - modifier = Modifier.width(320.dp), - ) - } - is PairingReceiverStatus.Failed -> { - AuroraEyebrow(text = "Step 01 — Connect") - Text( - text = "Setup didn’t finish", - style = TvServerSetupTextStyles.PairingTitle, - color = Color.White, - ) - Text( - text = "Something went wrong setting up ${status.serverName}. Try again from your phone, or set up your server manually.", - style = TvServerSetupTextStyles.PairingDetail, - color = MaterialTheme.colorScheme.error, - ) - AuroraPrimaryButton( - label = "Try again", - onClick = onCancel, - modifier = Modifier.width(320.dp), - ) - } - else -> Unit - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun ServerNameLabel(serverName: String) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - Text( - text = "SETTING UP", - style = TvServerSetupTextStyles.CodeLabel, - color = Color.White.copy(alpha = 0.48f), - ) - Text( - text = serverName, - style = TvServerSetupTextStyles.Headline, - color = Color.White, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun WaitingDots(compact: Boolean = false) { - val dotSize = if (compact) 8.dp else 12.dp - Row(horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 12.dp)) { - repeat(3) { - Box( - modifier = Modifier - .size(dotSize) - .background(Color.White.copy(alpha = 0.82f), RoundedCornerShape(999.dp)), - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SuccessMark() { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(74.dp) - .background(Color(0xFF22C55E).copy(alpha = 0.18f), RoundedCornerShape(999.dp)) - .border(2.dp, Color(0xFF22C55E).copy(alpha = 0.68f), RoundedCornerShape(999.dp)), - ) { - Text( - text = "✓", - style = TvServerSetupTextStyles.SuccessMark, - color = Color(0xFF86EFAC), - ) - } -} - -private fun completedSummary(names: List): String = - when (names.size) { - 0 -> "Taking you to your profiles…" - 1 -> "Signed in to ${names[0]}. Taking you to your profiles…" - else -> "Signed in to ${names.joinToString(", ")}. Taking you to your profiles…" - } - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun MatchCodeCard(code: String) { - if (code.isBlank()) return - val tileWidthDp = matchCodeTileWidthDp(code) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier - .fillMaxWidth() - .auroraGlass(12.dp) - .padding(horizontal = Spacing.md, vertical = Spacing.md), - ) { - Text( - text = "CONFIRM THIS CODE", - style = TvServerSetupTextStyles.CodeLabel, - color = Color.White.copy(alpha = 0.62f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(MATCH_CODE_TILE_GAP_DP.dp)) { - code.uppercase().forEach { ch -> - if (ch == '-' || ch == ' ') { - Text( - text = "–", - style = TvServerSetupTextStyles.CodeSeparator, - color = Color.White.copy(alpha = 0.42f), - modifier = Modifier.width(MATCH_CODE_SEPARATOR_WIDTH_DP.dp), - ) - } else { - Box( - modifier = Modifier - .size(width = tileWidthDp.dp, height = 42.dp) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) - .border(1.dp, Color.White.copy(alpha = 0.20f), RoundedCornerShape(7.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = ch.toString(), - style = TvServerSetupTextStyles.CodeDigit, - color = Color.White, - ) - } - } - } - } - } -} - private object TvServerSetupTextStyles { val Title = TextStyle( fontWeight = FontWeight.SemiBold, @@ -1017,43 +734,6 @@ private object TvServerSetupTextStyles { lineHeight = 22.sp, letterSpacing = 0.sp, ) - - val PairingTitle = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - lineHeight = 30.sp, - letterSpacing = 0.sp, - ) - - val CodeLabel = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 2.sp, - ) - - val CodeDigit = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp, - ) - - val CodeSeparator = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp, - ) - - val SuccessMark = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 38.sp, - lineHeight = 38.sp, - letterSpacing = 0.sp, - ) } /** Compact horizontal brand row — replaces the oversized centered logo/title pair. */ diff --git a/androidTvApp/src/androidMain/res/values/strings.xml b/androidTvApp/src/androidMain/res/values/strings.xml index 45adb8c63b..dbe678c4fb 100644 --- a/androidTvApp/src/androidMain/res/values/strings.xml +++ b/androidTvApp/src/androidMain/res/values/strings.xml @@ -29,4 +29,132 @@ Loading… Something went wrong Coming soon + + + Account + Sign in to %1$s + Scan with your phone\'s camera + Or go to %1$s and enter the code + Approve on your phone. This TV signs in by itself. + Have Silo on your phone? Open it on the same Wi‑Fi to sign in this TV. + You were signed out of %1$s. Sign in again. + Sign-in code + Sign-in QR code. Or go to %1$s slash activate and enter %2$s. + Getting a sign-in code… + Waiting for approval + Continue on your phone + New code. Waiting for approval. + Signed in as %1$s + Signed in + Couldn\'t finish signing in on this TV. + Sign-in was declined on your phone. + Sign-in paused. + Can\'t reach %1$s. Check this TV\'s connection. + Too many sign-in attempts. Trying again shortly. + Couldn\'t get a sign-in code. + This server needs an update before this TV can sign in. + This server only supports password sign-in. + Sign in with a password + Change server + Try again + Show a new code + + + Enter the code shown on the other device. + Sign in before approving this device. + We couldn\'t find that code. Check the code on the other device. + We couldn\'t find that code on %1$s. Check the code on the other device. + This code expired. The other device is showing a new one. + That device is already signed in. + This sign-in was declined. + The other device stopped this sign-in. + Network error. Check your connection. + The sign-in provider can\'t be reached right now, so this device couldn\'t confirm your account. You\'re still signed in. Try again in a moment. + Couldn\'t approve the device. Try again. + + + Sign in with a password + USERNAME + PASSWORD + Sign in + Signing in… + Create Account + Use your phone instead + Show password + Hide password + Username is required + Password is required + Invalid username or password + Account is disabled + Network error. Check your connection. + Password sign-in is turned off on this server. Use your phone instead. + Your account isn\'t allowed to use this server. + Your password has expired. Change it, then sign in again. + The sign-in service can\'t be reached right now. Try again later. + Login failed + Invalid username or password. If you sign in with %1$s, use your phone instead. + If you sign in with %1$s, use your phone instead. + You don\'t have an account on this server yet. Ask an admin to add you. + An account with this email already exists. Ask an admin to connect it to %1$s. + That %1$s account is already connected to another account. + Too many attempts. Wait a moment, then try again. + the sign-in provider + The account or server changed. Start sign-in again. + Unable to save this session. Try again. + Signed in, but local diagnostics cleanup did not finish. + + + Step 01 — Connect + Step 02 — Account + Almost there + All set + Phone or tablet connected + On your phone or tablet, choose which servers this TV should sign in to. + Continue on your phone or tablet. + Allow this setup? + Allow this sign-in? + A nearby phone or tablet wants to sign this TV in to %1$s. + Allow + Don\'t allow + Cancel + Setting up this TV + Signing in this TV + Starting secure sign-in… + SETTING UP + SIGNING IN TO + Confirm on your phone or tablet + Signing in + Check that your phone or tablet shows this code, then approve. + Your phone or tablet is checking this code for you. + + Older phones show %1$s instead. + Signed in + Signed in to %1$d servers + Finishing up on your phone or tablet… + You\'re all set + Taking you to your profiles… + Signed in to %1$s. Taking you to your profiles… + Continue + Setup didn\'t finish + Sign-in didn\'t finish + Something went wrong signing in to %1$s. Try again from your phone, or sign in on this TV. + The sign-in to %1$s was declined on the phone. + The code for %1$s expired before it was approved. Try again from your phone. + This TV can\'t reach %1$s. Check its network connection and try again. + The address for %1$s answered as a different server, so nothing was signed in. + %1$s or this app needs an update before this TV can sign in. + Try again + Can\'t reach %1$s + This TV can\'t reach %1$s through %2$s. Install or open the %2$s app on this TV and make sure it has access to the server\'s network, then try again. + This TV can\'t reach %1$s at %2$s. Check its network connection and try again. + Use %1$s + Use your phone or tablet, or enter the server address with the remote. + RECOMMENDED · USE YOUR PHONE + Looking for a phone or tablet… + Open Silo on a phone or tablet on the same Wi‑Fi. Accept the setup card and Silo will securely bring over the server and account. diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt deleted file mode 100644 index a7a98b5326..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.auth - -import kotlin.test.Test -import kotlin.test.assertTrue - -class TvLoginMatchCodeLayoutTest { - - @Test - fun `standard match code fits inside QR card content width`() { - val rowWidthDp = matchCodeRowWidthDp("WILLOW-GRO") - - assertTrue( - actual = rowWidthDp <= 252, - message = "Expected match-code row to fit within 252dp, but it was ${rowWidthDp}dp", - ) - } - - @Test - fun `long match code fits inside QR card content width`() { - val rowWidthDp = matchCodeRowWidthDp("PRAIRIE-SNOW") - - assertTrue( - actual = rowWidthDp <= 252, - message = "Expected long match-code row to fit within 252dp, but it was ${rowWidthDp}dp", - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt new file mode 100644 index 0000000000..e6cf809c1a --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt @@ -0,0 +1,764 @@ +package org.siloserver.silo.tv.ui.screens.auth + +import androidx.lifecycle.ViewModelStore +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.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.siloserver.silo.common.pairing.NearbySignInOutcome +import org.siloserver.silo.model.auth.DeviceLoginCancelResponse +import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse +import org.siloserver.silo.model.auth.DeviceLoginDecisionResponse +import org.siloserver.silo.model.auth.DeviceLoginLookupResponse +import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.model.auth.User +import org.siloserver.silo.network.AccountSessionChangedException +import org.siloserver.silo.network.AccountSessionExpectation +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.SiloAuthPlugin +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.DeviceLoginApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.DeviceLoginClock +import org.siloserver.silo.repository.DeviceLoginRepository +import org.siloserver.silo.repository.ExternalSignInRepository +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.api.ExternalSignInApi +import org.siloserver.silo.network.api.ServerConnections +import org.siloserver.silo.network.api.ServerIdentityApi +import org.siloserver.silo.network.api.ServerIdentityProbe +import org.siloserver.silo.repository.ServerIdentityRepository +import org.siloserver.silo.pairing.PairingReceiverState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.jsonObject +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The TV sign-in panel's device-code behavior: #421 (no silent "Loading" + * hang), #422 (no false "Signed in!"), lifecycle-aware polling (#420) and + * server-side cancel of an abandoned code. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvLoginViewModelDeviceSignInTest { + private val dispatcher = StandardTestDispatcher() + private val stores = mutableListOf() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() { + stores.forEach { it.clear() } + Dispatchers.resetMain() + } + + @Test + fun missingSignInContextOffersTryAgainInsteadOfSpinning() = runTest(dispatcher) { + val tokens = FakeTokens(nullExpectations = 3) + val api = FakeDeviceApi() + val vm = viewModel(tokens, api) + advanceUntilIdle() + assertEquals(TvSignInStatus.Failed, vm.deviceSignIn.value.status) + assertEquals(0, api.starts) + + vm.restartDeviceLogin() + runCurrent() + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + assertEquals("4821 7730", vm.deviceSignIn.value.code?.userCode) + assertEquals("silo.test/activate", vm.deviceSignIn.value.code?.activateText) + } + + @Test + fun failedSaveSaysCouldntFinishAndTryAgainGetsANewCode() = runTest(dispatcher) { + val tokens = FakeTokens(failInstall = true) + val api = FakeDeviceApi(approveAfterPolls = 1) + val vm = viewModel(tokens, api) + advanceUntilIdle() + assertEquals(TvSignInStatus.CouldntFinish, vm.deviceSignIn.value.status) + assertFalse(vm.uiState.value.loginSuccess) + + tokens.failInstall = false + api.approveAfterPolls = Int.MAX_VALUE + vm.restartDeviceLogin() + runCurrent() + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + assertEquals(2, api.starts, "the consumed request can't be collected again; a new code is needed") + } + + @Test + fun approvalShowsSignedInAsBeforeRouting() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi(approveAfterPolls = 1) + val vm = viewModel(tokens, api) + runCurrent() + advanceTimeBy(500) + assertEquals(TvSignInStatus.SignedIn, vm.deviceSignIn.value.status) + assertEquals("laura", vm.deviceSignIn.value.accountName) + assertFalse(vm.uiState.value.loginSuccess) + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + } + + @Test + fun stoppingWhileTheApprovalIsSavedStillSignsInAndRoutes() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi(approveAfterPolls = 1) + val vm = viewModel(tokens, api) + runCurrent() + advanceTimeBy(500) + assertEquals(TvSignInStatus.SignedIn, vm.deviceSignIn.value.status) + assertFalse(vm.uiState.value.loginSuccess) + + // Between the approved poll and routing on (the "Signed in" dwell). + vm.onStop() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess, "the handoff finishes in the background") + assertEquals("qr-access", tokens.accessToken) + } + + @Test + fun stoppingDuringAnApprovedPollKeepsItsTokens() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi(approveAfterPolls = 1, pollDelayMs = 1_000L) + val vm = viewModel(tokens, api) + runCurrent() + assertEquals(1, api.polls, "the poll is in flight") + vm.onStop() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + assertEquals(1, api.starts, "the code was not silently renewed") + } + + @Test + fun aFailedPasswordSaveLeavesTheCodeWorking() = runTest(dispatcher) { + val tokens = FakeTokens(failFirstInstall = true) + val api = FakeDeviceApi() + api.approveWhen = { tokens.installAttempts >= 1 } + val vm = viewModel(tokens, api, loginStatus = HttpStatusCode.OK) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + // The password save failed; a phone then approved the code on screen. + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + assertEquals(TvSignInStatus.SignedIn, vm.deviceSignIn.value.status) + } + + @Test + fun aNearbyPhoneApprovesTheCodeOnScreen() = runTest(dispatcher) { + val tokens = FakeTokens() + val api = FakeDeviceApi() + val vm = viewModel(tokens, api) + runCurrent() + val code = assertNotNull(vm.codeForNearbyApproval()) + assertEquals("dev-1", code.deviceCode) + var outcome: NearbySignInOutcome? = null + backgroundScope.launch { outcome = vm.nearbyApprovalOutcome(code.deviceCode) } + runCurrent() + assertEquals(null, outcome) + + api.approveAfterPolls = api.polls + 1 + advanceUntilIdle() + assertEquals(NearbySignInOutcome.SignedIn, outcome) + assertEquals(1, api.starts, "one code on screen, one request") + assertEquals("qr-access", tokens.accessToken) + } + + @Test + fun stoppingPausesPollingAndStartingPollsAtOnce() = runTest(dispatcher) { + val api = FakeDeviceApi() + val vm = viewModel(FakeTokens(), api) + runCurrent() + advanceTimeBy(11_000) + vm.onStop() + val polls = api.polls + advanceTimeBy(120_000) + assertEquals(polls, api.polls, "no polling while the screen is stopped") + + vm.onStart() + runCurrent() + assertEquals(polls + 1, api.polls) + assertEquals(1, api.starts) + } + + @Test + fun stopStartStopDuringAPollLeavesPollingStopped() = runTest(dispatcher) { + val api = FakeDeviceApi(pollDelayMs = 1_000L) + val vm = viewModel(FakeTokens(), api) + runCurrent() + assertEquals(1, api.polls, "the first poll is in flight") + + // All while that poll is still out: the second run waits for it. + vm.onStop() + vm.onStart() + runCurrent() + vm.onStop() + advanceTimeBy(120_000) + assertEquals(1, api.polls, "no polling while the screen is stopped") + + vm.onStart() + runCurrent() + assertEquals(2, api.polls, "back in the foreground it polls at once") + assertEquals(1, api.starts) + } + + @Test + fun restartingDuringAStoppedSlowPollLeavesOnePollStreamOnTheNewCode() = runTest(dispatcher) { + for (late in listOf("pending", "expired")) { + // dev-1's poll is on the wire and can't be called back; it answers + // at 12 s, between the new code's polls at 10 s and 15 s. + val api = FakeDeviceApi(pollDelayMs = 12_000L, pollsIgnoreCancel = true) + api.statusFor = { code -> if (code == "dev-1") late else "pending" } + val vm = viewModel(FakeTokens(), api) + runCurrent() + assertEquals(listOf("dev-1"), api.polledCodes, "the first poll is in flight") + api.pollDelayMs = 0L + + // "Try again" in the foreground after a stop/start during that poll. + vm.onStop() + vm.onStart() + vm.restartDeviceLogin() + runCurrent() + assertEquals("dev-2", vm.codeForNearbyApproval()?.deviceCode) + + advanceTimeBy(12_001) + assertEquals("dev-2", vm.codeForNearbyApproval()?.deviceCode, "the withdrawn code never comes back ($late)") + advanceTimeBy(18_000) + assertEquals("dev-2", vm.codeForNearbyApproval()?.deviceCode, "the new code isn't orphaned ($late)") + assertEquals(2, api.starts, "no third code ($late)") + assertEquals(1, api.polledCodes.count { it == "dev-1" }, "the old run polls no more ($late)") + // One stream: dev-2 polled at 0, 5, ... 30 s. + assertEquals(7, api.polledCodes.count { it == "dev-2" }, "one poll stream ($late): ${api.polledCodes}") + assertEquals(listOf("dev-1"), api.canceled) + } + } + + @Test + fun startingAfterAPasswordSignInWonAsksForNoNewCode() = runTest(dispatcher) { + val api = FakeDeviceApi() + val vm = viewModel(FakeTokens(), api, loginStatus = HttpStatusCode.OK) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + // The password sign-in completes while the app is in the background. + vm.onStop() + vm.onLoginClick() + // The login request runs on the mock engine's own threads: wait for + // its outcome rather than for virtual time, which is idle while stopped. + vm.uiState.first { it.loginSuccess } + runCurrent() + val starts = api.starts + + vm.onStart() + advanceTimeBy(60_000) + assertEquals(starts, api.starts, "no code for a TV that is already signed in") + } + + @Test + fun stoppingBeforeTheCodeIsRequestedWaitsForTheScreenToReturn() = runTest(dispatcher) { + val api = FakeDeviceApi() + // The first read of the sign-in context races and is retried, so the + // machine doesn't exist yet when the screen stops. + val vm = viewModel(FakeTokens(nullExpectations = 1), api) + runCurrent() + vm.onStop() + advanceTimeBy(120_000) + assertEquals(0, api.starts, "no code requested while the screen is stopped") + assertEquals(0, api.polls) + + vm.onStart() + runCurrent() + assertEquals(1, api.starts) + assertEquals(1, api.polls) + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + } + + @Test + fun passwordSignInWithdrawsTheCodeOnTheServer() = runTest(dispatcher) { + val api = FakeDeviceApi() + val tokens = FakeTokens() + val vm = viewModel(tokens, api, loginStatus = HttpStatusCode.OK) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + // The login request runs on the mock engine's own threads while virtual + // time advances, so codes may have renewed meanwhile (a renewal + // withdraws the code it replaces). Whatever was live when the password + // won is withdrawn: no code the TV showed stays approvable. + assertTrue(api.polledCodes.isNotEmpty()) + assertEquals(api.polledCodes.distinct(), api.canceled.distinct()) + } + + @Test + fun leavingTheScreenWithdrawsTheCodeOnlyWhenTheServerSupportsCancel() = runTest(dispatcher) { + val api = FakeDeviceApi() + val store = ViewModelStore() + store.put("login", viewModel(FakeTokens(), api, track = false)) + runCurrent() + store.clear() + advanceUntilIdle() + assertEquals(listOf("dev-1"), api.canceled) + + val old = FakeDeviceApi(capability = DeviceLoginCapabilityResponse(cancel = false)) + val oldStore = ViewModelStore() + oldStore.put("login", viewModel(FakeTokens(), old, track = false)) + runCurrent() + oldStore.clear() + advanceUntilIdle() + assertEquals(emptyList(), old.canceled) + } + + @Test + fun serverWithoutDeviceSignInGoesToThePasswordForm() = runTest(dispatcher) { + val api = FakeDeviceApi(capability = DeviceLoginCapabilityResponse(deviceLoginAvailable = false)) + val vm = viewModel(FakeTokens(), api) + advanceUntilIdle() + assertTrue(vm.deviceSignIn.value.passwordOnly) + assertEquals(0, api.starts) + } + + // --- Password gating (external sign-in) --- + + @Test + fun oidcOnlyServerWithLocalPasswordsOffOffersNoPassword() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(OIDC), passwordLogin = false)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + advanceUntilIdle() + assertFalse(vm.uiState.value.passwordAvailable, "the TV signs in with a phone only") + assertEquals(listOf("https://silo.test"), signIn.asked) + } + + @Test + fun ldapServerKeepsThePasswordFormAndSendsNoProvider() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LDAP), passwordLogin = true)) + val bodies = mutableListOf() + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), loginStatus = HttpStatusCode.OK, + registry = FakeRegistry(), externalSignIn = signIn, loginBodies = bodies, + ) + runCurrent() + assertTrue(vm.uiState.value.passwordAvailable) + vm.onUsernameChanged("alice") + vm.onPasswordChanged("directory-pw") + vm.onLoginClick() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess, "${vm.uiState.value}") + val login = kotlinx.serialization.json.Json.parseToJsonElement(bodies.single()).jsonObject + assertEquals(setOf("username", "password"), login.keys, "the server routes a directory login by account") + } + + @Test + fun theProvidersNamesAreKeptForThePasswordFormsErrors() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(OIDC, LDAP), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + advanceUntilIdle() + assertEquals(OIDC.displayName, vm.uiState.value.providerName) + assertEquals(LDAP.displayName, vm.uiState.value.directoryName) + } + + @Test + fun directoryRefusalsAndThrottlingHaveTheirOwnErrors() { + assertEquals(TvLoginError.EmailInUse, tvLoginError(ApiResult.Error(409, "email_in_use", "raw detail"))) + assertEquals(TvLoginError.IdentityLinkedElsewhere, tvLoginError(ApiResult.Error(409, "identity_linked_elsewhere", "raw"))) + assertEquals(TvLoginError.RateLimited, tvLoginError(ApiResult.Error(429, "", "raw"))) + } + + @Test + fun aServerThatCantListProvidersKeepsThePasswordForm() = runTest(dispatcher) { + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = FakeSignInApi(null)) + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + } + + /** + * An admin turns password sign-in off while the TV shows the form or sits + * in the background: the TV re-reads its options when the person goes + * back to the code, when the screen returns, and when the server refuses + * a password, so "Sign in with a password" goes away. + */ + @Test + fun theSignInOptionsAreReadAgainOnReturn() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + assertEquals(1, signIn.asked.size) + + // Back from the password form ("Use your phone instead"). + signIn.providers = SignInProviders(listOf(OIDC), passwordLogin = false) + vm.restartDeviceLogin() + advanceUntilIdle() + assertFalse(vm.uiState.value.passwordAvailable) + assertEquals(2, signIn.asked.size) + + // The screen comes back from the background. + signIn.providers = SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true) + vm.onStop() + vm.onStart() + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + assertEquals(3, signIn.asked.size) + + // A start without a stop first (the screen's first start) asks nothing more. + vm.onStart() + advanceUntilIdle() + assertEquals(3, signIn.asked.size) + } + + @Test + fun aRefusedPasswordBecauseLocalLoginIsOffRereadsTheOptions() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true)) + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), loginStatus = HttpStatusCode.Forbidden, + loginBody = """{"type":"https://siloserver.org/docs/api/v2/problems/local_login_disabled","title":"x","status":403}""", + registry = FakeRegistry(), externalSignIn = signIn, + ) + runCurrent() + assertTrue(vm.uiState.value.passwordAvailable) + signIn.providers = SignInProviders(listOf(OIDC), passwordLogin = false) + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + // The form closes; the screen shows the refusal outside it. + assertFalse(vm.uiState.value.passwordAvailable) + assertTrue(vm.uiState.value.passwordTurnedOff) + + // Passwords on again: the form can come back, without the old refusal. + signIn.providers = SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true) + vm.onStop() + vm.onStart() + advanceUntilIdle() + assertTrue(vm.uiState.value.passwordAvailable) + assertFalse(vm.uiState.value.passwordTurnedOff) + assertNull(vm.uiState.value.error) + } + + // --- st=login rollout gate (silo-apple PairingProtocol.advertisesSignInTVs) --- + + /** Released phones push servers without an identity, so a release TV must not advertise st=login yet. */ + @Test + fun gateOffAdvertisesNothingEvenWithAVerifiedIdentity() = runTest(dispatcher) { + val identity = FakeIdentityApi() + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), identityApi = identity, + advertisesSignIn = false, + ) + advanceUntilIdle() + assertNull(vm.pairingAdvertisement.value) + assertEquals(0, identity.probes, "no identity probe when nothing will be advertised") + } + + @Test + fun gateOnAdvertisesLoginWithTheServersIdentity() = runTest(dispatcher) { + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), identityApi = FakeIdentityApi(), + advertisesSignIn = true, + ) + advanceUntilIdle() + val advertisement = assertNotNull(vm.pairingAdvertisement.value) + assertEquals(PairingReceiverState.Login, advertisement.state) + assertEquals("server-1", advertisement.serverIdentity) + assertEquals("https://silo.test", advertisement.serverUrl) + } + + /** Password sign-in turned off while the form showed another error: that error goes with the form. */ + @Test + fun theFormsErrorsCloseWithIt() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, OIDC), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onLoginClick() + assertEquals(TvLoginError.UsernameRequired, vm.uiState.value.error) + signIn.providers = SignInProviders(listOf(OIDC), passwordLogin = false) + vm.onStop() + vm.onStart() + advanceUntilIdle() + assertFalse(vm.uiState.value.passwordAvailable) + assertNull(vm.uiState.value.error) + assertFalse(vm.uiState.value.passwordTurnedOff) + } + + @Test + fun localLoginDisabledHasItsOwnError() = runTest(dispatcher) { + val vm = viewModel( + FakeTokens(), FakeDeviceApi(), loginStatus = HttpStatusCode.Forbidden, + loginBody = """{"type":"https://siloserver.org/docs/api/v2/problems/local_login_disabled","title":"x","status":403}""", + ) + runCurrent() + vm.onUsernameChanged("jim") + vm.onPasswordChanged("pw") + vm.onLoginClick() + advanceUntilIdle() + assertEquals(TvLoginError.LocalLoginDisabled, vm.uiState.value.error) + } + + private fun TestScope.viewModel( + tokens: FakeTokens, + api: FakeDeviceApi, + loginStatus: HttpStatusCode = HttpStatusCode.NotFound, + track: Boolean = true, + registry: ServerRegistry? = null, + externalSignIn: ExternalSignInApi? = null, + loginBodies: MutableList? = null, + loginBody: String? = null, + identityApi: ServerIdentityApi? = null, + advertisesSignIn: Boolean = false, + ): TvLoginViewModel { + val client = HttpClient(MockEngine) { + engine { + addHandler { request -> + if (request.url.encodedPath.endsWith("/auth/login")) { + loginBodies?.add((request.body as io.ktor.http.content.TextContent).text) + } + respond( + loginBody ?: if (loginStatus == HttpStatusCode.OK) LOGIN_JSON else "{}", + loginStatus, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + } + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { this.tokenManager = tokens } + } + val vm = TvLoginViewModel( + authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + tokenManager = tokens, + deviceLogin = DeviceLoginRepository(api), + // The test scope itself (not backgroundScope, which advanceUntilIdle + // doesn't wait for), so the best-effort cancel call is observable. + backgroundScope = this, + clock = DeviceLoginClock { testScheduler.currentTime }, + serverRegistry = registry, + serverIdentities = identityApi?.let { api -> registry?.let { ServerIdentityRepository(it, api) } }, + externalSignIn = externalSignIn?.let(::ExternalSignInRepository), + advertisesSignIn = advertisesSignIn, + ) + if (track) stores += ViewModelStore().also { it.put("login", vm) } + return vm + } + + private class FakeDeviceApi( + val capability: DeviceLoginCapabilityResponse = DeviceLoginCapabilityResponse(cancel = true, openedSignal = true), + var approveAfterPolls: Int = Int.MAX_VALUE, + /** Approves the next poll once this is true, whatever the poll count. */ + var approveWhen: () -> Boolean = { false }, + /** How long each poll call takes, so a test can stop the screen mid-call. */ + var pollDelayMs: Long = 0L, + /** A poll call that ignores cancellation, like a request already on the wire. */ + val pollsIgnoreCancel: Boolean = false, + ) : DeviceLoginApi { + /** What a poll that doesn't approve answers for each code. */ + var statusFor: (String) -> String = { "pending" } + + var starts = 0 + var polls = 0 + val canceled = mutableListOf() + + /** Each code polled, i.e. shown on the TV and live on the server. */ + val polledCodes = mutableListOf() + + override suspend fun deviceLoginCapabilityAt(serverUrl: String) = ApiResult.Success(capability) + override suspend fun startDeviceLoginAt(serverUrl: String, deviceName: String?, devicePlatform: String?) = + ApiResult.Success( + DeviceLoginStartResponse( + deviceCode = "dev-${++starts}", + userCode = "4821-7730", + matchCode = "warm pony", + verificationUri = "https://silo.test/activate", + verificationUriComplete = "https://silo.test/activate?code=48217730", + expiresAt = "2099-01-01T00:00:00Z", + expiresIn = 900, + interval = 5, + deviceName = deviceName.orEmpty(), + devicePlatform = devicePlatform.orEmpty(), + ), + ) + + override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult { + polls++ + polledCodes += deviceCode + val took = pollDelayMs + if (took > 0) { + if (pollsIgnoreCancel) withContext(NonCancellable) { delay(took) } else delay(took) + } + return ApiResult.Success( + if (polls >= approveAfterPolls || approveWhen()) { + DeviceLoginPollResponse( + status = "approved", + accessToken = "qr-access", + refreshToken = "qr-refresh", + expiresIn = 3600, + user = User(id = "1", username = "laura", email = "laura@example.test", role = "user"), + ) + } else { + DeviceLoginPollResponse(status = statusFor(deviceCode)) + }, + ) + } + + override suspend fun cancelDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult { + canceled += deviceCode + return ApiResult.Success(DeviceLoginCancelResponse("canceled")) + } + + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unscoped") + override suspend fun pollDeviceLogin(deviceCode: String) = error("unscoped") + override suspend fun lookupDeviceLogin(token: String?, code: String?): ApiResult = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?): ApiResult = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?): ApiResult = error("unused") + } + + private class FakeRegistry : ServerRegistry { + private val entry = ServerEntry(id = "entry-1", url = "https://silo.test", fetchedName = "Silo") + override val entries = MutableStateFlow(listOf(entry)) + override val activeServerId = MutableStateFlow(entry.id) + override val activeEntry = MutableStateFlow(entry) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = entry.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + private class FakeIdentityApi : ServerIdentityApi { + var probes = 0 + override suspend fun probeIdentity(serverUrl: String): ServerIdentityProbe { + probes++ + return ServerIdentityProbe.Identity("server-1") + } + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + + /** Provider discovery; [providers] null = a server that can't list them. */ + private class FakeSignInApi(var providers: SignInProviders?) : ExternalSignInApi { + val asked = mutableListOf() + override suspend fun listProviders(serverUrl: String): ApiResult { + asked += serverUrl + return providers?.let { ApiResult.Success(it) } ?: ApiResult.Error(404, "not_found", "") + } + override suspend fun oauthCapabilities(serverUrl: String): ApiResult = + ApiResult.Success(OAuthHandshakeCapabilities(available = true, native = true, linking = true)) + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot) = error("TVs have no Sign-in settings") + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String) = error("TVs never run OAuth") + override suspend fun listIdentities(scope: AuthScopeSnapshot) = error("unused") + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String) = error("unused") + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String) = error("unused") + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String) = error("unused") + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, installationId: String, password: String, username: String, directoryPassword: String, + ) = error("unused") + } + + private class FakeTokens( + var nullExpectations: Int = 0, + var failInstall: Boolean = false, + /** Fail only the first install, then work. */ + var failFirstInstall: Boolean = false, + ) : TokenManager { + private var generation = 0L + var accessToken: String? = null + private var refreshToken: String? = null + var installAttempts = 0 + + override suspend fun captureAccountSessionExpectation(): AccountSessionExpectation? { + if (nullExpectations > 0) { + nullExpectations-- + return null + } + return AccountSessionExpectation(generation, null, "https://silo.test") + } + + override suspend fun replaceAccountSession( + serverId: String?, serverUrl: String?, accessToken: String, refreshToken: String, + expiresIn: Long, profileId: String?, profileToken: String?, expectedIdentity: AccountSessionExpectation?, + ) { + if (expectedIdentity != null && expectedIdentity.generation != generation) throw AccountSessionChangedException() + installAttempts++ + if (failInstall || (failFirstInstall && installAttempts == 1)) throw IllegalStateException("keystore unavailable") + generation++ + saveTokens(accessToken, refreshToken, expiresIn) + } + + override val sessionExpired = MutableSharedFlow() + override suspend fun getAccessToken(): String? = accessToken + override suspend fun getRefreshToken(): String? = refreshToken + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) { + this.accessToken = accessToken + this.refreshToken = refreshToken + } + override suspend fun clearTokens() { + accessToken = null + refreshToken = null + } + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String? = null + 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://silo.test" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String? = null + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + } + + private companion object { + val OIDC = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + val LDAP = SignInProvider("plugin:6:ldap", "Directory", SignInProvider.Mode.Credentials, false, null, "6", null) + val LOCAL = SignInProvider("local", "Silo account", SignInProvider.Mode.Credentials, true, null, null, null) + val LOGIN_JSON = """ + {"access_token":"credential-access","refresh_token":"credential-refresh","expires_in":3600, + "user":{"id":"1","username":"jim","email":"jim@example.com","role":"user","download_allowed":true}} + """.trimIndent() + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt index bdeaf0c7c7..0d7e5d98e9 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelRaceTest.kt @@ -166,7 +166,11 @@ class TvLoginViewModelRaceTest { assertEquals("newer-refresh", actualTokens.getRefreshToken()) assertFalse(viewModel.uiState.value.isLoading) assertFalse(viewModel.uiState.value.loginSuccess) - assertEquals("The account or server changed. Start sign-in again.", viewModel.uiState.value.error) + assertEquals(TvLoginError.IdentityChanged, viewModel.uiState.value.error) + if (qr) { + // #422: the QR card must not claim "Signed in" when the install lost the race. + assertEquals(TvSignInStatus.CouldntFinish, viewModel.deviceSignIn.value.status) + } viewModel.onLoginClick() advanceUntilIdle() @@ -193,7 +197,7 @@ class TvLoginViewModelRaceTest { val viewModel = passwordSignIn(tokenManager, HttpStatusCode.Unauthorized) assertFalse(viewModel.uiState.value.loginSuccess) - assertEquals("Invalid username or password", viewModel.uiState.value.error) + assertEquals(TvLoginError.InvalidCredentials, viewModel.uiState.value.error) assertEquals(emptyList(), tokenManager.savedAccessTokens) } @@ -206,7 +210,7 @@ class TvLoginViewModelRaceTest { } assertFalse(viewModel.uiState.value.loginSuccess) - assertEquals("The account or server changed. Start sign-in again.", viewModel.uiState.value.error) + assertEquals(TvLoginError.IdentityChanged, viewModel.uiState.value.error) assertEquals(listOf("newer-access"), tokenManager.savedAccessTokens) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanelCopyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanelCopyTest.kt new file mode 100644 index 0000000000..cdf34c1d64 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanelCopyTest.kt @@ -0,0 +1,44 @@ +package org.siloserver.silo.tv.ui.screens.auth + +import java.io.File +import org.siloserver.silo.common.pairing.PairingReceiverStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The nearby-phone panel's copy matches Apple TV's receiver panel. */ +class TvPairingPanelCopyTest { + private fun awaiting(automatic: Boolean, matchCode: String?) = PairingReceiverStatus.AwaitingApproval( + serverURL = "https://silo.test", + serverName = "Silo", + userCode = "4821-7730", + automatic = automatic, + matchCode = matchCode, + ) + + @Test + fun olderPhonesLineOnlyForAPersonCheckedAttempt() { + assertEquals("WARM PONY", olderPhonesMatchWords(awaiting(automatic = false, matchCode = "warm pony"))) + assertNull(olderPhonesMatchWords(awaiting(automatic = true, matchCode = "warm pony"))) + assertNull(olderPhonesMatchWords(awaiting(automatic = false, matchCode = " "))) + assertNull(olderPhonesMatchWords(awaiting(automatic = false, matchCode = null))) + } + + @Test + fun copyDeckMatchesAppleTv() { + val strings = File("src/androidMain/res/values/strings.xml").readText() + listOf( + "tv_pairing_connected_title\">Phone or tablet connected<", + "tv_pairing_confirm_title\">Confirm on your phone or tablet<", + "tv_pairing_confirm_title_automatic\">Signing in<", + "tv_pairing_confirm_detail\">Check that your phone or tablet shows this code, then approve.<", + "tv_pairing_confirm_detail_automatic\">Your phone or tablet is checking this code for you.<", + "tv_pairing_confirm_older_phones\">Older phones show %1\$s instead.<", + "tv_setup_phone_looking\">Looking for a phone or tablet…<", + "tv_setup_subtitle\">Use your phone or tablet, or enter the server address with the remote.<", + "tv_signin_form_title\">Sign in with a password<", + "tv_signin_form_submit\">Sign in<", + ).forEach { assertTrue(strings.contains(it), it) } + } +} diff --git a/baselineprofile/gradle.lockfile b/baselineprofile/gradle.lockfile index ce822a3179..f4625273f5 100644 --- a/baselineprofile/gradle.lockfile +++ b/baselineprofile/gradle.lockfile @@ -19,6 +19,7 @@ androidx.arch.core:core-runtime:2.2.0=benchmarkReleaseTestedApks,nonMinifiedRele androidx.benchmark:benchmark-common:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath androidx.benchmark:benchmark-macro-junit4:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath androidx.benchmark:benchmark-macro:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.browser:browser:1.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.collection:collection-jvm:1.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.collection:collection-ktx:1.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks androidx.collection:collection:1.0.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath @@ -287,6 +288,8 @@ io.coil-kt.coil3:coil-network-core-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinif io.coil-kt.coil3:coil-network-core:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.coil-kt.coil3:coil-network-okhttp-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.coil-kt.coil3:coil-network-okhttp:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-svg-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-svg:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.coil-kt.coil3:coil:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.github.peerless2012:ass-kt:0.5.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks io.github.peerless2012:ass-media:0.5.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks diff --git a/docs/api-v2/android-migration-status.md b/docs/api-v2/android-migration-status.md index fb30e70619..e3c12b3ed4 100644 --- a/docs/api-v2/android-migration-status.md +++ b/docs/api-v2/android-migration-status.md @@ -7,7 +7,9 @@ and the server ledger `contracts/api/v2/migration.json` (silo-server `origin/api | Endpoint family | v2 paths | Status | | --- | --- | --- | | Auth | `/api/v2/auth/{login,setup,signup,logout,refresh}`, `/api/v2/system/setup`, `/api/v2/account/me`, `/api/v2/system/info` | migrated | -| Device sign-in | `/api/v2/auth/device`, `/api/v2/auth/device/{start,poll,capability,approve,approve-handoff,deny}` | migrated | +| Device sign-in | `/api/v2/auth/device`, `/api/v2/auth/device/{start,poll,cancel,capability,approve,approve-handoff,deny}` | migrated | +| Server identity | `/api/v2/system/identity`, `/api/v2/system/connections` | migrated | +| External sign-in | `/api/v2/auth/providers`, `/api/v2/auth/oauth/capabilities`, `/api/v2/auth/oauth/{install_id}/native/start` (browser), `/api/v2/auth/oauth/complete`, `/api/v2/auth/external-sign-in/capabilities`, `/api/v2/account/identities`, `/api/v2/account/identities/{id}`, `/api/v2/account/identities/{link-ticket,link-complete,link-credentials}` | migrated | | Invitations | `/api/v2/invitations/capabilities`, `/api/v2/invitations/{token}`, `/api/v2/invitations/{token}/accept` | migrated | | Onboarding | `/api/v2/onboarding/{flow,state,progress}` | migrated | | Profiles | `/api/v2/profiles`, `/api/v2/profiles/{id}`, `/api/v2/profiles/{id}/verify-pin` | migrated | diff --git a/docs/auth-api-v2.md b/docs/auth-api-v2.md index a63b3387be..fcf4ddec6a 100644 --- a/docs/auth-api-v2.md +++ b/docs/auth-api-v2.md @@ -2,8 +2,8 @@ The existing phone and TV authentication paths use v2 login, setup, signup, signup status, logout and refresh. Setup status and current account use -`/api/v2/system/setup` and `/api/v2/account/me`. Device login uses v2 start, poll, lookup, capability, -approve, deny and profile-scoped handoff approval. Media credential refresh uses +`/api/v2/system/setup` and `/api/v2/account/me`. Device login uses v2 start, poll, cancel, lookup, +capability, approve, deny and profile-scoped handoff approval. Media credential refresh uses the same v2 refresh route while preserving its captured-origin credential guards. Login accepts200; setup, signup and device start accept201; device polling and @@ -17,9 +17,111 @@ Public auth exchanges omit current bearer/profile credentials. Candidate-server calls retain explicit URLs. Optional request members are omitted when absent by the existing JSON configuration. Login, setup, signup, device start and collecting polls opt out of auth-plugin replay. A successful pending poll schedules the next -protocol poll; a failed or ambiguous poll requires an explicit restart, because -its response may have contained the only issued token pair. Phone and TV prevent -repeated auth submissions while the first is pending. +protocol poll; an approval without usable tokens requires an explicit restart +with a new code, because the server issues the token pair once. Phone and TV +prevent repeated auth submissions while the first is pending. + +## TV device sign-in + +`DeviceSignInMachine` (shared) drives the TV sign-in screen's code; the +setup-mode LAN receiver uses the single-attempt loop in +`DeviceLoginRepository`. Both read every poll answer through +`DeviceLoginPoller`, so they follow the same rules: + +- Poll at once after start, then at `interval` / `poll_after`. Network errors, + 5xx and 429 back off from the interval to 30 s. A 404 or another 4xx on poll + means the server no longer has the request. +- A local deadline starts at `expires_in`. A pending poll answer carries the + request's current `expires_at`, which an approver's lookup extends; the + deadline moves to it, converted to the device's clock through the start + answer's own `expires_at`/`expires_in` pair. Waits are clamped to the + deadline, so the last call before a code is given up is a poll. +- The sign-in screen replaces a code only after a poll: when the server says + it is expired, consumed or canceled, or when a poll at the deadline still + finds it pending, in which case the code is withdrawn with `cancel` first. + A capability read that failed earlier is retried then; the code is withdrawn + unless the capability says `cancel` is false, so a read that fails again + still sends the cancel (a server without it answers 404). It pauses after + an hour without the person doing anything, reports "can't reach" after 10 s + of failed starts or 30 s of failed polls, and "too many requests" after a + minute of 429s, while still retrying. A 404 + or 501 start, or a capability `state` other than `available`, means the + server has no device sign-in: the password form. +- `ON_STOP` ends the wait between polls; a poll already in flight finishes and + an approval being saved completes, because the server hands out the tokens + once. `ON_START` polls a live code at once. Leaving the screen, retrying, + changing server or signing in with a password withdraws the code with + `cancel` (also when the capability couldn't be read; a server without + `cancel` answers 404). A password save that fails leaves the code working. + "Continue on your phone" shows when a poll reports `opened`, which only + servers that advertise `opened_signal` send. +- The code is shown grouped 4+4 with `/activate` and a QR of + `verification_uri_complete`. Match words are never shown on the TV. The + phone's LAN confirmation adds an "Older TV apps show ..." line with them, + like the web `/activate` card, until TV apps that show user codes have + shipped. + +## Server identity and LAN pairing + +Saved servers record the deployment identity verified at their URL +(`ServerEntry.verifiedServerId`, from `GET /api/v2/system/identity`). +`silo://device?server=&url=&code=` links and signed-out +TVs are matched to saved servers by that identity, never by URL spelling; when +no identity can be verified the link's origin is matched instead. A link +without `server` (the web page omits it when the identity is unknown) is +scoped by `url`'s origin, the way an HTTPS device link is. + +A link's code is approved on the saved server it names, through that server's +own profileless account scope, without switching the phone's active server +(silo-apple parity). When the phone isn't signed in there it asks to sign in +first rather than trying another server. Only `token` links, which the active +server's lookup alone accepts, still offer the switch. A link naming a server +the phone hasn't saved offers to add it, with the link's `url` prefilled. + +The approver renews its access token before looking a code up and before +approving or denying: the approval's account read and decision calls are +marked `freshSiloAuth()`, so `SiloAuthPlugin` refreshes that server's scope +first when its token is expiring, under the same lock as the 401 refresh. +On a saved server other than the active one, the card names the account read +through that server's scope; when that read fails it says it couldn't confirm +the account (or that the provider can't be reached, when that was the +reason), offers a retry, and keeps approving disabled. +"Not you?" signs out of that server's account and returns to sign-in, keeping +the pending code; on a server other than the active one it makes that server +active first. As on silo-apple, it reads "Not you? Switch account" when the +next sign-in lets the person choose (a provider that takes `select_account`, +or only the password form) and "Not you? Sign out" otherwise, including when +the server's sign-in options couldn't be read. Where a provider takes +`select_account` and the server lists exactly one provider, the login screen +that follows starts that provider's sign-in by itself, once +(`NativeSignInCoordinator.requestAccountChoice(autoStart = true)`, kept in +memory only and handed back if the screen goes away before the browser +opens). "Sign in" after a 401 does the same without +signing out: it signs in on the server the code was looked up on. + +The LAN pairing protocol matches silo-apple frame for frame +(`PairingProtocolGoldenFramesTest`). A TV on its sign-in screen advertises +`_silopair._tcp` with TXT `st=login` and `srv=`; a phone offers it +only when it holds a signed-in server with that verified identity, skips the +server chooser and pushes only that server. That TV accepts only a push whose +`serverIdentity` equals its `srv` and answers with the code its sign-in screen +already shows; the screen polls and saves the session, so there is only one +request. The TV advertises only while its sign-in screen is visible. +Advertising `st=login` sits behind a rollout gate shared with silo-apple +(`PairingProtocol.advertisesSignInTVs` there, `TvLoginViewModel`'s +`advertisesSignIn`, set from `BuildConfig.DEBUG`, here): on in debug builds, +off in release until the iOS and Android phone apps that accept `login` TVs +have both shipped. A phone that predates them offers a sign-in TV the setup +chooser and pushes servers without an identity, which the TV refuses. First-run +TVs keep `st=setup` and +the chooser, with the phone's active server preselected. `pushServer` carries +`serverIdentity` and `endpoints` (from `GET /api/v2/system/connections`); the +TV probes the pushed address, offers a verified alternate when it can't reach +it, and never signs in at an address that answers with another identity. A +newer push replaces one in flight. `serverResult.error` is a failure code +(`auth_failed`, `denied`, `expired`, `unreachable`, `identity_mismatch`, +`update_required`). People compare the TV's user code; `match_code` stays on +the wire for the phone's consistency check. Before an asynchronous session exchange, callers capture the intended server URL and an account identity generation under the identity transition barrier. Account @@ -36,11 +138,122 @@ Custom TokenManager implementations must implement atomic expectation capture and replacement; the default interface refuses a guarded installation rather than falling back to check-then-write. -Invitation transport remains on its existing v1 domain. Browser OAuth initiation -and callback and plugin launch/proxy are unchanged. Provider listing, OAuth -completion, password management and session-management UI have no active Android -consumer in this slice and are not introduced. Membership runtime activation is -separate; none of its producers or dispatch paths are enabled here. +Invitation transport remains on its existing v1 domain. Plugin launch/proxy is +unchanged. Password management and session-management UI have no active Android +consumer and are not introduced. Membership runtime activation is separate; none +of its producers or dispatch paths are enabled here. External sign-in is described +below. + +## External sign-in (OIDC, LDAP) + +Server contract: silo-server `docs/auth-api.md` ("External sign-in", "OAuth +sign-in flows") and `docs/architecture/external-sign-in.md`. + +What a sign-in screen offers (`SignInOptions`, shared) comes from +`listAuthProviders` and `getOAuthHandshakeCapabilities`, both read without +credentials at the server's saved URL. The password form shows when +`password_login` is true or any credentials provider is listed; a directory +(LDAP) sign-in sends no `provider`, because the server routes a password login +by account. A server that answers without provider discovery keeps the +password form alone. The phone shows no password form while discovery runs; +when the providers or the handshake capability can't be read (a network +failure, a 5xx or a 429), it shows the password form with a retry for the +other options, and the TV keeps its password option. The phone adds a "Sign +in with " button for each oauth provider with a native start +(`native_start_path`; a provider without it offers no native sign-in), on +a server whose handshake capability says `native`, and "Use a different +account" under them when it also says `select_account`. TVs never run a provider sign-in: they hide the password option when the server +lists no credentials provider and `password_login` is false, and sign in with +a phone instead. On a server with an oauth provider the TV's password form +says "If you sign in with , use your phone instead.", and so does +its wrong-password message. + +The phone's native flow (`androidApp/.../auth/`): + +- The native start always opens on the saved server's base URL. The app keeps + only the part of `native_start_path` from + `/api/v2/auth/oauth/{install_id}/native/start` on, with its query, and + appends it to the saved base, path prefix included. Any path prefix the + listing names is ignored, so the app never opens another origin + (`ExternalSignInApi.nativeStartPath`, `NativeSignInProtocol.startUrl`). +- The caller re-reads the saved server's `server_id` (`getServerIdentity`) + and passes it, with the saved URL, to `NativeSignInCoordinator.begin`. + `begin` binds the flow to the saved origin, makes a random `app_state` and + a PKCE S256 verifier, and keeps the flow in encrypted preferences for its + 10-minute lifetime, so it survives the process being killed while the + browser is open. The start URL opens in a Custom Tab, never a WebView. + "Use a different account", and the first provider sign-in on a server + after an explicit Silo sign-out (Settings, the profile menu, "Not you? + Switch account"), add `prompt=select_account` when the handshake + capability says `select_account`; a sign-in after a session expired and a + linking flow never do. +- `NativeSignInCallbackActivity` receives `org.siloserver.silo:/auth/callback` + (scheme-only filter, no `autoVerify`; Android can't match a path without a + host, so `NativeSignInCallback.parse` accepts only that exact path). +- Any app can claim or send the custom scheme, so a redirect counts only when a + flow is pending, not expired, and its `state` matches in constant time. + Anything else changes nothing and leaves the pending flow for the real + redirect. After a match the flow is spent: `iss` (RFC 9207; the server sends + the origin where the native start first arrived) must be present and equal + the saved base's origin, compared in the form a browser sends as `Host` + (lowercase host, IDN names in punycode, IPv6 bracketed and compressed per + RFC 5952, default port omitted), or the code is discarded with + "This sign-in came back from a different server. Nothing was signed in."; + `server` must equal the saved server's verified `server_id`. The code is + redeemed once, at the saved base only and only while the saved server is + still at that origin, with the flow's verifier (`completeOAuthLogin`; + `completeAccountIdentityLink` for a `link=1` redirect, with the session that + asked for the link ticket). +- `iss` closes a relay through the browser: a hostile saved server whose + native start redirects the browser to the real server's native start gets + back a redirect naming the origin where the real server's start arrived, + its own, which is not the saved origin. It covers only relays the server + can't be tricked into recording. A hostile server can request the start + itself with its own origin as `Host`, so the server records a start origin + other than its public URL only where a server outside the local network + can't hold it: the origins of connected network access providers, + loopback, private, link-local and `100.64.0.0/10` IP literals, and + single-label, `.local`, `.lan`, `.localdomain`, `.home.arpa` and + `.internal` names; it refuses any other `Host`. A hostile saved server on + such an address can still relay. The rule and the residual are in + silo-server `docs/architecture/external-sign-in.md` ("Native apps"). +- A sign-in never changes saved servers: a server saved by its LAN address + signs in on that address and stays there. The app never moves, re-keys or + duplicates a saved server because of a provider sign-in. +- `error=` and redemption problems map to text in + `NativeSignInMessages`, including `account_required` ("You don't have an + account on this server yet. Ask an admin to add you."); `invalid_grant` and + `invalid_token` both mean the flow starts again. Codes, verifiers, tickets + and tokens are never logged. + +Account settings "Sign-in" (`SignInSettingsViewModel`) shows only on a server +whose `getExternalSignInCapabilities` says `identities`. It lists the linked +identities and offers Disconnect (`deleteAccountIdentity`) unless the list's +`can_unlink` is false, in which case it says the identity is the account's +only way to sign in; the server still refuses the last sign-in method with +409 `last_sign_in_method`. "Connect" for an oauth provider (handshake `native` +and `linking`) confirms the local password with +`createAccountIdentityLinkTicket` and runs the native flow with `link_ticket`. +"Connect" for a directory (`credentials_linking` in +`getExternalSignInCapabilities`; directory linking needs no handshake) sends +the local password and the directory username and password to +`linkAccountIdentityWithCredentials`. Its two 422 refusals differ only by the +member named (`body.password`, `body.directory_password`); every other refusal +is branched on by problem code, including `local_password_required` and +`already_linked`. + +Password login maps `local_login_disabled`, `not_permitted`, +`password_expired`, `provider_unavailable`, `email_in_use`, +`identity_linked_elsewhere`, `account_required` and 429 to their own messages +on phone and TV. A refresh answered with 503 `provider_unavailable` (the +provider could not re-check the account) keeps the session like any other +5xx; the next request refreshes again. A request that needs a fresh bearer +(approving or declining a TV, on the phone or through a nearby TV, on a +pinned server or the active one; reading the account the approval card names; +a link ticket, a link's confirmation and linking with directory credentials) +whose access token has expired and whose refresh got that answer is not sent; +the screen says the provider can't be reached, that the person is still +signed in, and to try again. ## Email invitation claims diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b26a60ca25..ad24aac5dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ ksp = "2.1.20-2.0.1" room = "2.8.4" androidx-test-core = "1.6.1" androidx-webkit = "1.16.0" +androidx-browser = "1.10.0" bouncycastle = "1.84" profileinstaller = "1.4.1" benchmark = "1.3.4" @@ -61,6 +62,8 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } +# Custom Tabs for external sign-in (phone app only). +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -95,6 +98,8 @@ navigation-compose = { module = "androidx.navigation:navigation-compose", versio # Coil coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +# SVG sign-in provider icons (the first-party OIDC plugin ships one). +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } # Haze — backdrop blur for the phone app's floating tab bar (real blur on # API 31+, tinted scrim fallback below). diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 12c3bf88dd..f9d3b15a02 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -314,6 +314,14 @@ + + + + + + + + @@ -4813,6 +4821,14 @@ + + + + + + + + @@ -6347,6 +6363,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/sync-apiv2-fixtures.sh b/scripts/sync-apiv2-fixtures.sh index ba4c3f5629..1de1ceac71 100755 --- a/scripts/sync-apiv2-fixtures.sh +++ b/scripts/sync-apiv2-fixtures.sh @@ -5,7 +5,8 @@ # Usage: scripts/sync-apiv2-fixtures.sh /path/to/silo-server # # Only the fixtures the Android client consumes are copied (the four pilot -# operations, the probe's system-info body, and the generic problem bodies) +# operations, the probe's system-info body, the device sign-in and server +# identity bodies, the external sign-in bodies, and the generic problem bodies) # plus fixtures.schema.json and an index.json filtered to the vendored # entries. The full OpenAPI document is never vendored. SOURCE records the # exact server commit in the repository's key=value convention. @@ -32,6 +33,25 @@ SELECTED=( rate_limited profile_verification_required not_acceptable + start_device_login_ok + poll_device_login_ok + poll_device_login_opened + cancel_device_login_ok + get_device_login_ok + get_device_login_capability_ok + server_identity_ok + server_connections_ok + list_auth_providers_ok + get_oauth_handshake_capabilities_ok + get_external_sign_in_capabilities_ok + complete_oauth_login_native_ok + complete_oauth_login_invalid_grant + list_account_identities_ok + create_account_identity_link_ticket_ok + create_account_identity_link_ticket_wrong_password + complete_account_identity_link_invalid_grant + delete_account_identity_last_sign_in_method + refresh_session_provider_unavailable ) [ -d "$SRC" ] || { echo "no fixtures at $SRC" >&2; exit 1; } @@ -62,13 +82,26 @@ PY COMMIT="$(git -C "$SERVER_DIR" rev-parse HEAD)" REF="$(git -C "$SERVER_DIR" rev-parse --abbrev-ref HEAD)" +# A checkout whose fixtures differ from its HEAD is a local snapshot: the +# commit named below does not contain these bodies, so SOURCE says so. +SNAPSHOT=committed +if [ -n "$(git -C "$SERVER_DIR" status --porcelain -- contracts/api/v2/fixtures contracts/api/v2/fixtures.schema.json)" ]; then + SNAPSHOT=uncommitted + echo "warning: $SRC has uncommitted changes; SOURCE records a local snapshot" >&2 +fi cat > "$DEST/SOURCE" < + if (entry.id == serverId) entry.copy(verifiedServerId = verifiedServerId) else entry + } + persistAndApplyLocked(updated, _activeServerId.value) + } + } + override suspend fun setFetchedName(serverId: String, fetchedName: String?) { mutex.withLock { val updated = _entries.value.map { entry -> diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt index 9bf5c3bcf2..86d6e66483 100644 --- a/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt @@ -506,22 +506,39 @@ class EncryptedTokenManagerImpl( override suspend fun snapshotDurableLoginAuthority(): DurableLoginAuthority? = tokenWriteMutex.withLock { mutex.withLock { val scope = snapshotCurrentScopeLocked() ?: return@withLock null - if (scope.credentialGenerationId != null || scope.profileId.isNullOrBlank() || - accessToken.isNullOrBlank() || refreshToken.isNullOrBlank() || - scope.serverId in failedAuthorityWrites) return@withLock null - val key = serverScopedKey(scope.serverId, KEY_LOGIN_ID) - var loginId = prefs.getString(key, null) - if (loginId.isNullOrBlank()) { - loginId = java.util.UUID.randomUUID().toString() - pendingAuthorityBootstrap.add(scope.serverId) - } - // A failed SharedPreferences commit may still change its in-memory map. - // Retry that commit before exposing the marker, never trust the map alone. - if (scope.serverId in pendingAuthorityBootstrap) { - if (!prefs.edit().putString(key, loginId).commit()) return@withLock null - pendingAuthorityBootstrap.remove(scope.serverId) - } - DurableLoginAuthority(loginId, scope) + if (scope.credentialGenerationId != null || scope.profileId.isNullOrBlank()) return@withLock null + DurableLoginAuthority(durableLoginIdLocked(scope.serverId) ?: return@withLock null, scope) + } + } + + /** + * The login id of [serverId]'s saved slot, which every account + * replacement writes afresh and every sign-out removes. A slot signed in + * before the id existed gets one here. Null without saved credentials or + * while the slot's last write failed. + */ + private fun durableLoginIdLocked(serverId: String): String? { + if (persistentAccessToken(serverId).isNullOrBlank() || persistentRefreshToken(serverId).isNullOrBlank() || + serverId in failedAuthorityWrites) return null + val key = serverScopedKey(serverId, KEY_LOGIN_ID) + var loginId = prefs.getString(key, null) + if (loginId.isNullOrBlank()) { + loginId = java.util.UUID.randomUUID().toString() + pendingAuthorityBootstrap.add(serverId) + } + // A failed SharedPreferences commit may still change its in-memory map. + // Retry that commit before exposing the marker, never trust the map alone. + if (serverId in pendingAuthorityBootstrap) { + if (!prefs.edit().putString(key, loginId).commit()) return null + pendingAuthorityBootstrap.remove(serverId) + } + return loginId + } + + override suspend fun loginSessionId(serverId: String): String? = tokenWriteMutex.withLock { + mutex.withLock { + ensureCacheMatchesRegistryLocked() + durableLoginIdLocked(serverId) } } @@ -564,6 +581,36 @@ class EncryptedTokenManagerImpl( ) } + /** + * Answers for a saved account's slot only, active or not. A temporary + * overlay's scope answers false: its deadline lives with the overlay and + * [accessTokenExpiresWithin] already covers it. + */ + override suspend fun accessTokenExpiresWithin(scope: AuthScopeSnapshot, marginMs: Long): Boolean { + if (scope.credentialGenerationId != null) return false + return mutex.withLock { + ensureCacheMatchesRegistryLocked() + val serverId = scope.serverId + if (persistentAccessToken(serverId) == null) return@withLock false + val expiry: Long? + val lifetime: Long? + if (serverId == activeServerId) { + expiry = tokenExpiryEpochMs + lifetime = tokenLifetimeMs + } else { + val expiryKey = serverScopedKey(serverId, KEY_TOKEN_EXPIRY) + val lifetimeKey = serverScopedKey(serverId, KEY_TOKEN_LIFETIME) + expiry = if (prefs.contains(expiryKey)) prefs.getLong(expiryKey, 0L) else null + lifetime = if (prefs.contains(lifetimeKey)) prefs.getLong(lifetimeKey, 0L) else null + } + shouldRefreshProactively( + remainingMs = (expiry ?: return@withLock false) - System.currentTimeMillis(), + lifetimeMs = lifetime, + marginMs = marginMs, + ) + } + } + override suspend fun getAccessTokenForScope(serverId: String): String? = mutex.withLock { if (serverId == activeServerId) accessToken else prefs.getString(serverScopedKey(serverId, KEY_ACCESS_TOKEN), null) diff --git a/shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt index eab44b00bf..e656936d09 100644 --- a/shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt +++ b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt @@ -395,6 +395,55 @@ class EncryptedTokenManagerScopeGenerationTest { assertTrue(observed.isEmpty()) } + // accessTokenExpiresWithin: whether the approver renews a scope's token + // before looking a TV's code up (TV sign-in spec). + + private fun key(serverId: String, name: String) = AndroidServerRegistry.serverScopedKey(serverId, name) + + private fun savedSession(serverId: String, expiresInMs: Long?): Array> = listOfNotNull( + key(serverId, EncryptedTokenManagerImpl.KEY_ACCESS_TOKEN) to "$serverId-access", + key(serverId, EncryptedTokenManagerImpl.KEY_REFRESH_TOKEN) to "$serverId-refresh", + expiresInMs?.let { key(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_EXPIRY) to System.currentTimeMillis() + it }, + expiresInMs?.let { key(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_LIFETIME) to 3_600_000L }, + ).toTypedArray() + + private fun scope(serverId: String, credentialGenerationId: String? = null) = + AuthScopeSnapshot(serverId, null, "https://$serverId.example", null, credentialGenerationId = credentialGenerationId) + + @Test + fun theActiveServersNearExpiryAsksForARefresh() = runTest { + val near = EncryptedTokenManagerImpl(inMemoryPreferences(*savedSession("server-a", 10_000L)), FakeServerRegistry()) + assertTrue(near.accessTokenExpiresWithin(scope("server-a"), marginMs = 60_000L)) + + val far = EncryptedTokenManagerImpl(inMemoryPreferences(*savedSession("server-a", 3_000_000L)), FakeServerRegistry()) + assertFalse(far.accessTokenExpiresWithin(scope("server-a"), marginMs = 60_000L)) + } + + @Test + fun aNonActiveServersNearExpiryIsReadFromItsOwnSlot() = runTest { + val manager = EncryptedTokenManagerImpl( + inMemoryPreferences(*savedSession("server-a", 3_000_000L), *savedSession("server-b", 10_000L)), + FakeServerRegistry(), + ) + assertTrue(manager.accessTokenExpiresWithin(scope("server-b"), marginMs = 60_000L)) + assertFalse(manager.accessTokenExpiresWithin(scope("server-a"), marginMs = 60_000L), "the active slot is separate") + } + + @Test + fun aNonActiveServerWithoutAStoredExpiryDoesNotAsk() = runTest { + val manager = EncryptedTokenManagerImpl( + inMemoryPreferences(*savedSession("server-a", 10_000L), *savedSession("server-b", expiresInMs = null)), + FakeServerRegistry(), + ) + assertFalse(manager.accessTokenExpiresWithin(scope("server-b"), marginMs = 60_000L)) + } + + @Test + fun anOverlayScopeDoesNotAsk() = runTest { + val manager = EncryptedTokenManagerImpl(inMemoryPreferences(*savedSession("server-a", 10_000L)), FakeServerRegistry()) + assertFalse(manager.accessTokenExpiresWithin(scope("server-a", credentialGenerationId = "overlay-1"), marginMs = 60_000L)) + } + private class FakeServerRegistry : ServerRegistry { private val serverA = ServerEntry(id = "server-a", url = "https://server-a.example") private val serverB = ServerEntry(id = "server-b", url = "https://server-b.example") diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt index 99e317d10f..ef5fe93cc3 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt @@ -23,6 +23,10 @@ val networkModule = module { single { AuthApi(get(), get()) } single { OnboardingApi(get(), get(), get()) } single { DefaultDeviceLoginApi(get(), get()) } + single { DefaultServerIdentityApi(get()) } + single { + org.siloserver.silo.network.api.DefaultExternalSignInApi(get(), get()) + } single { CatalogV2Api(get(), get(), get()) } single { PersonRefreshV2Api(get(), get(), get()) } single { WatchDetailV2Api(get(), get(), get()) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index 9d0a77b07e..bfdde51bab 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -62,6 +62,8 @@ val repositoryModule = module { } single { OnboardingRepository(get()) } single { DeviceLoginRepository(get()) } + single { org.siloserver.silo.repository.ServerIdentityRepository(get(), get()) } + single { org.siloserver.silo.repository.ExternalSignInRepository(get()) } single { CatalogRepository( catalogApi = get(), diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormat.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormat.kt new file mode 100644 index 0000000000..03569cd3d0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormat.kt @@ -0,0 +1,45 @@ +package org.siloserver.silo.model.auth + +/** + * How device sign-in codes are shown and read. The server issues eight digits + * (`4821-7730`); older servers issue eight letters and digits (`ABCD-EFGH`). + * Both are shown grouped 4+4 with a space and typed without separators; the + * server ignores spaces and dashes on lookup. + */ +object DeviceCodeFormat { + /** Maximum characters in a typed code, separators excluded. */ + const val LENGTH = 8 + + /** `4821-7730` → `4821 7730`. Anything that isn't eight characters is shown as sent, dashes as spaces. */ + fun display(userCode: String): String { + val compact = normalize(userCode) + return if (compact.length == LENGTH) { + compact.substring(0, 4) + " " + compact.substring(4) + } else { + userCode.trim().replace('-', ' ') + } + } + + /** Strips spaces and dashes and uppercases, the form the server compares. */ + fun normalize(input: String): String = + input.filter { it.isLetterOrDigit() }.uppercase() + + /** One character per word, so a screen reader reads `4 8 2 1 7 7 3 0`, not a number. */ + fun spoken(userCode: String): String = normalize(userCode).toList().joinToString(" ") + + /** + * The typed-URL form of the verification address: `silo.example.com/activate`, + * without the scheme (an `http://` address keeps it, since the browser + * needs it). Falls back to the complete URI without its query. + */ + fun activateText(verificationUri: String, verificationUriComplete: String): String { + val raw = verificationUri.takeIf { it.isNotBlank() } + ?: verificationUriComplete.substringBefore('?') + val trimmed = raw.trim().trimEnd('/') + return if (trimmed.startsWith("https://", ignoreCase = true)) trimmed.substring("https://".length) else trimmed + } + + /** `silo.example.com` from a verification address, for "Can't reach …" copy. */ + fun host(url: String): String = + url.trim().substringAfter("://").substringBefore('/').substringBefore('?') +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceLoginModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceLoginModels.kt index 7ebd3a57e6..7691bc082c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceLoginModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/DeviceLoginModels.kt @@ -57,6 +57,17 @@ data class DeviceLoginPollResponse( @SerialName("profile_token") val profileToken: String? = null, val temporary: Boolean? = null, @SerialName("session_expires_at") val sessionExpiresAt: String? = null, + /** + * A pending request an approver has looked up. Servers that predate the + * opened signal never send it, so absent reads as not opened. + */ + val opened: Boolean = false, + /** + * The request's current expiry on a pending answer. A lookup on the + * approving device extends it, so the device moves its local deadline to + * it rather than replacing a code someone is approving. + */ + @SerialName("expires_at") val expiresAt: String? = null, ) @Serializable @@ -68,14 +79,34 @@ data class DeviceLoginLookupResponse( @SerialName("device_platform") val devicePlatform: String? = null, @SerialName("ip_address_hint") val ipAddressHint: String? = null, @SerialName("expires_at") val expiresAt: String? = null, + /** When the TV asked, for the approval card's "just now". */ + @SerialName("requested_at") val requestedAt: String? = null, @SerialName("client_purpose") val clientPurpose: String? = null, val temporary: Boolean? = null, + /** Deployment identity of the issuing server, when it knows one. */ + @SerialName("server_id") val serverId: String? = null, + /** Display name of the issuing server, for the approval card. */ + @SerialName("server_name") val serverName: String? = null, ) +/** + * `GET /auth/device/capability`. [deviceLoginAvailable] is false when the + * server reports device sign-in is not configured; [cancel] and + * [openedSignal] report the two Phase 1 additions and read false on servers + * that predate them. + */ @Serializable data class DeviceLoginCapabilityResponse( @SerialName("remote_playback_handoff") val remotePlaybackHandoff: Boolean = false, @SerialName("protocol_versions") val protocolVersions: List = emptyList(), + val deviceLoginAvailable: Boolean = true, + val cancel: Boolean = false, + val openedSignal: Boolean = false, +) + +@Serializable +data class DeviceLoginCancelResponse( + val status: String, ) @Serializable @@ -90,7 +121,7 @@ data class DeviceLoginDecisionResponse( ) enum class DeviceLoginStatus { - Pending, Approved, Denied, Expired, Consumed, Unknown; + Pending, Approved, Denied, Expired, Consumed, Canceled, Unknown; companion object { fun fromWire(raw: String): DeviceLoginStatus = when (raw) { @@ -99,6 +130,7 @@ enum class DeviceLoginStatus { "denied" -> Denied "expired" -> Expired "consumed" -> Consumed + "canceled" -> Canceled else -> Unknown } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt new file mode 100644 index 0000000000..6c78b65d7c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt @@ -0,0 +1,202 @@ +package org.siloserver.silo.model.auth + +/** + * One way to sign in that the server lists before login + * (`GET /api/v2/auth/providers`). See silo-server `docs/auth-api.md` + * ("External sign-in") and `docs/architecture/external-sign-in.md`. + */ +data class SignInProvider( + /** The value `login` takes as `provider`; apps never send it (the server routes by account). */ + val id: String, + val displayName: String, + val mode: Mode, + val isDefault: Boolean, + /** Absolute icon URL, or null when the provider ships none. */ + val iconUrl: String?, + val installationId: String?, + /** + * `startNativeOAuthLogin` relative to the server base + * (`/api/v2/auth/oauth/{id}/native/start`); oauth providers only. Apps + * open it on their own saved base URL, never on the origin the server + * listed it with. + */ + val nativeStartPath: String?, +) { + enum class Mode { Credentials, OAuth, Unknown } +} + +/** `listAuthProviders`: the providers plus whether any of them takes a password. */ +data class SignInProviders( + val providers: List, + val passwordLogin: Boolean, +) + +/** `getOAuthHandshakeCapabilities`, reduced to what the apps use. */ +data class OAuthHandshakeCapabilities( + val available: Boolean, + /** `startNativeOAuthLogin` serves apps (the `org.siloserver.silo:/auth/callback` handoff). */ + val native: Boolean, + /** Link tickets and `completeAccountIdentityLink` are served. */ + val linking: Boolean, + /** Sign-in starts take `prompt=select_account` ("Use a different account", "Not you? Switch account"). */ + val selectAccount: Boolean = false, +) { + companion object { + val None = OAuthHandshakeCapabilities(available = false, native = false, linking = false) + } +} + +/** `getExternalSignInCapabilities`, reduced to what the apps use. */ +data class ExternalSignInCapabilities( + /** `listAccountIdentities` and `deleteAccountIdentity` are served: Settings shows "Sign-in". */ + val identities: Boolean, + /** + * `linkAccountIdentityWithCredentials` is served: an account links a + * directory (LDAP) identity with its directory username and password. + * Directory linking needs no OAuth handshake, so this document reports it. + */ + val credentialsLinking: Boolean = false, +) + +/** + * What a sign-in screen offers for one server. + * + * - [oauthProviders]: a "Sign in with " button each. Only providers the + * app can run natively: an oauth provider with a native start path, on a + * server whose handshake capability says `native`. + * - [showPasswordForm]: the username and password form. Shown when any listed + * provider takes a password (the local one, or a directory such as LDAP, + * which the server reaches without a provider field) and hidden only when + * the server says no provider does. + */ +data class SignInOptions( + val oauthProviders: List, + val showPasswordForm: Boolean, + /** A non-local credentials provider (LDAP) is enabled: its people use the password form too. */ + val directoryProvider: SignInProvider?, + /** A provider sign-in may ask the provider to offer another account (`prompt=select_account`). */ + val selectAccount: Boolean = false, +) { + companion object { + /** A server that predates provider discovery: the password form alone. */ + val PasswordOnly = SignInOptions(oauthProviders = emptyList(), showPasswordForm = true, directoryProvider = null) + + /** + * What [providers] offer through the app, given the server's OAuth + * [handshake]. The account's Sign-in settings build on the same + * filters (linking adds its own capability gates). + */ + fun of(providers: SignInProviders, handshake: OAuthHandshakeCapabilities): SignInOptions { + val oauth = if (handshake.available && handshake.native) { + providers.providers.filter { + it.mode == SignInProvider.Mode.OAuth && !it.nativeStartPath.isNullOrBlank() + } + } else { + emptyList() + } + val credentials = providers.providers.filter { it.mode == SignInProvider.Mode.Credentials } + return SignInOptions( + oauthProviders = oauth, + showPasswordForm = providers.passwordLogin || credentials.isNotEmpty(), + directoryProvider = credentials.firstOrNull { it.id != LOCAL_PROVIDER_ID }, + selectAccount = oauth.isNotEmpty() && handshake.selectAccount, + ) + } + + const val LOCAL_PROVIDER_ID = "local" + } +} + +/** One external identity linked to the signed-in account (`listAccountIdentities`). */ +data class AccountIdentity( + val id: String, + val installationId: String, + /** Empty while that provider is not enabled. */ + val providerId: String, + /** Empty while that provider is not enabled. */ + val providerName: String, + val username: String, + val email: String, + val displayName: String, + /** RFC 3339 instant. */ + val linkedAt: String, + /** RFC 3339 instant, or null before the first sign-in through it. */ + val lastSignInAt: String?, +) { + /** The name to show for the person at the provider: username, then email, then display name. */ + val accountLabel: String + get() = username.ifBlank { email.ifBlank { displayName } } +} + +/** `listAccountIdentities`: the account's identities and whether one may be disconnected now. */ +data class AccountIdentities( + val items: List, + /** + * Whether `deleteAccountIdentity` would disconnect an identity now (the + * account has another identity, or its local password still signs in). + * Null from a server that doesn't say; Disconnect is then offered and the + * server's refusal explains. + */ + val canUnlink: Boolean?, +) + +/** A single-use ticket that starts one linking flow (`createAccountIdentityLinkTicket`). */ +data class AccountIdentityLinkTicket( + val ticket: String, + val expiresAt: String, +) { + override fun toString(): String = "AccountIdentityLinkTicket(ticket=, expiresAt=$expiresAt)" +} + +/** + * Why a password sign-in (`login`) was refused, from the problem code first + * and the status second (silo-server `docs/auth-api.md`, "External sign-in": + * `local_login_disabled`, `not_permitted` and `password_expired` are 403, + * `provider_unavailable` 503). Phone and TV map each to their own copy. + */ +enum class PasswordLoginFailure { + InvalidCredentials, + + /** Password sign-in is off on this server and the account is not break-glass. */ + LocalLoginDisabled, + + /** The directory accepted the password but its group rules don't admit the account. */ + NotPermitted, + + /** The directory says the password has expired. */ + PasswordExpired, + AccountDisabled, + + /** The directory (LDAP) could not be reached. */ + ProviderUnavailable, + + /** A first directory sign-in found another account with the same email (409). */ + EmailInUse, + + /** The directory identity is linked to another account (409). */ + IdentityLinkedElsewhere, + + /** Too many attempts (429). */ + RateLimited, + + /** A first directory sign-in found no account and the server doesn't create one (`account_required`). */ + AccountRequired, + Other; + + companion object { + fun of(code: Int, problem: String): PasswordLoginFailure = when { + problem == "local_login_disabled" -> LocalLoginDisabled + problem == "not_permitted" -> NotPermitted + problem == "password_expired" -> PasswordExpired + problem == "provider_unavailable" -> ProviderUnavailable + problem == "email_in_use" -> EmailInUse + problem == "identity_linked_elsewhere" -> IdentityLinkedElsewhere + problem == "account_required" -> AccountRequired + problem == "rate_limited" || code == 429 -> RateLimited + code == 401 -> InvalidCredentials + code == 403 -> AccountDisabled + code == 503 -> ProviderUnavailable + else -> Other + } + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/server/ServerEntry.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/server/ServerEntry.kt index d77f4ff887..89fda7b926 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/server/ServerEntry.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/server/ServerEntry.kt @@ -25,6 +25,13 @@ data class ServerEntry( val lastUsedAtEpochMs: Long = 0L, /** Native API contract learned on connect; see [ServerContract]. */ val contract: ServerContract = ServerContract.UNKNOWN, + /** + * Deployment identity (`GET /api/v2/system/identity`) last verified at + * [url]. One deployment answers the same id at every address, so this — + * not URL spelling — is how a TV's advertisement or a `silo://device` + * link is matched to a saved server. Null until verified. + */ + val verifiedServerId: String? = null, ) { val displayName: String get() = userOverrideName?.takeIf { it.isNotBlank() } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt index d0abcd8734..72b5d415e5 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.siloserver.silo.model.auth.RefreshRequest import org.siloserver.silo.model.auth.RefreshResponse +import org.siloserver.silo.network.apiv2.Problem +import io.ktor.client.statement.bodyAsText /** * How close to expiry an access token may get before a request refreshes it @@ -50,6 +52,12 @@ internal enum class RefreshOutcome { * concurrent traffic amplifies the outage it is already suffering. */ FailedTransient, + + /** + * A [FailedTransient] the server explained: 503 `provider_unavailable`, + * the sign-in provider couldn't re-check the session. The session stays. + */ + ProviderUnavailable, } /** @@ -240,6 +248,11 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { if (!refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { // Gateway/proxy/server failure: the credentials may well // still be good, so the caller may spend them as before. + if (refreshResponse.status == HttpStatusCode.ServiceUnavailable && + isProviderUnavailableProblem(runCatching { refreshResponse.bodyAsText() }.getOrNull()) + ) { + return@withLock RefreshOutcome.ProviderUnavailable + } return@withLock RefreshOutcome.FailedTransient } run { @@ -283,6 +296,83 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { } } + /** + * One refresh of a pinned (background or other-server) [pinnedScope], + * serialised on [refreshMutex]. Never invalidates the active UI session: a + * rejected refresh token is only remembered so it isn't spent again. Tells + * a refresh the server refused with 503 + * `provider_unavailable` (the provider re-check couldn't reach the + * provider under `fail_closed`) apart from any other failure: the session + * is still valid and the caller can say so. + * + * @return [PinnedRefresh.Refreshed] when [pinnedScope] now holds a token + * other than [sentAuth]. + */ + suspend fun refreshPinnedScope(pinnedScope: AuthScopeSnapshot, sentAuth: String?): PinnedRefresh { + val pinnedGeneration = pinnedScope.credentialGenerationId + return refreshMutex.withLock { + // Another path may have refreshed this scope while we waited. + val current = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } + if (current != null && current != sentAuth) { + return@withLock PinnedRefresh.Refreshed + } + val refreshToken = tokenManager.getRefreshTokenForScope(pinnedScope) + if (refreshToken.isNullOrBlank() || pinnedScope.serverUrl.isBlank() || + refreshToken in rejectedPinnedRefreshTokens.value + ) { + return@withLock PinnedRefresh.Failed + } + try { + diagnosticsObserver.safeAuthRefresh("started") + val refreshResponse = client.post("${pinnedScope.serverUrl}/api/v2/auth/refresh") { + contentType(ContentType.Application.Json) + setBody(RefreshRequest(refreshToken)) + } + if (refreshResponse.status == HttpStatusCode.OK) { + diagnosticsObserver.safeAuthRefresh("succeeded") + val tokens = refreshResponse.body() + tokenManager.saveTokensForScope( + scope = pinnedScope, + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + expiresIn = tokens.expiresIn, + ) + // A temporary credential generation may have ended while + // refresh was in flight. Its token manager deliberately + // drops that stale response instead of falling through to + // the saved account, so retry only when the exact scope now + // exposes the rotated token. + val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } + if (after != null && after != sentAuth) PinnedRefresh.Refreshed else PinnedRefresh.Failed + } else { + diagnosticsObserver.safeAuthRefresh("failed") + if (refreshResponse.status == HttpStatusCode.ServiceUnavailable && + isProviderUnavailableProblem(runCatching { refreshResponse.bodyAsText() }.getOrNull()) + ) { + return@withLock PinnedRefresh.ProviderUnavailable + } + if (refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { + if (pinnedGeneration != null) { + deadCredentialGenerations.update { it + pinnedGeneration } + } else { + rejectedPinnedRefreshTokens.update { it + refreshToken } + } + } + // Don't invalidate the active session for a background scope. + // Re-check in case a concurrent path refreshed it in flight. + val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } + if (after != null && after != sentAuth) PinnedRefresh.Refreshed else PinnedRefresh.Failed + } + } catch (e: Throwable) { + diagnosticsObserver.safeAuthRefresh("failed") + PinnedRefresh.Failed + } + } + } + + suspend fun refreshPinnedScopeOnce(pinnedScope: AuthScopeSnapshot, sentAuth: String?): Boolean = + refreshPinnedScope(pinnedScope, sentAuth) == PinnedRefresh.Refreshed + onRequest { request, _ -> val skipAuth = request.attributes.getOrNull(SkipSiloAuthAttributeKey) == true val requireAuth = request.attributes.getOrNull(RequireSiloAuthAttributeKey) == true @@ -458,14 +548,53 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { request.removeSiloCredentialHeaders() return@on proceed(request) } - if (request.attributes.getOrNull(SingleAttemptAttributeKey) == true) return@on proceed(request) + val singleAttempt = request.attributes.getOrNull(SingleAttemptAttributeKey) == true + if (singleAttempt && pinnedScope == null) return@on proceed(request) if (pinnedScope != null) { - val sentAuth = request.headers[HttpHeaders.Authorization] + var sentAuth = request.headers[HttpHeaders.Authorization] + val pinnedGeneration = pinnedScope.credentialGenerationId + // Refresh first when the caller asked for a fresh bearer (an approver + // about to approve a TV, a single-use link confirmation) and this + // scope's token is expiring. Same refresh as the 401 path below, so + // a rotation can't race it. This runs for single-attempt requests + // too: it happens before the one request is sent, not as a retry. + if ( + request.attributes.getOrNull(FreshSiloAuthAttributeKey) == true && + sentAuth != null && + (pinnedGeneration == null || pinnedGeneration !in deadCredentialGenerations.value) && + tokenManager.accessTokenExpiresWithin(pinnedScope, PROACTIVE_REFRESH_MARGIN_MS) + ) { + diagnosticsObserver.safeAuthRefresh("required") + when (refreshPinnedScope(pinnedScope, sentAuth)) { + PinnedRefresh.Refreshed -> + tokenManager.getAccessTokenForScope(pinnedScope)?.let { newToken -> + request.headers.remove(HttpHeaders.Authorization) + request.header(HttpHeaders.Authorization, "Bearer $newToken") + sentAuth = "Bearer $newToken" + } + // The provider couldn't re-check the session. It stays valid, + // but an expired bearer would only come back 401 and read as + // "sign in again": fail the call with the reason instead. + PinnedRefresh.ProviderUnavailable -> + if (tokenManager.accessTokenExpiresWithin(pinnedScope, 0L)) { + request.removeSiloCredentialHeaders() + throw SiloAuthUnavailableException(SiloAuthUnavailableException.PROVIDER_UNAVAILABLE) + } + // The scope may have been signed out or replaced meanwhile: + // send the captured bearer only while the scope still holds it. + PinnedRefresh.Failed -> + if (tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } != sentAuth) { + request.removeSiloCredentialHeaders() + throw SiloAuthUnavailableException(SiloAuthUnavailableException.CREDENTIALS_REPUDIATED) + } + } + } + // Exactly one request: a single-attempt call never replays after a 401. + if (singleAttempt) return@on proceed(request) val originalCall = proceed(request) if (originalCall.response.status != HttpStatusCode.Unauthorized) { return@on originalCall } - val pinnedGeneration = pinnedScope.credentialGenerationId if (pinnedGeneration != null && pinnedGeneration in deadCredentialGenerations.value) { // Already-rejected temporary credentials: surface the 401 instead of // re-refreshing them for every pinned op (progress ticks, teardown). @@ -477,59 +606,7 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { return@on originalCall } diagnosticsObserver.safeAuthRefresh("required") - val refreshed = refreshMutex.withLock { - // Another path may have refreshed this scope while we waited. - val current = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } - if (current != null && current != sentAuth) { - return@withLock true - } - val refreshToken = tokenManager.getRefreshTokenForScope(pinnedScope) - if (refreshToken.isNullOrBlank() || pinnedScope.serverUrl.isBlank() || - refreshToken in rejectedPinnedRefreshTokens.value - ) { - return@withLock false - } - try { - diagnosticsObserver.safeAuthRefresh("started") - val refreshResponse = client.post("${pinnedScope.serverUrl}/api/v2/auth/refresh") { - contentType(ContentType.Application.Json) - setBody(RefreshRequest(refreshToken)) - } - if (refreshResponse.status == HttpStatusCode.OK) { - diagnosticsObserver.safeAuthRefresh("succeeded") - val tokens = refreshResponse.body() - tokenManager.saveTokensForScope( - scope = pinnedScope, - accessToken = tokens.accessToken, - refreshToken = tokens.refreshToken, - expiresIn = tokens.expiresIn, - ) - // A temporary credential generation may have ended while - // refresh was in flight. Its token manager deliberately - // drops that stale response instead of falling through to - // the saved account, so retry only when the exact scope now - // exposes the rotated token. - val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } - after != null && after != sentAuth - } else { - diagnosticsObserver.safeAuthRefresh("failed") - if (refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { - if (pinnedGeneration != null) { - deadCredentialGenerations.update { it + pinnedGeneration } - } else { - rejectedPinnedRefreshTokens.update { it + refreshToken } - } - } - // Don't invalidate the active session for a background scope. - // Re-check in case a concurrent path refreshed it in flight. - val after = tokenManager.getAccessTokenForScope(pinnedScope)?.let { "Bearer $it" } - after != null && after != sentAuth - } - } catch (e: Throwable) { - diagnosticsObserver.safeAuthRefresh("failed") - false - } - } + val refreshed = refreshPinnedScopeOnce(pinnedScope, sentAuth) return@on if (refreshed) { tokenManager.getAccessTokenForScope(pinnedScope)?.let { newToken -> request.headers.remove(HttpHeaders.Authorization) @@ -620,10 +697,22 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { ) } + // The provider couldn't re-check the session. It stays valid, + // but a request that needs a fresh bearer would only come + // back 401 on an expired one and read as "sign in again": + // fail it with the reason instead, as the pinned path does. + RefreshOutcome.ProviderUnavailable -> + if (request.attributes.getOrNull(FreshSiloAuthAttributeKey) == true && + tokenManager.accessTokenExpiresWithin(0L) + ) { + request.removeSiloCredentialHeaders() + throw SiloAuthUnavailableException(SiloAuthUnavailableException.PROVIDER_UNAVAILABLE) + } + RefreshOutcome.NotAttempted, RefreshOutcome.FailedTransient -> Unit } proactiveRefreshFailedTransiently = - earlyOutcome == RefreshOutcome.FailedTransient + earlyOutcome == RefreshOutcome.FailedTransient || earlyOutcome == RefreshOutcome.ProviderUnavailable // This request's bearer was captured BEFORE the refresh mutex was // waited on. In that window another coroutine can have signed out, @@ -779,6 +868,25 @@ private fun HttpRequestBuilder.applyProfileHeaders( private suspend fun TokenManager.temporaryGenerationId(): String? = snapshotCurrentScope()?.credentialGenerationId +/** How one pinned-scope refresh ended. */ +enum class PinnedRefresh { + /** The scope now holds a new token. */ + Refreshed, + + /** 503 `provider_unavailable`: the session is kept, try again later. */ + ProviderUnavailable, + + /** Anything else; the caller spends what it has. */ + Failed, +} + +/** Whether [body] is the v2 problem `provider_unavailable`. */ +internal fun isProviderUnavailableProblem(body: String?): Boolean { + if (body.isNullOrBlank()) return false + val problem = runCatching { SiloJson.decodeFromString(Problem.serializer(), body) }.getOrNull() ?: return false + return problem.code == SiloAuthUnavailableException.PROVIDER_UNAVAILABLE_PROBLEM +} + private fun HttpStatusCode.shouldInvalidateSessionAfterRefreshFailure(): Boolean = this == HttpStatusCode.BadRequest || this == HttpStatusCode.Unauthorized || diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt index bac1372b11..9e0585d87a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthScopeSnapshot.kt @@ -64,6 +64,24 @@ data class AuthScopeSnapshot( current.identityGeneration == identityGeneration && current.credentialEpoch == credentialEpoch + companion object { + /** + * The account scope, with no profile, for a call to a saved server that + * need not be the active one (approving a TV, reading its connections). + * Such a scope can't carry the token manager's live persistent-credential + * epoch, so the request is pinned to [identityGeneration] instead: a late + * refresh can't overwrite a same-server account replacement. + */ + fun profileless(serverId: String, serverUrl: String, identityGeneration: Long) = AuthScopeSnapshot( + serverId = serverId, + serverUrl = serverUrl, + profileId = null, + profileToken = null, + identityGeneration = identityGeneration, + isIdentityGenerationStamped = true, + ) + } + override fun toString(): String = "AuthScopeSnapshot(" + "serverId=, profileId=, serverUrl=, " + @@ -107,6 +125,21 @@ fun HttpRequestBuilder.skipSiloAuth() { attributes.put(SkipSiloAuthAttributeKey, true) } +/** + * Marks a request that must go out on a fresh bearer. Pinned ([authScope]): + * when the scope's access token has expired or is about to, [SiloAuthPlugin] + * refreshes it first rather than waiting for a 401. Both pinned and on the + * active session: when that refresh is refused with 503 + * `provider_unavailable` and the token has already expired, the request + * isn't sent and fails with [SiloAuthUnavailableException.PROVIDER_UNAVAILABLE]. + * Used where the TV sign-in spec has the approver refresh before approving + * or declining a TV (and reading the account the card names), and for the + * account's linking operations: the link ticket, a link's confirmation and + * linking with directory credentials. + */ +internal val FreshSiloAuthAttributeKey: AttributeKey = AttributeKey("SiloFreshAuth") +internal fun HttpRequestBuilder.freshSiloAuth() { attributes.put(FreshSiloAuthAttributeKey, true) } + /** A nonretryable mutation must not be replayed by the auth-refresh interceptor. */ internal val SingleAttemptAttributeKey = AttributeKey("SiloSingleAttempt") internal fun HttpRequestBuilder.singleAttempt() { attributes.put(SingleAttemptAttributeKey, true) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/ServerRegistry.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/ServerRegistry.kt index 3d6d795752..58a418ef1d 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/ServerRegistry.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/ServerRegistry.kt @@ -56,6 +56,12 @@ interface ServerRegistry { */ suspend fun setContract(serverId: String, contract: ServerContract) {} + /** + * Records the deployment identity verified at [serverId]'s URL. Default is + * a no-op so single-purpose fakes need not track it. + */ + suspend fun setVerifiedServerId(serverId: String, verifiedServerId: String?) {} + /** Remove an entry entirely. If it was active, fall back to the next-MRU server. */ suspend fun remove(serverId: String) @@ -73,3 +79,15 @@ interface ServerRegistry { /** Bump the active server's lastUsedAt — call after a successful API hit. */ suspend fun touchActive() } + +/** + * Saved servers this device holds an access token for, the active one first, + * then most recently used: the servers a phone can approve a TV on or push to + * a nearby one. + */ +suspend fun ServerRegistry.signedInEntries(tokenManager: TokenManager): List { + val activeId = activeServerId.value + return entries.value + .filter { it.url.isNotBlank() && !tokenManager.getAccessTokenForScope(it.id).isNullOrBlank() } + .sortedWith(compareByDescending { it.id == activeId }.thenByDescending { it.lastUsedAtEpochMs }) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloAuthUnavailableException.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloAuthUnavailableException.kt index fc4455c322..fb5fa0cca8 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloAuthUnavailableException.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloAuthUnavailableException.kt @@ -11,6 +11,12 @@ package org.siloserver.silo.network * is already torn down. The access token may still have time left, which is * exactly the danger: the server could honour a write for a session the app * has ended. + * - [PROVIDER_UNAVAILABLE] — a request that needs a fresh bearer + * ([freshSiloAuth]: approving or declining a TV, a link ticket, a link's + * confirmation, linking with directory credentials) found its access token + * expired, and the refresh was refused with 503 `provider_unavailable`: the + * sign-in provider couldn't re-check the session. The session stays; the + * caller says so and the person retries. * * Subclasses [IllegalStateException] because that is what the required-auth * path has always thrown, so existing handlers keep working. Callers that @@ -23,5 +29,13 @@ class SiloAuthUnavailableException(val reason: String) : IllegalStateException(r companion object { const val REQUIRED_AUTH_UNAVAILABLE = "required_silo_auth_unavailable" const val CREDENTIALS_REPUDIATED = "silo_auth_credentials_repudiated" + const val PROVIDER_UNAVAILABLE = "silo_auth_provider_unavailable" + + /** The v2 problem code the refresh answers. */ + const val PROVIDER_UNAVAILABLE_PROBLEM = "provider_unavailable" + + /** Whether [error] is a [PROVIDER_UNAVAILABLE] refusal. */ + fun isProviderUnavailable(error: Throwable?): Boolean = + (error as? SiloAuthUnavailableException)?.reason == PROVIDER_UNAVAILABLE } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt index 1a4e725fab..f619ccadec 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt @@ -232,6 +232,14 @@ interface TokenManager { */ suspend fun accessTokenExpiresWithin(marginMs: Long): Boolean = false + /** + * [accessTokenExpiresWithin] for a saved account's [scope], which need not + * be the active server: a phone approving a TV on another saved server + * renews that server's token first. Same clamping rules. Default false, so + * an implementation that can't answer keeps the reactive 401 refresh. + */ + suspend fun accessTokenExpiresWithin(scope: AuthScopeSnapshot, marginMs: Long): Boolean = false + /** * Capture the currently-active scope for pinning a background request. Returns * null when no server is active or the implementation isn't multi-server-aware. @@ -240,6 +248,14 @@ interface TokenManager { */ suspend fun snapshotCurrentScope(): AuthScopeSnapshot? = null + /** + * The durable id of the login whose credentials [serverId]'s saved slot + * holds: new on every sign-in there, gone on sign-out, and unchanged + * across process restarts, refreshes and profile switches. Null when that + * server has no saved credentials, or the implementation keeps no such id. + */ + suspend fun loginSessionId(serverId: String): String? = null + /** * Read a specific server's latest access token (handles rotation). Default * delegates to the active-scope [getAccessToken] for single-scope impls. diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthApi.kt index a650154ba7..7b85b81857 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthApi.kt @@ -8,6 +8,9 @@ import io.ktor.http.* import org.siloserver.silo.model.auth.* import org.siloserver.silo.network.ApiErrorBody import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.authScope +import org.siloserver.silo.network.freshSiloAuth import org.siloserver.silo.network.map import org.siloserver.silo.network.singleAttempt import org.siloserver.silo.network.skipSiloAuth @@ -116,6 +119,20 @@ class AuthApi( suspend fun getMe(): ApiResult = safeApiV2Call(apiV2Gate) { client.get("/api/v2/account/me") }.map { account -> account.toUser() } + /** + * The account signed in on [scope]'s server, which need not be the active + * one. Read by a phone about to approve a TV, so the scope's access token is + * renewed first when it has expired or is about to (TV sign-in spec). + */ + suspend fun getMe(scope: AuthScopeSnapshot): ApiResult = + // Gated on the scope's own server, not the active one. + safeApiV2Call(apiV2Gate.forServer(scope.serverId)) { + client.get("/api/v2/account/me") { + authScope(scope) + freshSiloAuth() + } + }.map { account -> account.toUser() } + suspend fun logout(): ApiResult = safeApiV2Call(apiV2Gate) { client.post("/api/v2/auth/logout").requireAuthStatus(204) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthWireV2.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthWireV2.kt index 173ea044f5..78f70b93f7 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthWireV2.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/AuthWireV2.kt @@ -50,9 +50,12 @@ internal data class DevicePollV2( val temporary: Boolean, val tokens: TokenPairV2? = null, @SerialName("session_expires_at") val sessionExpiresAt: String? = null, + val opened: Boolean = false, + @SerialName("expires_at") val expiresAt: String? = null, ) { fun domain() = DeviceLoginPollResponse(status, pollAfter, tokens?.accessToken, tokens?.refreshToken, - tokens?.expiresIn, tokens?.user?.domain(), profileId, profileToken, temporary, sessionExpiresAt) + tokens?.expiresIn, tokens?.user?.domain(), profileId, profileToken, temporary, sessionExpiresAt, opened, + expiresAt = expiresAt) } @Serializable @@ -61,8 +64,16 @@ internal data class DeviceCapabilityV2( val state: String, @SerialName("remote_playback_handoff") val handoff: Boolean, @SerialName("protocol_versions") val protocols: List, + val cancel: Boolean = false, + @SerialName("opened_signal") val openedSignal: Boolean = false, ) { - fun domain() = DeviceLoginCapabilityResponse(state == "available" && handoff, protocols) + fun domain() = DeviceLoginCapabilityResponse( + remotePlaybackHandoff = state == "available" && handoff, + protocolVersions = protocols, + deviceLoginAvailable = state == "available", + cancel = state == "available" && cancel, + openedSignal = state == "available" && openedSignal, + ) } internal fun HttpResponse.requireAuthStatus(expected: Int): HttpResponse = also { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DeviceLoginApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DeviceLoginApi.kt index dd417c642e..936340bf57 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DeviceLoginApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DeviceLoginApi.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.network.api +import org.siloserver.silo.model.auth.DeviceLoginCancelResponse import org.siloserver.silo.model.auth.DeviceLoginPollRequest import org.siloserver.silo.model.auth.DeviceLoginPollResponse import org.siloserver.silo.model.auth.DeviceLoginDecisionRequest @@ -15,6 +16,7 @@ import org.siloserver.silo.network.singleAttempt import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.authScope +import org.siloserver.silo.network.freshSiloAuth import org.siloserver.silo.network.skipSiloAuth import io.ktor.client.HttpClient import io.ktor.client.request.get @@ -71,6 +73,25 @@ interface DeviceLoginApi { message = "Remote playback handoff is not supported.", ) + /** + * The device-pairing capability document at [serverUrl]: whether device + * sign-in is configured, and whether the server supports cancel and the + * opened signal. Same document [remotePlaybackCapabilityAt] reads. + */ + suspend fun deviceLoginCapabilityAt( + serverUrl: String, + ): ApiResult = remotePlaybackCapabilityAt(serverUrl) + + /** + * Withdraw this device's own request (`POST /auth/device/cancel`) so an + * abandoned code can't be approved later. Only call it when the + * capability reports `cancel`. + */ + suspend fun cancelDeviceLoginAt( + serverUrl: String, + deviceCode: String, + ): ApiResult = unsupportedScopedDeviceLoginOperation() + suspend fun startRemotePlaybackAt( serverUrl: String, deviceName: String?, @@ -182,6 +203,22 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap }.requireAuthStatus(200) }.map { it.domain() } + override suspend fun deviceLoginCapabilityAt( + serverUrl: String, + ): ApiResult = remotePlaybackCapabilityAt(serverUrl) + + override suspend fun cancelDeviceLoginAt( + serverUrl: String, + deviceCode: String, + ): ApiResult = safeApiV2Call(ApiV2Gate.Unrestricted) { + client.post("${serverUrl.trimEnd('/')}/api/v2/auth/device/cancel") { + skipSiloAuth() + singleAttempt() + contentType(ContentType.Application.Json) + setBody(DeviceLoginPollRequest(deviceCode)) + }.requireAuthStatus(200) + } + override suspend fun startRemotePlaybackAt( serverUrl: String, deviceName: String?, @@ -218,6 +255,8 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap code: String?, ): ApiResult = safeApiV2Call(gate) { client.post("/api/v2/auth/device/approve") { + // The approver refreshes before deciding (TV sign-in spec). + freshSiloAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(token = token, code = code)) }.requireAuthStatus(200) @@ -228,6 +267,8 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap code: String?, ): ApiResult = safeApiV2Call(gate) { client.post("/api/v2/auth/device/deny") { + // The approver refreshes before deciding (TV sign-in spec). + freshSiloAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(token = token, code = code)) }.requireAuthStatus(200) @@ -236,7 +277,7 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap override suspend fun lookupDeviceLoginForScope( scope: AuthScopeSnapshot, code: String, - ): ApiResult = safeApiV2Call(gate) { + ): ApiResult = safeApiV2Call(gate.forServer(scope.serverId)) { client.get("/api/v2/auth/device") { skipSiloAuth() authScope(scope) @@ -247,9 +288,11 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap override suspend fun approveDeviceLoginForScope( scope: AuthScopeSnapshot, code: String, - ): ApiResult = safeApiV2Call(gate) { + ): ApiResult = safeApiV2Call(gate.forServer(scope.serverId)) { client.post("/api/v2/auth/device/approve") { authScope(scope) + // The approver refreshes before deciding (TV sign-in spec). + freshSiloAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(code = code)) }.requireAuthStatus(200) @@ -276,9 +319,11 @@ class DefaultDeviceLoginApi(private val client: HttpClient, private val gate: Ap override suspend fun denyDeviceLoginForScope( scope: AuthScopeSnapshot, code: String, - ): ApiResult = safeApiV2Call(gate) { + ): ApiResult = safeApiV2Call(gate.forServer(scope.serverId)) { client.post("/api/v2/auth/device/deny") { authScope(scope) + // The approver refreshes before deciding (TV sign-in spec). + freshSiloAuth() contentType(ContentType.Application.Json) setBody(DeviceLoginDecisionRequest(code = code)) }.requireAuthStatus(200) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt new file mode 100644 index 0000000000..565610ff96 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt @@ -0,0 +1,399 @@ +package org.siloserver.silo.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.coroutines.CancellationException +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.encodeURLPathPart +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.siloserver.silo.model.auth.AccountIdentities +import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.AccountIdentityLinkTicket +import org.siloserver.silo.model.auth.ExternalSignInCapabilities +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.apiv2.Problem +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.safeApiV2Call +import org.siloserver.silo.network.authScope +import org.siloserver.silo.network.freshSiloAuth +import org.siloserver.silo.network.map +import org.siloserver.silo.network.singleAttempt +import org.siloserver.silo.network.skipSiloAuth + +/** + * External sign-in (OIDC, LDAP) operations the apps use: provider discovery, + * the native OAuth handoff's completion, and the account's Sign-in section. + * Contract: silo-server `docs/auth-api.md` ("External sign-in", "OAuth sign-in + * flows"). Nothing here retries; the completions are single use. + */ +interface ExternalSignInApi { + /** `listAuthProviders` at [serverUrl], unauthenticated. */ + suspend fun listProviders(serverUrl: String): ApiResult + + /** `getOAuthHandshakeCapabilities` at [serverUrl], unauthenticated. */ + suspend fun oauthCapabilities(serverUrl: String): ApiResult + + /** + * `getExternalSignInCapabilities` at [scope]'s server: whether it serves + * the account's identities (`listAccountIdentities`, `deleteAccountIdentity`). + */ + suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult + + /** `completeOAuthLogin` for a native code: the token pair and the account. */ + suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult + + /** `listAccountIdentities` for [scope]'s account. */ + suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult + + /** `deleteAccountIdentity`: disconnect one of [scope]'s identities. */ + suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult + + /** `createAccountIdentityLinkTicket` after the account re-enters its local password. */ + suspend fun createLinkTicket( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult + + /** `completeAccountIdentityLink`: confirm a native linking flow's code. */ + suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult + + /** + * `linkAccountIdentityWithCredentials`: link [scope]'s account to a + * directory (LDAP) identity, confirming the account's local [password] + * and signing in to the directory as [username] with [directoryPassword]. + */ + suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult +} + +class DefaultExternalSignInApi( + private val client: HttpClient, + private val apiV2Gate: ApiV2Gate, +) : ExternalSignInApi { + + // Provider discovery is public and addressed by URL, often to a saved + // server other than the active one, so the active server's update verdict + // doesn't gate it (like the other URL-addressed sign-in reads). A server + // without it answers 404, which leaves the password form alone. + override suspend fun listProviders(serverUrl: String): ApiResult = + safeApiV2Call(ApiV2Gate.Unrestricted) { + client.get("${serverUrl.trimEnd('/')}/api/v2/auth/providers") { skipSiloAuth() } + }.map { it.domain(serverUrl) } + + override suspend fun oauthCapabilities(serverUrl: String): ApiResult = + safeApiV2Call(ApiV2Gate.Unrestricted) { + client.get("${serverUrl.trimEnd('/')}/api/v2/auth/oauth/capabilities") { skipSiloAuth() } + }.map { it.domain() } + + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = + safeApiV2Call(apiV2Gate.forServer(scope.serverId)) { + client.get("/api/v2/auth/external-sign-in/capabilities") { authScope(scope) } + }.map { it.domain() } + + override suspend fun completeOAuthLogin( + serverUrl: String, + code: String, + codeVerifier: String, + ): ApiResult = safeApiV2Call(apiV2Gate) { + client.post("${serverUrl.trimEnd('/')}/api/v2/auth/oauth/complete") { + skipSiloAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(CompleteOAuthLoginV2(code = code, codeVerifier = codeVerifier)) + }.requireAuthStatus(200) + }.map { it.domain() } + + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult = + safeApiV2Call(apiV2Gate.forServer(scope.serverId)) { + client.get("/api/v2/account/identities") { authScope(scope) } + }.map { collection -> AccountIdentities(collection.items.map { it.domain() }, collection.canUnlink) } + + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult = + safeApiV2Call(apiV2Gate.forServer(scope.serverId)) { + client.delete("/api/v2/account/identities/${identityId.encodeURLPathPart()}") { + authScope(scope); singleAttempt() + } + } + + override suspend fun createLinkTicket( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult = locatedCall(scope, expected = 200, decode = { body -> + SiloJson.decodeFromString(LinkTicketV2.serializer(), body).let { + AccountIdentityLinkTicket(ticket = it.ticket, expiresAt = it.expiresAt) + } + }) { + client.post("/api/v2/account/identities/link-ticket") { + authScope(scope); freshSiloAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(LinkTicketRequestV2(installationId = installationId, password = password)) + } + } + + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult = + safeApiV2Call(apiV2Gate.forServer(scope.serverId)) { + client.post("/api/v2/account/identities/link-complete") { + authScope(scope); freshSiloAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(CompleteOAuthLoginV2(code = code, codeVerifier = codeVerifier)) + } + } + + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = locatedCall(scope, expected = 201, decode = { body -> + SiloJson.decodeFromString(AccountIdentityV2.serializer(), body).domain() + }) { + client.post("/api/v2/account/identities/link-credentials") { + authScope(scope); freshSiloAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody( + LinkCredentialsRequestV2( + installationId = installationId, + password = password, + username = username, + directoryPassword = directoryPassword, + ), + ) + } + } + + /** + * One v2 exchange whose 422s differ only by the member the problem names + * (see [locatedError]); everything else is as [safeApiV2Call]. + */ + private suspend fun locatedCall( + scope: AuthScopeSnapshot, + expected: Int, + decode: (String) -> T, + block: suspend () -> HttpResponse, + ): ApiResult { + apiV2Gate.forServer(scope.serverId).blocked()?.let { return it } + return try { + val response = block() + val body = response.bodyAsText() + when { + response.status.value == expected -> ApiResult.Success(decode(body)) + response.status.isSuccess() -> ApiResult.Error(0, "invalid_response", "Unexpected response status.") + else -> locatedError(response.status.value, body) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ApiResult.NetworkError(e) + } + } + + companion object { + /** A 422 at `body.password`: the account's local password is wrong. */ + const val WRONG_PASSWORD = "wrong_password" + + /** A 422 at `body.directory_password`: the directory refused the username and password. */ + const val DIRECTORY_REFUSED = "directory_refused" + + /** + * The problem as an [ApiResult.Error] whose `error` is the problem + * code, except for a 422 that names `body.directory_password` + * ([DIRECTORY_REFUSED]) or `body.password` ([WRONG_PASSWORD]): those + * two refusals share `validation_failed` and differ only by member. + */ + internal fun locatedError(status: Int, body: String): ApiResult.Error { + val problem = runCatching { SiloJson.decodeFromString(Problem.serializer(), body) }.getOrNull() + val locations = problem?.errors.orEmpty().map { it.location } + val code = when { + status == 422 && "body.directory_password" in locations -> DIRECTORY_REFUSED + status == 422 && "body.password" in locations -> WRONG_PASSWORD + else -> problem?.code.orEmpty() + } + return ApiResult.Error(status, code, problem?.detail.orEmpty()) + } + } +} + +@Serializable +internal data class AuthProviderV2( + val id: String, + @SerialName("display_name") val displayName: String, + val mode: String, + val default: Boolean = false, + @SerialName("icon_url") val iconUrl: String? = null, + @SerialName("installation_id") val installationId: String? = null, + @SerialName("native_start_path") val nativeStartPath: String? = null, +) { + fun domain(serverUrl: String) = SignInProvider( + id = id, + displayName = displayName, + mode = when (mode) { + "credentials" -> SignInProvider.Mode.Credentials + "oauth" -> SignInProvider.Mode.OAuth + else -> SignInProvider.Mode.Unknown + }, + isDefault = default, + iconUrl = iconUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { absoluteUrl(it, serverUrl) }, + installationId = installationId?.takeIf { it.isNotBlank() }, + // Only the path is kept: the app opens it on its own saved server base. + nativeStartPath = nativeStartPath(nativeStartPath), + ) +} + +/** + * The native start relative to the server base: `/api/v2/auth/oauth/{id}/native/start` + * and any query, taken from `native_start_path`. An oauth provider without it + * offers no native sign-in. Whatever comes before `/api/v2/` (a reverse + * proxy's path prefix) is dropped: apps resolve the path against their own + * saved base URL and never open another origin. + * Null for anything that isn't that route. + */ +internal fun nativeStartPath(path: String?): String? { + val raw = path?.trim() + ?.takeIf { it.startsWith("/") && !it.startsWith("//") } + ?.substringBefore('#') ?: return null + val start = raw.substringBefore('?').indexOf(NATIVE_START_PREFIX) + if (start < 0) return null + return raw.substring(start).takeIf { NATIVE_START_ROUTE.matches(it) } +} + +private const val NATIVE_START_PREFIX = "/api/v2/auth/oauth/" +private val NATIVE_START_ROUTE = Regex("^/api/v2/auth/oauth/[A-Za-z0-9._~%-]+/native/start(\\?[^#\\s]*)?$") + +/** A site-relative icon path (`/api/v2/plugin-content/...`) resolves against the server that listed it. */ +internal fun absoluteUrl(url: String, serverUrl: String): String? = when { + url.startsWith("https://") || url.startsWith("http://") -> url + url.startsWith("/") && !url.startsWith("//") -> serverUrl.trimEnd('/') + url + else -> null +} + +@Serializable +internal data class AuthProviderCollectionV2( + val items: List, + @SerialName("password_login") val passwordLogin: Boolean = true, +) { + fun domain(serverUrl: String) = SignInProviders( + providers = items.map { it.domain(serverUrl) }, + passwordLogin = passwordLogin, + ) +} + +@Serializable +internal data class OAuthHandshakeCapabilitiesV2( + val state: String? = null, + val available: Boolean = false, + val native: Boolean = false, + val linking: Boolean = false, + @SerialName("select_account") val selectAccount: Boolean = false, +) { + fun domain(): OAuthHandshakeCapabilities { + val usable = available && (state == null || state == "available") + return OAuthHandshakeCapabilities( + available = usable, + native = usable && native, + linking = usable && linking, + selectAccount = usable && selectAccount, + ) + } +} + +@Serializable +internal data class ExternalSignInCapabilitiesV2( + val state: String? = null, + val available: Boolean = false, + val identities: Boolean = false, + @SerialName("credentials_linking") val credentialsLinking: Boolean = false, +) { + fun domain(): ExternalSignInCapabilities { + val usable = available && (state == null || state == "available") + return ExternalSignInCapabilities( + identities = usable && identities, + credentialsLinking = usable && credentialsLinking, + ) + } +} + +@Serializable +internal data class CompleteOAuthLoginV2( + val code: String, + @SerialName("code_verifier") val codeVerifier: String, +) { + override fun toString(): String = "CompleteOAuthLoginV2()" +} + +@Serializable +internal data class AccountIdentityV2( + val id: String, + @SerialName("installation_id") val installationId: String, + @SerialName("provider_id") val providerId: String = "", + @SerialName("provider_name") val providerName: String = "", + val username: String = "", + val email: String = "", + @SerialName("display_name") val displayName: String = "", + @SerialName("linked_at") val linkedAt: String, + @SerialName("last_sign_in_at") val lastSignInAt: String? = null, +) { + fun domain() = AccountIdentity( + id = id, + installationId = installationId, + providerId = providerId, + providerName = providerName, + username = username, + email = email, + displayName = displayName, + linkedAt = linkedAt, + lastSignInAt = lastSignInAt, + ) +} + +@Serializable +internal data class AccountIdentityCollectionV2( + val items: List, + @SerialName("can_unlink") val canUnlink: Boolean? = null, +) + +@Serializable +internal data class LinkTicketRequestV2( + @SerialName("installation_id") val installationId: String, + val password: String, +) { + override fun toString(): String = "LinkTicketRequestV2(installationId=$installationId, password=)" +} + +@Serializable +internal data class LinkTicketV2( + val ticket: String, + @SerialName("expires_at") val expiresAt: String, +) { + override fun toString(): String = "LinkTicketV2()" +} + +@Serializable +internal data class LinkCredentialsRequestV2( + @SerialName("installation_id") val installationId: String, + val password: String, + val username: String, + @SerialName("directory_password") val directoryPassword: String, +) { + override fun toString(): String = "LinkCredentialsRequestV2(installationId=$installationId, )" +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ServerIdentityApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ServerIdentityApi.kt new file mode 100644 index 0000000000..e4ceaa5f93 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ServerIdentityApi.kt @@ -0,0 +1,165 @@ +package org.siloserver.silo.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.isSuccess +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.apiv2.ApiV2Probe +import org.siloserver.silo.network.authScope +import org.siloserver.silo.network.singleAttempt +import org.siloserver.silo.network.skipSiloAuth +import org.siloserver.silo.pairing.PairingEndpoint + +/** + * The deployment identity contract (silo-server + * `docs/architecture/server-identity.md`): `GET /api/v2/system/identity` is + * public and answers one stable `server_id` at every address of a deployment; + * `GET /api/v2/system/connections` is authenticated and lists the addresses the + * deployment offers. The id is self-asserted, so it only decides which + * addresses are worth trying and which saved server a link or a TV means. + * Device-login approval remains the proof that two addresses share a backend. + * + * Mirrors silo-apple `ServerIdentity.swift`. + */ +interface ServerIdentityApi { + /** Classify one address: the identity it reports, an older server, or unreachable. */ + suspend fun probeIdentity(serverUrl: String): ServerIdentityProbe + + /** + * The addresses a saved server offers, read with that server's own + * credentials through [scope]. Null when the feature is unavailable or the + * request fails; callers then offer the saved URL alone. + */ + suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? +} + +sealed interface ServerIdentityProbe { + /** The address answered the identity operation. */ + data class Identity(val serverId: String) : ServerIdentityProbe + + /** The address answered, but it predates the identity contract. */ + data object UnsupportedServer : ServerIdentityProbe + + /** The address could not be reached, or did not answer like a Silo server. */ + data object Unreachable : ServerIdentityProbe +} + +/** `GET /api/v2/system/connections`, reduced to what other devices can use. */ +data class ServerConnections( + val serverId: String, + /** Addresses another device may try, in the server's order, de-duplicated. */ + val endpoints: List, +) + +@Serializable +internal data class ServerIdentityV2( + @SerialName("server_id") val serverId: String, +) + +@Serializable +internal data class ServerConnectionsV2( + val state: String? = null, + val allowed: Boolean? = null, + @SerialName("server_id") val serverId: String, + val endpoints: List = emptyList(), +) { + @Serializable + internal data class Endpoint( + val kind: String, + val url: String? = null, + val provider: String? = null, + @SerialName("display_name") val displayName: String? = null, + val state: String? = null, + ) { + /** + * Only a connected provider carries a URL; a public endpoint always + * does. Anything without one cannot be offered to another device. + */ + fun usable(): PairingEndpoint? { + val url = url?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return when (kind) { + "public" -> PairingEndpoint.of(url = url, kind = PairingEndpoint.Kind.Public) + "provider" -> PairingEndpoint.of( + url = url, + kind = PairingEndpoint.Kind.Provider, + provider = provider, + displayName = displayName, + ) + else -> null + } + } + } + + val isAvailable: Boolean get() = state == "available" && allowed != false + + fun domain(): ServerConnections { + val seen = mutableSetOf() + return ServerConnections( + serverId = serverId, + endpoints = endpoints.mapNotNull { it.usable() }.filter { seen.add(it.url) }, + ) + } +} + +class DefaultServerIdentityApi(private val client: HttpClient) : ServerIdentityApi { + + override suspend fun probeIdentity(serverUrl: String): ServerIdentityProbe { + val base = serverUrl.trim().trimEnd('/') + if (base.isEmpty()) return ServerIdentityProbe.Unreachable + return try { + withTimeoutOrNull(PROBE_TIMEOUT_MS) { + val response = client.get("$base$IDENTITY_PATH") { + skipSiloAuth() + singleAttempt() + } + val body = response.bodyAsText() + when { + response.status.isSuccess() -> { + val id = SiloJson.decodeFromString(ServerIdentityV2.serializer(), body) + .serverId.trim() + if (id.isEmpty()) ServerIdentityProbe.Unreachable else ServerIdentityProbe.Identity(id) + } + response.status == HttpStatusCode.NotFound && ApiV2Probe.isLegacyNotFound(body) -> + ServerIdentityProbe.UnsupportedServer + else -> ServerIdentityProbe.Unreachable + } + } ?: ServerIdentityProbe.Unreachable + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + ServerIdentityProbe.Unreachable + } + } + + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = try { + withTimeoutOrNull(PROBE_TIMEOUT_MS) { + val response = client.get(CONNECTIONS_PATH) { + authScope(scope) + singleAttempt() + } + if (!response.status.isSuccess()) return@withTimeoutOrNull null + SiloJson.decodeFromString(ServerConnectionsV2.serializer(), response.bodyAsText()) + .takeIf { it.isAvailable } + ?.domain() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + companion object { + const val IDENTITY_PATH = "/api/v2/system/identity" + const val CONNECTIONS_PATH = "/api/v2/system/connections" + + /** A TV probing several candidate addresses in turn must fail fast on the dead ones. */ + const val PROBE_TIMEOUT_MS = 8_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Gate.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Gate.kt index 2b3781996b..203f174e6a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Gate.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Gate.kt @@ -9,12 +9,24 @@ import org.siloserver.silo.network.ServerRegistry * [ServerContract.UPDATE_REQUIRED] state. The state comes from the registry * entry (set by [ApiV2Probe] on connect); nothing here ever performs a * request. + * + * A call scoped to one saved server, which need not be the active one, is + * gated on that server's own entry instead: [forServer]. */ -class ApiV2Gate(private val registry: ServerRegistry? = null) { +class ApiV2Gate( + private val registry: ServerRegistry? = null, + /** The saved server this gate checks; null for the active one. */ + private val serverId: String? = null, +) { /** The error to return instead of calling the server, or null when the call may proceed. */ - fun blocked(): ApiResult.Error? = - if (registry?.activeEntry?.value?.contract == ServerContract.UPDATE_REQUIRED) { + fun blocked(): ApiResult.Error? { + val entry = if (serverId == null) { + registry?.activeEntry?.value + } else { + registry?.entries?.value?.firstOrNull { it.id == serverId } + } + return if (entry?.contract == ServerContract.UPDATE_REQUIRED) { ApiResult.Error( code = 0, // no HTTP exchange happened; distinguishes the gate from any server status error = UPDATE_REQUIRED_ERROR, @@ -23,6 +35,13 @@ class ApiV2Gate(private val registry: ServerRegistry? = null) { } else { null } + } + + /** + * The gate for a call scoped to [serverId]'s saved server: that entry's + * verdict applies, not the active server's. + */ + fun forServer(serverId: String): ApiV2Gate = ApiV2Gate(registry, serverId) companion object { const val UPDATE_REQUIRED_ERROR = "update_server" diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/pairing/PairingMessage.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/pairing/PairingMessage.kt index b152f6c656..dbcbc54c1f 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/pairing/PairingMessage.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/pairing/PairingMessage.kt @@ -1,6 +1,8 @@ package org.siloserver.silo.pairing import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray @@ -40,6 +42,74 @@ enum class PairingServerStatus(val wire: String) { } } +/** + * Why a pushed server failed on the TV. Carried in [PairingMessage.ServerResult.error] + * so the phone can say what to fix. Older TVs send only [AuthFailed] (or, on + * older Android TVs, free text), and any value this build doesn't know reads + * as that generic failure. Mirrors silo-apple `PairingFailureCode`. + */ +enum class PairingFailureCode(val wire: String) { + /** Device authorization could not be started or completed; the legacy catch-all. */ + AuthFailed("auth_failed"), + + /** The phone's user declined the request, or the server refused it. */ + Denied("denied"), + + /** The device code expired before approval, or was already used. */ + Expired("expired"), + + /** The TV could not reach the pushed address, and no offered alternative worked. */ + Unreachable("unreachable"), + + /** An address answered with a different deployment identity. */ + IdentityMismatch("identity_mismatch"), + + /** The server is v1-only, or no longer accepts this app version. */ + UpdateRequired("update_required"); + + companion object { + fun fromWire(value: String?): PairingFailureCode = + entries.firstOrNull { it.wire == value } ?: AuthFailed + } +} + +/** + * One address a deployment offers. The same value crosses the + * `system/connections` document and the pairing push, so a TV that can't reach + * the phone's address can verify and use one it can. Mirrors silo-apple + * `ServerEndpoint`. Build it with [of] so [url] carries no trailing slash. + */ +data class PairingEndpoint( + val url: String, + val kind: Kind, + /** Provider slug for [Kind.Provider], e.g. `tailscale`. */ + val provider: String? = null, + /** Provider display name from its manifest; never derived from the hostname. */ + val displayName: String? = null, +) { + enum class Kind(val wire: String) { + Public("public"), + Provider("provider"); + + companion object { + /** Unknown kinds read as public, matching silo-apple. */ + fun fromWire(value: String?): Kind = entries.firstOrNull { it.wire == value } ?: Public + } + } + + companion object { + /** Builds an endpoint with [url] normalized the way both platforms compare addresses. */ + fun of( + url: String, + kind: Kind, + provider: String? = null, + displayName: String? = null, + ): PairingEndpoint = PairingEndpoint(normalizeEndpointUrl(url), kind, provider, displayName) + } +} + +fun normalizeEndpointUrl(url: String): String = url.trim().trimEnd('/') + /** Thrown when a message cannot be encoded or decoded against the wire schema. */ class PairingMessageException(message: String) : Exception(message) @@ -59,10 +129,18 @@ sealed class PairingMessage { val supportedVersions: List, ) : PairingMessage() - /** phone → TV, one per chosen server. */ + /** + * phone → TV, one per chosen server. [serverIdentity] and [endpoints] are + * optional additions (protocol still v1): the deployment identity the phone + * verified at [serverURL], and the other addresses the deployment offers, + * so a TV that cannot reach the phone's address can verify and use one it + * can. Older peers omit and ignore them. + */ data class PushServer( val serverURL: String, val serverName: String?, + val serverIdentity: String? = null, + val endpoints: List? = null, ) : PairingMessage() /** @@ -76,7 +154,11 @@ sealed class PairingMessage { val matchCode: String, ) : PairingMessage() - /** TV → phone, terminal per-server outcome. */ + /** + * TV → phone, terminal per-server outcome. [serverURL] always echoes the + * pushed URL, even when the TV signed in at another address. [error] is a + * [PairingFailureCode] wire value on failure. + */ data class ServerResult( val serverURL: String, val status: PairingServerStatus, @@ -135,6 +217,10 @@ object PairingMessageCodec { put(KEY_TYPE, JsonPrimitive("pushServer")) put("serverURL", JsonPrimitive(msg.serverURL)) if (msg.serverName != null) put("serverName", JsonPrimitive(msg.serverName)) + if (msg.serverIdentity != null) put("serverIdentity", JsonPrimitive(msg.serverIdentity)) + msg.endpoints?.let { endpoints -> + put("endpoints", buildJsonArray { endpoints.forEach { add(it.toJson()) } }) + } } is PairingMessage.DeviceStarted -> { put(KEY_TYPE, JsonPrimitive("deviceStarted")) @@ -170,6 +256,17 @@ object PairingMessageCodec { "pushServer" -> PairingMessage.PushServer( serverURL = obj.requireString("serverURL"), serverName = obj.optionalString("serverName"), + serverIdentity = obj.optionalString("serverIdentity"), + endpoints = (obj["endpoints"] as? JsonArray)?.map { element -> + val endpoint = element as? JsonObject + ?: throw PairingMessageException("endpoints must hold objects") + PairingEndpoint.of( + url = endpoint.requireString("url"), + kind = PairingEndpoint.Kind.fromWire(endpoint.requireString("kind")), + provider = endpoint.optionalString("provider"), + displayName = endpoint.optionalString("displayName"), + ) + }, ) "deviceStarted" -> PairingMessage.DeviceStarted( serverURL = obj.requireString("serverURL"), @@ -189,6 +286,13 @@ object PairingMessageCodec { } } + private fun PairingEndpoint.toJson(): JsonObject = buildJsonObject { + put("url", JsonPrimitive(url)) + put("kind", JsonPrimitive(kind.wire)) + if (provider != null) put("provider", JsonPrimitive(provider)) + if (displayName != null) put("displayName", JsonPrimitive(displayName)) + } + private fun JsonObject.requireString(key: String): String { val prim = (this[key] as? JsonPrimitive) ?: throw PairingMessageException("Missing field: $key") @@ -196,7 +300,7 @@ object PairingMessageCodec { } private fun JsonObject.optionalString(key: String): String? = - (this[key] as? JsonPrimitive)?.content + (this[key] as? JsonPrimitive)?.takeIf { it !is JsonNull }?.content private fun JsonObject.requireIntArray(key: String): List { val arr = this[key]?.jsonArray diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt index c157b79ced..f492bef3e4 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt @@ -135,6 +135,22 @@ class AuthRepository( authApi.login(LoginRequest(username, password), it.serverUrl) } + /** + * Signs in with the one-time code a native OAuth flow returned to the app + * (silo-server `docs/auth-api.md`, "OAuth sign-in flows"). The flow must + * still belong to [serverId], the saved server the app started it for and + * the active one, or nothing is redeemed. [redeem] spends the code at the + * saved base URL it is given (the caller checks it is still the origin the + * flow started on), and the session lands on that server exactly like a + * password sign-in. The saved server keeps its address and id. + */ + suspend fun completeNativeOAuthLogin( + serverId: String, + redeem: suspend (serverUrl: String) -> ApiResult, + ): ApiResult = authenticate { expected -> + if ((expected.serverId ?: serverRegistry?.activeServerId?.value) != serverId) staleSession() else redeem(expected.serverUrl) + } + suspend fun loginForTokens(username: String, password: String, expected: AccountSessionExpectation? = null): ApiResult { val captured = expected ?: tokenManager.captureAccountSessionExpectation() ?: return staleSession() val result = authApi.login(LoginRequest(username, password), captured.serverUrl) @@ -215,6 +231,10 @@ class AuthRepository( suspend fun getCurrentUser(): ApiResult = authApi.getMe() + /** Fetches the account signed in on [scope]'s server. */ + suspend fun getCurrentUser(scope: org.siloserver.silo.network.AuthScopeSnapshot): ApiResult = + authApi.getMe(scope) + /** * Logs out by clearing all persisted tokens and profile state for the * **currently active** server. The [ServerRegistry] entry is kept so the diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceLoginRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceLoginRepository.kt index 1b2e82f768..2d93c511c6 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceLoginRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceLoginRepository.kt @@ -1,48 +1,247 @@ package org.siloserver.silo.repository +import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse import org.siloserver.silo.model.auth.DeviceLoginPollResponse import org.siloserver.silo.model.auth.DeviceLoginStartResponse import org.siloserver.silo.model.auth.DeviceLoginStatus import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.api.DeviceLoginApi -import kotlinx.coroutines.CancellationException +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.util.parseRfc3339ToEpochMillis import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.time.TimeSource + +/** Monotonic milliseconds, injectable so tests can drive deadlines with virtual time. */ +fun interface DeviceLoginClock { + fun nowMs(): Long + + companion object { + private val origin = TimeSource.Monotonic.markNow() + val System = DeviceLoginClock { origin.elapsedNow().inWholeMilliseconds } + } +} /** - * Owns the OAuth device-login state machine: initiate → poll loop → - * terminal (Approved / Failed). Exposes a single [state] StateFlow the - * UI can observe. + * How a failed device-login call should be handled. Shared by the pairing + * receiver's poll loop and the TV sign-in screen so both treat the same + * server answer the same way. + */ +enum class DeviceLoginErrorKind { + /** Network failure, timeout or 5xx: the request may still be live; back off and retry. */ + Transient, + + /** 429: back off and retry; tell the person if it persists. */ + RateLimited, + + /** 404 (or 501): the server has no such request (poll) or no device sign-in (start). */ + NotFound, + + /** 410, 426, or the v2 gate: the server or this app must be updated first. */ + UpdateRequired, + + /** Any other 4xx: retrying the same call won't help. */ + Rejected; + + companion object { + fun of(result: ApiResult<*>): DeviceLoginErrorKind = when (result) { + is ApiResult.NetworkError -> Transient + is ApiResult.Error -> when { + result.code == 0 && result.error == ApiV2Gate.UPDATE_REQUIRED_ERROR -> UpdateRequired + result.code == 429 -> RateLimited + result.code == 404 || result.code == 501 -> NotFound + result.code == 410 || result.code == 426 -> UpdateRequired + result.code == 408 || result.code >= 500 -> Transient + // code 0 without the gate marker: the response could not be read. + result.code == 0 -> Transient + else -> Rejected + } + is ApiResult.Success -> error("not a failure") + } + } +} + +/** Timing rules for device-login polling. */ +object DeviceLoginTiming { + /** Backoff ceiling for network errors, 5xx and 429. */ + const val MAX_BACKOFF_MS = 30_000L + + /** Next backoff after a failure: doubles from the poll interval up to [MAX_BACKOFF_MS]. */ + fun nextBackoffMs(current: Long, intervalMs: Long): Long = + if (current <= 0L) intervalMs.coerceIn(1_000L, MAX_BACKOFF_MS) + else (current * 2).coerceAtMost(MAX_BACKOFF_MS) +} + +/** + * One live device code's polling, shared by [DeviceSignInMachine] and + * [DeviceLoginRepository]'s pairing loop so both read a server answer the + * same way. [poll] makes one call and says what it means; the caller decides + * what the screen shows and whether to wait, renew or stop. * - * Mirrors Apple's [QRLoginViewModel.swift] polling behavior: - * - polls immediately after start (don't wait the first interval) - * - swallows transient network errors and keeps polling - * - 404 on poll = server cleaned up the row → terminal Failed(Expired) - * - honors server-requested poll-after override on Pending responses + * Deadline: starts at `expires_in` from the start answer. A pending answer + * carries the request's current `expires_at`, which an approver's lookup + * extends; the deadline moves to it (never earlier), converted from server + * time to this device's clock through the start answer's own + * `expires_at`/`expires_in` pair, so clock skew between the two cancels out. + * Waits are clamped to the deadline, so the last call before a caller gives + * up on a code is always a poll. + */ +class DeviceLoginPoller( + private val pollOnce: suspend (deviceCode: String) -> DeviceLoginRepository.PollOutcome, + val session: DeviceLoginStartResponse, + private val clock: DeviceLoginClock, +) { + /** What one poll answer means. */ + sealed interface Step { + /** Still pending: wait [waitMs] (already clamped to the deadline) before the next poll. */ + data class Pending(val waitMs: Long) : Step + + /** Approved with tokens; they are in [response] and nowhere else. */ + data class Approved(val response: DeviceLoginPollResponse) : Step + + /** Approved, but the answer carried no tokens. */ + data object MissingTokens : Step + + data object Denied : Step + + /** + * The server has no live request for this code any more: [status] is + * `expired`, `consumed` or `canceled`, or null for a 404 or another + * 4xx that retrying won't change. + */ + data class Gone(val status: DeviceLoginStatus?) : Step + + /** 410, 426 or the v2 gate: the server or this app must be updated. */ + data object UpdateRequired : Step + + /** A status this build doesn't know. */ + data class Unknown(val status: String) : Step + + /** Network error, 5xx or 429: the request may still be live. Wait [waitMs] (clamped to the deadline) and poll again. */ + data class Retry(val kind: DeviceLoginErrorKind, val waitMs: Long) : Step + } + + private val startedAt = clock.nowMs() + + /** Local-clock ms minus server-clock ms, or null when the start answer's `expires_at` can't be read. */ + private val serverToLocalMs: Long? = parseRfc3339ToEpochMillis(session.expiresAt) + ?.let { serverExpiry -> startedAt + session.expiresIn.coerceAtLeast(1) * 1_000L - serverExpiry } + + /** Local time after which the code is treated as expired. */ + var deadline: Long = startedAt + session.expiresIn.coerceAtLeast(1) * 1_000L + private set + + /** True once an approver has looked the request up (the server's opened signal). */ + var opened: Boolean = false + private set + + private var intervalMs = session.interval.coerceAtLeast(1) * 1_000L + private var backoffMs = 0L + + /** Whether the deadline has passed. */ + val isExpired: Boolean + get() = clock.nowMs() >= deadline + + suspend fun poll(): Step = when (val outcome = pollOnce(session.deviceCode)) { + is DeviceLoginRepository.PollOutcome.Answered -> { + backoffMs = 0L + answered(outcome.response) + } + is DeviceLoginRepository.PollOutcome.Failed -> when (outcome.kind) { + DeviceLoginErrorKind.NotFound, DeviceLoginErrorKind.Rejected -> Step.Gone(null) + DeviceLoginErrorKind.UpdateRequired -> Step.UpdateRequired + DeviceLoginErrorKind.Transient, DeviceLoginErrorKind.RateLimited -> { + backoffMs = DeviceLoginTiming.nextBackoffMs(backoffMs, intervalMs) + Step.Retry(outcome.kind, clampToDeadline(backoffMs)) + } + } + } + + private fun answered(response: DeviceLoginPollResponse): Step = when (val status = DeviceLoginStatus.fromWire(response.status)) { + DeviceLoginStatus.Pending -> { + if (response.opened) opened = true + val serverExpiry = response.expiresAt?.let(::parseRfc3339ToEpochMillis) + if (serverExpiry != null && serverToLocalMs != null) { + deadline = maxOf(deadline, serverExpiry + serverToLocalMs) + } + response.pollAfter?.let { intervalMs = it.coerceAtLeast(1) * 1_000L } + Step.Pending(clampToDeadline(intervalMs)) + } + DeviceLoginStatus.Approved -> + if (response.accessToken.isNullOrBlank() || response.refreshToken.isNullOrBlank()) { + Step.MissingTokens + } else { + Step.Approved(response) + } + DeviceLoginStatus.Denied -> Step.Denied + DeviceLoginStatus.Expired, + DeviceLoginStatus.Consumed, + DeviceLoginStatus.Canceled, + -> Step.Gone(status) + DeviceLoginStatus.Unknown -> Step.Unknown(response.status) + } + + private fun clampToDeadline(waitMs: Long): Long = + waitMs.coerceAtMost((deadline - clock.nowMs()).coerceAtLeast(0L)) +} + +/** + * Device-login operations and the single-attempt state machine used by LAN + * companion pairing: start → poll → terminal (Approved / Failed). The TV + * sign-in screen drives its own renewing session through [DeviceSignInMachine] + * on top of the same operations. + * + * Both read each poll answer through [DeviceLoginPoller]: + * - polls immediately after start, then honors `interval` / `poll_after` + * - network errors, 5xx and 429 back off exponentially up to 30 s + * - a local deadline from `expires_in`, moved to a pending answer's + * `expires_at`, ends the attempt even while the server can't be reached; + * the last call before it ends is a poll + * - 404 (or another 4xx) on poll = the server has no such request → Failed(Expired) */ class DeviceLoginRepository( private val api: DeviceLoginApi, + private val clock: DeviceLoginClock = DeviceLoginClock.System, ) { sealed class DeviceLoginState { object Idle : DeviceLoginState() object Initiating : DeviceLoginState() + data class Awaiting(val session: DeviceLoginStartResponse) : DeviceLoginState() + data class Approved(val response: DeviceLoginPollResponse) : DeviceLoginState() data class Failed(val reason: FailureReason, val message: String? = null) : DeviceLoginState() } enum class FailureReason { - StartFailed, // initiate POST returned non-2xx - Expired, // polled row gone (404) or status=expired + StartFailed, // start answered with an error that retrying won't fix + Unreachable, // start could not reach the server + RateLimited, // start answered 429 + Unsupported, // start answered 404: no device sign-in on this server + UpdateRequired, // 410 / v1-only / app too old + Expired, // polled row gone (404), status=expired, or the local deadline passed Denied, // status=denied + Canceled, // status=canceled Consumed, // status=consumed (already used) MissingTokens, // status=approved but no access_token returned UnknownStatus, // server returned a status we don't recognize } + /** Result of one start call. */ + sealed interface StartOutcome { + data class Started(val session: DeviceLoginStartResponse) : StartOutcome + data class Failed(val kind: DeviceLoginErrorKind) : StartOutcome + } + + /** Result of one poll call. */ + sealed interface PollOutcome { + data class Answered(val response: DeviceLoginPollResponse) : PollOutcome + data class Failed(val kind: DeviceLoginErrorKind) : PollOutcome + } + private val _state = MutableStateFlow(DeviceLoginState.Idle) val state: StateFlow = _state.asStateFlow() @@ -64,24 +263,17 @@ class DeviceLoginRepository( suspend fun beginAt(serverUrl: String?, deviceName: String?, devicePlatform: String?) { _state.value = DeviceLoginState.Initiating - val startResult = if (serverUrl == null) { - api.startDeviceLogin(deviceName, devicePlatform) - } else { - api.startDeviceLoginAt(serverUrl, deviceName, devicePlatform) - } - val session = when (val r = startResult) { - is ApiResult.Success -> r.data - is ApiResult.Error -> { - _state.value = DeviceLoginState.Failed( - FailureReason.StartFailed, - "Server returned ${r.code}: ${r.message}", - ) - return - } - is ApiResult.NetworkError -> { + val session = when (val outcome = start(serverUrl, deviceName, devicePlatform)) { + is StartOutcome.Started -> outcome.session + is StartOutcome.Failed -> { _state.value = DeviceLoginState.Failed( - FailureReason.StartFailed, - r.exception.message, + when (outcome.kind) { + DeviceLoginErrorKind.Transient -> FailureReason.Unreachable + DeviceLoginErrorKind.RateLimited -> FailureReason.RateLimited + DeviceLoginErrorKind.NotFound -> FailureReason.Unsupported + DeviceLoginErrorKind.UpdateRequired -> FailureReason.UpdateRequired + DeviceLoginErrorKind.Rejected -> FailureReason.StartFailed + }, ) return } @@ -95,6 +287,43 @@ class DeviceLoginRepository( _state.value = DeviceLoginState.Idle } + /** One start call against [serverUrl], or the active server when null. */ + suspend fun start(serverUrl: String?, deviceName: String?, devicePlatform: String?): StartOutcome { + val result = if (serverUrl == null) { + api.startDeviceLogin(deviceName, devicePlatform) + } else { + api.startDeviceLoginAt(serverUrl, deviceName, devicePlatform) + } + return when (result) { + is ApiResult.Success -> StartOutcome.Started(result.data) + else -> StartOutcome.Failed(DeviceLoginErrorKind.of(result)) + } + } + + /** One poll call against [serverUrl], or the active server when null. */ + suspend fun poll(serverUrl: String?, deviceCode: String): PollOutcome { + val result = if (serverUrl == null) { + api.pollDeviceLogin(deviceCode) + } else { + api.pollDeviceLoginAt(serverUrl, deviceCode) + } + return when (result) { + is ApiResult.Success -> PollOutcome.Answered(result.data) + else -> PollOutcome.Failed(DeviceLoginErrorKind.of(result)) + } + } + + /** The capability document at [serverUrl], or null when it can't be read. */ + suspend fun capability(serverUrl: String): DeviceLoginCapabilityResponse? = + (api.deviceLoginCapabilityAt(serverUrl) as? ApiResult.Success)?.data + + /** + * Withdraw [deviceCode] on the server so an abandoned code can't be + * approved later. Best effort: a failure leaves the request to expire. + */ + suspend fun cancel(serverUrl: String, deviceCode: String): Boolean = + api.cancelDeviceLoginAt(serverUrl, deviceCode) is ApiResult.Success + suspend fun lookup(token: String?, code: String?) = api.lookupDeviceLogin(token = token, code = code) @@ -114,88 +343,51 @@ class DeviceLoginRepository( api.denyDeviceLoginForScope(scope, code) private suspend fun runPollLoop(session: DeviceLoginStartResponse, serverUrl: String?) { - var intervalMs = session.interval.coerceAtLeast(1) * 1_000L - + val poller = DeviceLoginPoller(pollOnce = { poll(serverUrl, it) }, session = session, clock = clock) while (true) { - try { - val pollResult = if (serverUrl == null) { - api.pollDeviceLogin(session.deviceCode) - } else { - api.pollDeviceLoginAt(serverUrl, session.deviceCode) + when (val step = poller.poll()) { + is DeviceLoginPoller.Step.Pending -> { + if (poller.isExpired) break + delay(step.waitMs) } - val response = when (val r = pollResult) { - is ApiResult.Success -> r.data - is ApiResult.Error -> { - if (r.code == 404) { - _state.value = DeviceLoginState.Failed( - FailureReason.Expired, - "This sign-in request has expired.", - ) - return - } - // Transient (5xx, proxy hiccup): the pending authorization is - // still valid on the server, so keep polling. A consumed code - // answers 404 above, which already ends the flow. - delay(intervalMs) - continue - } - is ApiResult.NetworkError -> { - delay(intervalMs) - continue - } + is DeviceLoginPoller.Step.Retry -> { + if (poller.isExpired) break + delay(step.waitMs) } - - // Honor server's poll-after override if present. - response.pollAfter?.let { intervalMs = it.coerceAtLeast(1) * 1_000L } - - when (DeviceLoginStatus.fromWire(response.status)) { - DeviceLoginStatus.Pending -> { - delay(intervalMs) - } - DeviceLoginStatus.Approved -> { - if (response.accessToken.isNullOrBlank() || - response.refreshToken.isNullOrBlank()) { - _state.value = DeviceLoginState.Failed( - FailureReason.MissingTokens, - "Server approved the session but did not return tokens.", - ) - return - } - _state.value = DeviceLoginState.Approved(response) - return - } - DeviceLoginStatus.Denied -> { - _state.value = DeviceLoginState.Failed( - FailureReason.Denied, - "Sign-in was denied on the other device.", - ) - return - } - DeviceLoginStatus.Expired -> { - _state.value = DeviceLoginState.Failed( - FailureReason.Expired, - "This code expired before it was approved.", - ) - return - } - DeviceLoginStatus.Consumed -> { - _state.value = DeviceLoginState.Failed( - FailureReason.Consumed, - "This code has already been used.", - ) - return - } - DeviceLoginStatus.Unknown -> { - _state.value = DeviceLoginState.Failed( - FailureReason.UnknownStatus, - "Unexpected status: ${response.status}", - ) - return + is DeviceLoginPoller.Step.Approved -> { + _state.value = DeviceLoginState.Approved(step.response) + return + } + DeviceLoginPoller.Step.MissingTokens -> { + fail(FailureReason.MissingTokens, "Server approved the session but did not return tokens.") + return + } + DeviceLoginPoller.Step.Denied -> { + fail(FailureReason.Denied, "Sign-in was declined on the other device.") + return + } + is DeviceLoginPoller.Step.Gone -> { + when (step.status) { + DeviceLoginStatus.Canceled -> fail(FailureReason.Canceled, "This sign-in request was canceled.") + DeviceLoginStatus.Consumed -> fail(FailureReason.Consumed, "This code has already been used.") + else -> fail(FailureReason.Expired, "This code expired before it was approved.") } + return + } + DeviceLoginPoller.Step.UpdateRequired -> { + fail(FailureReason.UpdateRequired) + return + } + is DeviceLoginPoller.Step.Unknown -> { + fail(FailureReason.UnknownStatus, "Unexpected status: ${step.status}") + return } - } catch (e: CancellationException) { - throw e } } + fail(FailureReason.Expired, "This code expired before it was approved.") + } + + private fun fail(reason: FailureReason, message: String? = null) { + _state.value = DeviceLoginState.Failed(reason, message) } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceSignInMachine.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceSignInMachine.kt new file mode 100644 index 0000000000..215a048ad9 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DeviceSignInMachine.kt @@ -0,0 +1,399 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse +import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse + +/** What the TV sign-in screen shows for its device code. See [DeviceSignInMachine]. */ +sealed interface DeviceSignInState { + /** Asking the server for a code. */ + data object GettingCode : DeviceSignInState + + /** + * A live code. [opened] is true once an approver has looked it up (the + * server's opened signal); [renewed] is true when this code replaced an + * expired one without the person asking. + */ + data class ShowingCode( + val session: DeviceLoginStartResponse, + val opened: Boolean = false, + val renewed: Boolean = false, + ) : DeviceSignInState + + /** The approver signed this TV in; the tokens are in [response] and nowhere else. */ + data class Approved(val response: DeviceLoginPollResponse) : DeviceSignInState + + /** The approver declined. */ + data object Denied : DeviceSignInState + + /** Codes renewed for about an hour with no approval; waiting for the person. */ + data object Paused : DeviceSignInState + + /** + * Calls keep failing with network errors or 5xx. Still retrying with + * backoff. [session] is the last code, when it is still live. + */ + data class Unreachable(val session: DeviceLoginStartResponse?) : DeviceSignInState + + /** The server has answered 429 for over a minute. Still retrying. */ + data class TooManyRequests(val session: DeviceLoginStartResponse?) : DeviceSignInState + + /** The server doesn't offer device sign-in; only a password works. */ + data object NoDeviceSignIn : DeviceSignInState + + /** The server or this app must be updated first. */ + data object UpdateRequired : DeviceSignInState + + /** The server answered in a way retrying won't fix (missing tokens, unknown status, 4xx on start). */ + data object Failed : DeviceSignInState +} + +/** Timing for [DeviceSignInMachine]; shortened in tests. */ +data class DeviceSignInConfig( + /** Stop renewing codes after this long without the person doing anything. */ + val pauseAfterMs: Long = 60 * 60_000L, + /** Show "can't reach" when getting a code has failed for this long. */ + val startUnreachableAfterMs: Long = 10_000L, + /** Show "can't reach" when polling has failed for this long. */ + val pollUnreachableAfterMs: Long = 30_000L, + /** Show "too many requests" when 429s have persisted this long. */ + val rateLimitedAfterMs: Long = 60_000L, + /** Bound on the best-effort cancel call when the code is abandoned. */ + val cancelTimeoutMs: Long = 5_000L, +) + +/** + * The TV sign-in screen's device code: gets a code, polls it, renews it in + * place when it expires while the screen is visible, pauses after about an + * hour of renewals, and names the failure when the server can't be reached or + * keeps answering 429. Silo-Server/silo-server TV sign-in spec, states table. + * Each poll answer is read through [DeviceLoginPoller], the same step the + * pairing receiver's loop uses. + * + * [run] drives the machine until it reaches a state that waits for the + * person ([DeviceSignInState.Approved], Denied, Paused, NoDeviceSignIn, + * UpdateRequired, Failed), or until [requestStop]. Stopping keeps the current + * code, so the screen can stop polling when the app goes to the background and + * call [run] again when it returns: the code is polled at once and replaced + * only if the server no longer holds it. + * + * A code is replaced only after a poll: when the server says it is gone, or + * when a poll finds the local deadline passed. A code replaced while the + * server still holds it is withdrawn there first (when the server supports + * cancel), so it can't be approved after the TV moved on. + * + * Each [run] is numbered. A new [run], [restart] or [abandon] supersedes the + * run before it: when a call returns to a superseded run, the run drops the + * answer and returns without publishing, replacing or starting anything, so + * a run left blocked in a slow call can't bring back a withdrawn code or drop + * the newer run's code. + * + * Not thread-safe: call everything from one coroutine context. + */ +class DeviceSignInMachine( + private val repository: DeviceLoginRepository, + val serverUrl: String, + private val deviceName: String?, + private val devicePlatform: String?, + private val clock: DeviceLoginClock = DeviceLoginClock.System, + private val config: DeviceSignInConfig = DeviceSignInConfig(), +) { + private val _state = MutableStateFlow(DeviceSignInState.GettingCode) + val state: StateFlow = _state.asStateFlow() + + private var capability: DeviceLoginCapabilityResponse? = null + private var poller: DeviceLoginPoller? = null + private var renewed = false + + /** + * [Runs.current] numbers the run that owns the machine; [Runs.stopped] is + * the run [requestStop] asked to return at its next wait or before its + * next call. + */ + private data class Runs(val current: Long = 0L, val stopped: Long = -1L) + + private val runs = MutableStateFlow(Runs()) + + /** Start of the current run of automatic renewals; reset by [restart] and [noteActivity]. */ + private var renewalBudgetStart: Long? = null + + /** Whether the machine is waiting for the person rather than working. */ + val isSettled: Boolean + get() = when (_state.value) { + is DeviceSignInState.Approved, + DeviceSignInState.Denied, + DeviceSignInState.Paused, + DeviceSignInState.NoDeviceSignIn, + DeviceSignInState.UpdateRequired, + DeviceSignInState.Failed, + -> true + else -> false + } + + /** The person is here (screen came back to the foreground): renewals start their hour again. */ + fun noteActivity() { + renewalBudgetStart = clock.nowMs() + } + + /** + * Ask [run] to return. A wait between calls ends at once; a call already + * in flight finishes first and its answer is handled, so a poll the server + * already answered with the one-time tokens is never dropped. + */ + fun requestStop() { + runs.update { it.copy(stopped = it.current) } + } + + /** + * Forget the current code and start from "Getting a sign-in code". Call + * [abandon] first when the old code may still be live on the server. + */ + fun restart() { + supersede() + poller = null + renewed = false + renewalBudgetStart = null + _state.value = DeviceSignInState.GettingCode + } + + /** + * Withdraw the current code on the server (when it supports cancel) so it + * can't be approved after the TV moved on. The call runs in [scope], which + * must outlive the screen; the code is forgotten immediately and any run + * still in a call is superseded. + */ + fun abandon(scope: CoroutineScope) { + supersede() + val abandoned = poller?.session ?: return + poller = null + if (!mayWithdraw) return + scope.launch { withdraw(abandoned) } + } + + suspend fun run() { + if (isSettled) return + val mine = runs.updateAndGet { it.copy(current = it.current + 1) }.current + fun superseded() = runs.value.current != mine + fun stopped() = runs.value.let { it.current != mine || it.stopped == mine } + val budgetStart = renewalBudgetStart ?: clock.nowMs().also { renewalBudgetStart = it } + + if (capability == null) capability = repository.capability(serverUrl) + if (superseded()) return + if (capability?.deviceLoginAvailable == false) { + publish(DeviceSignInState.NoDeviceSignIn) + return + } + + var failingSince: Long? = null + var rateLimitedSince: Long? = null + var startBackoffMs = 0L + + fun noteFailure(kind: DeviceLoginErrorKind, starting: Boolean) { + val now = clock.nowMs() + if (kind == DeviceLoginErrorKind.RateLimited) { + val since = rateLimitedSince ?: now.also { rateLimitedSince = it } + if (now - since >= config.rateLimitedAfterMs) { + publish(DeviceSignInState.TooManyRequests(liveSession())) + } + } else { + val since = failingSince ?: now.also { failingSince = it } + val threshold = if (starting) config.startUnreachableAfterMs else config.pollUnreachableAfterMs + if (now - since >= threshold) { + publish(DeviceSignInState.Unreachable(liveSession())) + } + } + } + + fun recovered() { + failingSince = null + rateLimitedSince = null + startBackoffMs = 0L + } + + while (true) { + // A cancelled run (the screen left, or a password sign-in won) must + // not reach the server again. A wait that times out as it is + // cancelled can return normally, and the next steps may not + // suspend before they ask for a new code. + currentCoroutineContext().ensureActive() + if (stopped()) return + val live = poller + if (live == null) { + // About an hour without the person doing anything: stop asking + // the server for codes nobody is looking at. + if (clock.nowMs() - budgetStart >= config.pauseAfterMs) { + publish(DeviceSignInState.Paused) + return + } + if (_state.value !is DeviceSignInState.Unreachable && + _state.value !is DeviceSignInState.TooManyRequests && + _state.value !is DeviceSignInState.ShowingCode + ) { + publish(DeviceSignInState.GettingCode) + } + val outcome = repository.start(serverUrl, deviceName, devicePlatform) + if (superseded()) { + // Nobody will show or poll this code: withdraw it. + if (outcome is DeviceLoginRepository.StartOutcome.Started && mayWithdraw) { + withdraw(outcome.session) + } + return + } + when (outcome) { + is DeviceLoginRepository.StartOutcome.Started -> { + recovered() + val started = DeviceLoginPoller( + pollOnce = { repository.poll(serverUrl, it) }, + session = outcome.session, + clock = clock, + ) + poller = started + publish(DeviceSignInState.ShowingCode(started.session, opened = false, renewed = renewed)) + // Poll at once: fall through. + } + is DeviceLoginRepository.StartOutcome.Failed -> when (outcome.kind) { + DeviceLoginErrorKind.NotFound -> { + publish(DeviceSignInState.NoDeviceSignIn) + return + } + DeviceLoginErrorKind.UpdateRequired -> { + publish(DeviceSignInState.UpdateRequired) + return + } + DeviceLoginErrorKind.Rejected -> { + publish(DeviceSignInState.Failed) + return + } + DeviceLoginErrorKind.Transient, DeviceLoginErrorKind.RateLimited -> { + noteFailure(outcome.kind, starting = true) + startBackoffMs = DeviceLoginTiming.nextBackoffMs(startBackoffMs, 1_000L) + if (!pause(startBackoffMs, mine)) return + continue + } + } + } + if (stopped()) return + } + + val current = poller ?: continue + val step = current.poll() + // Restarted or abandoned during the call: this answer is for a + // code the screen no longer shows. + if (superseded()) return + when (step) { + is DeviceLoginPoller.Step.Pending -> { + recovered() + publish(DeviceSignInState.ShowingCode(current.session, opened = current.opened, renewed = renewed)) + if (current.isExpired) { + replace(current, stillLive = true, mine) + } else if (!pause(step.waitMs, mine)) { + return + } + } + is DeviceLoginPoller.Step.Approved -> { + poller = null + publish(DeviceSignInState.Approved(step.response)) + return + } + DeviceLoginPoller.Step.MissingTokens, is DeviceLoginPoller.Step.Unknown -> { + poller = null + publish(DeviceSignInState.Failed) + return + } + DeviceLoginPoller.Step.Denied -> { + poller = null + publish(DeviceSignInState.Denied) + return + } + // Gone on the server: replace it in place. + is DeviceLoginPoller.Step.Gone -> replace(current, stillLive = false, mine) + DeviceLoginPoller.Step.UpdateRequired -> { + poller = null + publish(DeviceSignInState.UpdateRequired) + return + } + is DeviceLoginPoller.Step.Retry -> { + noteFailure(step.kind, starting = false) + // Past the local deadline without an answer: the server may + // still hold the code (an approver can extend it, and a 429 + // or 5xx doesn't prove it gone), so withdraw it, bounded, + // before asking for a new one. + if (current.isExpired) { + replace(current, stillLive = true, mine) + } else if (!pause(step.waitMs, mine)) { + return + } + } + } + } + } + + /** + * Drop [current] for a new code. [stillLive]: the server still holds it + * (the local deadline passed first), so withdraw it before moving on. + * + * [current] stays the machine's code until it has been withdrawn, so an + * [abandon] that lands meanwhile still finds and withdraws it; the + * withdraw itself can't be cut short by cancelling the run. + */ + private suspend fun replace(current: DeviceLoginPoller, stillLive: Boolean, mine: Long) { + // A capability read that failed earlier gets another chance, so a + // server that does support cancel gets its codes withdrawn. + if (capability == null) capability = repository.capability(serverUrl) + // Restarted or abandoned meanwhile: abandon took the code. + if (runs.value.current != mine) return + if (stillLive && mayWithdraw) withdraw(current.session) + if (runs.value.current != mine) return + poller = null + renewed = true + } + + /** + * Whether a code may be withdrawn on the server: every withdraw decision + * uses this one rule. Unknown support (the capability read failed) still + * gets the cancel: a server without it answers 404, which is harmless. + */ + private val mayWithdraw: Boolean + get() = capability?.cancel != false + + /** Best-effort, bounded cancel of [session] that survives cancelling the caller. */ + private suspend fun withdraw(session: DeviceLoginStartResponse) { + withContext(NonCancellable) { + withTimeoutOrNull(config.cancelTimeoutMs) { + repository.cancel(serverUrl, session.deviceCode) + } + } + } + + /** Retire the current run: it returns at its next check without publishing. */ + private fun supersede() { + runs.update { it.copy(current = it.current + 1) } + } + + /** Wait [ms]; false when [requestStop], [restart] or [abandon] ended the wait for run [mine]. */ + private suspend fun pause(ms: Long, mine: Long): Boolean { + fun Runs.ends() = current != mine || stopped == mine + if (ms <= 0L) return !runs.value.ends() + return withTimeoutOrNull(ms) { runs.first { it.ends() } } == null + } + + private fun liveSession(): DeviceLoginStartResponse? = + poller?.takeIf { !it.isExpired }?.session + + private fun publish(state: DeviceSignInState) { + _state.value = state + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt new file mode 100644 index 0000000000..13716b854b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt @@ -0,0 +1,78 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.siloserver.silo.model.auth.AccountIdentities +import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.AccountIdentityLinkTicket +import org.siloserver.silo.model.auth.ExternalSignInCapabilities +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.SignInOptions +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.api.ExternalSignInApi + +/** + * External sign-in (OIDC, LDAP) for the apps: what a sign-in screen offers, + * and the account's Sign-in section. The native OAuth handoff itself (browser, + * PKCE, the app redirect) belongs to the phone app; TVs never run it. + */ +class ExternalSignInRepository( + private val api: ExternalSignInApi, +) { + /** + * What to offer on [serverUrl]'s sign-in screen, or null when it couldn't + * be asked just now (a network failure, a 5xx or a 429 on either read): + * the screen then offers the password form and a retry rather than + * settling on no providers. A server that answers without provider + * discovery (an older one) gets the password form + * alone, as before external sign-in; one that answers without the OAuth + * handshake capability shows no provider buttons. + */ + suspend fun signInOptions(serverUrl: String): SignInOptions? = coroutineScope { + val providers = async { api.listProviders(serverUrl) } + val handshake = async { api.oauthCapabilities(serverUrl) } + val listed = when (val result = providers.await()) { + is ApiResult.Success -> result.data + is ApiResult.Error -> return@coroutineScope if (result.isTemporary()) null else SignInOptions.PasswordOnly + is ApiResult.NetworkError -> return@coroutineScope null + } + val capabilities = when (val result = handshake.await()) { + is ApiResult.Success -> result.data + is ApiResult.Error -> if (result.isTemporary()) return@coroutineScope null else OAuthHandshakeCapabilities.None + is ApiResult.NetworkError -> return@coroutineScope null + } + SignInOptions.of(listed, capabilities) + } + + /** A refusal that says nothing about what the server offers: try again later. */ + private fun ApiResult.Error.isTemporary(): Boolean = code >= 500 || code == 429 + + suspend fun providers(serverUrl: String): ApiResult = api.listProviders(serverUrl) + + suspend fun oauthCapabilities(serverUrl: String): ApiResult = + api.oauthCapabilities(serverUrl) + + suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = + api.externalSignInCapabilities(scope) + + suspend fun identities(scope: AuthScopeSnapshot): ApiResult = api.listIdentities(scope) + + suspend fun disconnect(scope: AuthScopeSnapshot, identityId: String): ApiResult = + api.deleteIdentity(scope, identityId) + + suspend fun linkTicket( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult = api.createLinkTicket(scope, installationId, password) + + suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = api.linkWithCredentials(scope, installationId, password, username, directoryPassword) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ServerIdentityRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ServerIdentityRepository.kt new file mode 100644 index 0000000000..eba7cd1ce2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ServerIdentityRepository.kt @@ -0,0 +1,71 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.api.ServerIdentityApi +import org.siloserver.silo.network.api.ServerIdentityProbe + +/** + * Deployment identities of saved servers. The id comes from + * `GET /api/v2/system/identity` at the saved URL and is recorded on the entry + * ([ServerEntry.verifiedServerId]) so later matches don't need the network. + * + * Used to match a TV's `srv` advertisement and a `silo://device?server=` link + * to a saved server by identity rather than URL spelling: one deployment has + * several addresses, and two deployments can reuse an address over time. + */ +class ServerIdentityRepository( + private val registry: ServerRegistry, + private val api: ServerIdentityApi, +) { + /** + * The identity verified at [entry]'s URL. Probes when none is recorded or + * [refresh] is set; a probe that can't reach the server keeps the recorded + * value, and one that reaches a server without the identity contract + * clears it. + */ + suspend fun identityOf(entry: ServerEntry, refresh: Boolean = false): String? { + val recorded = entry.verifiedServerId?.takeIf { it.isNotBlank() } + if (recorded != null && !refresh) return recorded + return when (val probe = api.probeIdentity(entry.url)) { + is ServerIdentityProbe.Identity -> { + if (probe.serverId != recorded) registry.setVerifiedServerId(entry.id, probe.serverId) + probe.serverId + } + ServerIdentityProbe.UnsupportedServer -> { + if (recorded != null) registry.setVerifiedServerId(entry.id, null) + null + } + ServerIdentityProbe.Unreachable -> recorded + } + } + + /** + * The entries among [candidates] whose identity is [serverId]. Entries + * with a recorded identity are matched without the network; the others + * are probed (in parallel) only when no recorded identity matched. + */ + suspend fun entriesFor( + serverId: String, + candidates: List = registry.entries.value, + ): List { + val wanted = serverId.trim().takeIf { it.isNotEmpty() } ?: return emptyList() + val recorded = candidates.filter { it.verifiedServerId == wanted } + if (recorded.isNotEmpty()) return recorded + val unverified = candidates.filter { it.verifiedServerId.isNullOrBlank() && it.url.isNotBlank() } + if (unverified.isEmpty()) return emptyList() + return coroutineScope { + unverified + .map { entry -> async { entry to identityOf(entry) } } + .awaitAll() + .filter { (_, id) -> id == wanted } + .map { (entry, _) -> entry } + } + } + + /** Probe an address that isn't saved (a link's `url`, a pushed server). */ + suspend fun probe(serverUrl: String): ServerIdentityProbe = api.probeIdentity(serverUrl) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/DevicePairingViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/DevicePairingViewModel.kt index 18c0a7f3ea..cb89ee6497 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/DevicePairingViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/DevicePairingViewModel.kt @@ -2,24 +2,158 @@ package org.siloserver.silo.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.siloserver.silo.model.auth.DeviceCodeFormat import org.siloserver.silo.model.auth.DeviceLoginLookupResponse import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.SiloAuthUnavailableException +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.signedInEntries import org.siloserver.silo.repository.DeviceLoginRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +/** A saved, signed-in server a TV's code can be approved on. */ +data class DeviceApprovalServer( + val id: String, + val url: String, + val displayName: String, + val isActive: Boolean = false, +) + +/** + * The servers "Sign in a TV" can approve on, and the credentials each call + * runs with. Codes are unique only per server, so a typed code is looked up + * on one chosen server, never on every saved server. + */ +interface DeviceApprovalServers { + /** Signed-in servers, the active one first. */ + suspend fun list(): List + + /** The profileless account scope approvals on [server] run in. */ + fun scope(server: DeviceApprovalServer): AuthScopeSnapshot + + /** + * The account the approval would sign the TV in as, when known. Read + * before each lookup through [server]'s own scope, renewing its access + * token first when it has expired or is about to: the approver refreshes + * before looking up and approving (TV sign-in spec). Throws + * [SiloAuthUnavailableException.PROVIDER_UNAVAILABLE] when that renewal + * was refused because the sign-in provider couldn't re-check the session. + */ + suspend fun accountName(server: DeviceApprovalServer): String? = null +} + +/** Production [DeviceApprovalServers] over the saved-server registry. */ +class RegistryDeviceApprovalServers( + private val registry: ServerRegistry, + private val tokenManager: TokenManager, + private val identityTransitions: IdentityTransitionBarrier, + /** The account signed in on a server, read through that server's own scope. */ + private val accountNameOf: suspend (AuthScopeSnapshot) -> String? = { null }, +) : DeviceApprovalServers { + override suspend fun list(): List { + val activeId = registry.activeServerId.value + return registry.signedInEntries(tokenManager) + .map { DeviceApprovalServer(it.id, it.url, it.displayName, isActive = it.id == activeId) } + } + + override fun scope(server: DeviceApprovalServer) = + AuthScopeSnapshot.profileless(server.id, server.url, identityTransitions.generation.value) + + override suspend fun accountName(server: DeviceApprovalServer): String? = accountNameOf(scope(server)) +} + +/** Why "Sign in a TV" can't go on; each screen maps these to its own strings. */ +sealed interface DevicePairingError { + data object EnterCode : DevicePairingError + data object SignInFirst : DevicePairingError + data object NotFound : DevicePairingError + + /** The code isn't on [serverName]; other saved servers are offered. */ + data class NotFoundOn(val serverName: String) : DevicePairingError + data object Expired : DevicePairingError + data object AlreadySignedIn : DevicePairingError + data object Declined : DevicePairingError + data object Canceled : DevicePairingError + data object Network : DevicePairingError + + /** + * The sign-in provider couldn't re-check this phone's session (503 + * `provider_unavailable` on its refresh). The session stays; try again. + */ + data object ProviderUnavailable : DevicePairingError + + /** The server's own explanation, or null for a generic failure. */ + data class Server(val message: String?) : DevicePairingError +} + +/** How the TV's request ended after this phone approved it. */ +enum class DeviceFollowOutcome { + /** The TV collected its sign-in. */ + SignedIn, + + /** The TV withdrew the request (it left its sign-in screen or signed in another way). */ + Canceled, + + /** The request expired before the TV collected it. */ + Expired, + + /** The request was declined elsewhere. */ + Denied, + + /** The TV hadn't collected it when following stopped. */ + TimedOut, +} + data class DevicePairingUiState( val token: String? = null, + /** The typed code without separators; shown grouped 4+4. */ val code: String = "", val lookup: DeviceLoginLookupResponse? = null, val isLoading: Boolean = false, val isSubmitting: Boolean = false, val completedStatus: String? = null, - val error: String? = null, + val error: DevicePairingError? = null, + /** Servers the code can be approved on; empty when only the active server is used. */ + val servers: List = emptyList(), + val selectedServerId: String? = null, + /** Account the approval signs the TV in as, when known. */ + val accountName: String? = null, + /** The selected server's account is being read. */ + val checkingAccount: Boolean = false, + /** + * Why the selected server's account couldn't be read, when the reason is + * known: the sign-in provider couldn't re-check the session + * ([DevicePairingError.ProviderUnavailable]). Null otherwise. + */ + val accountError: DevicePairingError? = null, + /** The chosen server answered "no such code": offer the others. */ + val notFound: Boolean = false, + /** After approving: how the TV's request ended, or null while it hasn't yet. */ + val followOutcome: DeviceFollowOutcome? = null, + /** + * A link named the server (`silo://device?server=`): the code is looked + * up and approved there, through that server's own credentials, and + * nowhere else. No chooser, no "Try ". + */ + val serverLocked: Boolean = false, ) { + /** + * A `silo://device?token=` link names no server, so its lookup and + * decision go to the active server: no chooser, no "Try ". + */ + val canChooseServer: Boolean + get() = token == null && !serverLocked && servers.size > 1 + /** * Whether approving or denying is a real, informed decision right now. * @@ -27,33 +161,100 @@ data class DevicePairingUiState( * deep link arrives with a token already set and starts its lookup * automatically, so "there is something to submit" is true from * construction — before the server has said which device is asking, from - * where, or with which match code. Those details are the entire content of - * the decision, and they only exist once [lookup] resolves. Gating on the + * where, or with which code. Those details are the entire content of the + * decision, and they only exist once [lookup] resolves. Gating on the * identifier let a viewer approve a sign-in they could not see, and kept * approving available after a failed lookup had cleared [lookup] and * reported the request invalid or expired. */ val canDecide: Boolean get() = lookup != null && !isSubmitting + + /** + * Whether the card can say which account approving signs the TV in as. + * Required on a saved server other than the active one: the approver + * sees exactly which account they are approving for (TV sign-in spec). + * The active server's account is the one this phone is using. + */ + val accountConfirmed: Boolean + get() = accountName != null || selectedServer?.isActive != false + + /** Reading the account on a non-active server failed: say so and offer a retry. */ + val accountUnconfirmed: Boolean + get() = !accountConfirmed && !checkingAccount + + /** Approving also needs the account known; declining doesn't. */ + val canApprove: Boolean + get() = canDecide && accountConfirmed + + val selectedServer: DeviceApprovalServer? + get() = servers.firstOrNull { it.id == selectedServerId } + + /** Other saved servers to try after [notFound]. */ + val otherServers: List + get() = servers.filter { it.id != selectedServerId } } +/** + * Approves (or declines) a TV's sign-in from the phone: by `silo://device` + * link or by typing the TV's code. With [servers], the code is looked up and + * approved on the chosen server through its own account scope (the active + * server is preselected); without, on the active server. + * + * [lockServer]: a link named [initialServerId], so approving happens on that + * saved server's own account scope without switching the phone's active + * server (silo-apple parity). When this phone isn't signed in there the + * screen asks to sign in rather than trying another server. + */ class DevicePairingViewModel( private val repository: DeviceLoginRepository, initialToken: String?, initialCode: String?, + private val servers: DeviceApprovalServers? = null, + private val initialServerId: String? = null, + lockServer: Boolean = false, ) : ViewModel() { private val _uiState = MutableStateFlow( DevicePairingUiState( token = initialToken?.takeIf { it.isNotBlank() }, - code = initialCode.orEmpty(), + code = DeviceCodeFormat.normalize(initialCode.orEmpty()), + // Only a code can be looked up on a named server's own scope; a + // token link always goes to the active server. + serverLocked = lockServer && servers != null && initialServerId != null && initialToken.isNullOrBlank(), ), ) val uiState: StateFlow = _uiState.asStateFlow() + private var followJob: Job? = null + init { - if (_uiState.value.token != null || _uiState.value.code.isNotBlank()) { - lookup() + val hasRequest = _uiState.value.token != null || _uiState.value.code.isNotBlank() + if (servers == null) { + if (hasRequest) lookup() + } else { + viewModelScope.launch { + val list = servers.list() + val locked = _uiState.value.serverLocked + // A token link is looked up on the active server, so that's the + // one the card names. A locked link never falls back to another + // server: its code means nothing there. + val named = list.firstOrNull { it.id == initialServerId && _uiState.value.token == null } + val selected = when { + // Never another server: the token goes to the active one. + _uiState.value.token != null -> list.firstOrNull { it.isActive } + locked -> named + else -> named ?: list.firstOrNull { it.isActive } ?: list.firstOrNull() + } + _uiState.update { it.copy(servers = list, selectedServerId = selected?.id) } + when { + locked && selected == null -> + _uiState.update { it.copy(error = DevicePairingError.SignInFirst) } + // The lookup reads the account first. + hasRequest -> lookup() + else -> loadAccountName() + } + } } } @@ -65,12 +266,36 @@ class DevicePairingViewModel( lookupGeneration++ _uiState.update { it.copy( - code = value.trim().uppercase(), + code = DeviceCodeFormat.normalize(value).take(DeviceCodeFormat.LENGTH), lookup = null, completedStatus = null, error = null, + notFound = false, + ) + } + } + + /** Choose the server to look the code up on; looks it up again when one was typed. */ + fun selectServer(serverId: String) { + if (!_uiState.value.canChooseServer) return + if (_uiState.value.servers.none { it.id == serverId }) return + lookupGeneration++ + _uiState.update { + it.copy( + selectedServerId = serverId, + accountName = null, + accountError = null, + lookup = null, + error = null, + notFound = false, + isLoading = false, ) } + if (_uiState.value.code.isNotBlank() || _uiState.value.token != null) { + lookup() + } else { + viewModelScope.launch { loadAccountName() } + } } /** @@ -92,19 +317,33 @@ class DevicePairingViewModel( val token = current.token?.takeIf { it.isNotBlank() } val code = current.code.takeIf { it.isNotBlank() } if (token == null && code == null) { - _uiState.update { it.copy(error = "Enter the code shown on your TV.") } + _uiState.update { it.copy(error = DevicePairingError.EnterCode) } + return + } + + if (current.serverLocked && current.selectedServer == null) { + _uiState.update { it.copy(error = DevicePairingError.SignInFirst) } return } val generation = ++lookupGeneration loadingOwner = generation + // The server chosen when the lookup started; choosing another retires it. + val server = current.selectedServer viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null, completedStatus = null) } - val lookupResult = repository.lookup(token = token, code = code) - // Retired while in flight: a newer lookup or, more importantly, a - // decision has superseded this answer. Clearing isLoading is still - // this request's job, but nothing else it has to say is current. - if (generation != lookupGeneration) { + _uiState.update { it.copy(isLoading = true, error = null, completedStatus = null, notFound = false) } + // Read the account first: it renews the chosen server's token when + // it is expiring, and the card names who approving signs in. + if (servers != null) loadAccountName() + // Retired while the account was read (another server chosen, a + // decision made): send nothing, since a lookup marks the request + // opened on whichever server receives it. + val lookupResult = if (generation == lookupGeneration) requestLookup(token, code, server) else null + // Retired before or while in flight: a newer lookup or, more + // importantly, a decision has superseded this answer. Clearing + // isLoading is still this request's job, but nothing else it has + // to say is current. + if (lookupResult == null || generation != lookupGeneration) { // Clear the loading flag only while this lookup still owns it. // A newer lookup has raised it again for itself, and clearing // it here would report that one as finished while it runs. @@ -113,10 +352,15 @@ class DevicePairingViewModel( } return@launch } - when (val result = lookupResult) { + when (val result: ApiResult = lookupResult) { is ApiResult.Success -> { + val problem = statusProblem(result.data.status) _uiState.update { - it.copy(isLoading = false, lookup = result.data, error = null) + if (problem == null) { + it.copy(isLoading = false, lookup = result.data, error = null) + } else { + it.copy(isLoading = false, lookup = null, error = problem) + } } } is ApiResult.Error -> { @@ -124,13 +368,14 @@ class DevicePairingViewModel( it.copy( isLoading = false, lookup = null, - error = pairingError(result.code, result.message), + error = pairingError(result, it), + notFound = result.code == 404 && it.canChooseServer, ) } } is ApiResult.NetworkError -> { _uiState.update { - it.copy(isLoading = false, error = "Network error. Check your connection.") + it.copy(isLoading = false, error = networkError(result)) } } } @@ -141,6 +386,12 @@ class DevicePairingViewModel( decide(approve = true) } + /** "Try again" after the selected server's account couldn't be read. */ + fun retryAccount() { + if (_uiState.value.checkingAccount) return + viewModelScope.launch { loadAccountName() } + } + fun deny() { decide(approve = false) } @@ -150,9 +401,15 @@ class DevicePairingViewModel( val token = current.token?.takeIf { it.isNotBlank() } val code = current.code.takeIf { it.isNotBlank() } if (token == null && code == null) { - _uiState.update { it.copy(error = "Enter the code shown on your TV.") } + _uiState.update { it.copy(error = DevicePairingError.EnterCode) } + return + } + if (current.serverLocked && current.selectedServer == null) { + _uiState.update { it.copy(error = DevicePairingError.SignInFirst) } return } + // Never sign the TV in to an account the card couldn't name. + if (approve && !current.accountConfirmed) return // Retires any lookup already running, before it can report back over // the decision this is about to make. The retired lookup will not clear @@ -160,13 +417,10 @@ class DevicePairingViewModel( // it here — otherwise the screen shows a spinner nothing will finish. lookupGeneration++ _uiState.update { it.copy(isLoading = false) } + val server = current.selectedServer viewModelScope.launch { - _uiState.update { it.copy(isSubmitting = true, error = null, completedStatus = null) } - val result = if (approve) { - repository.approve(token = token, code = code) - } else { - repository.deny(token = token, code = code) - } + _uiState.update { it.copy(isSubmitting = true, error = null, completedStatus = null, followOutcome = null) } + val result = requestDecision(approve, token, code, server) when (result) { is ApiResult.Success -> { _uiState.update { @@ -176,29 +430,145 @@ class DevicePairingViewModel( error = null, ) } + if (approve) followTv(token, code, server) } is ApiResult.Error -> { _uiState.update { it.copy( isSubmitting = false, - error = pairingError(result.code, result.message), + error = pairingError(result, it), ) } } is ApiResult.NetworkError -> { _uiState.update { - it.copy(isSubmitting = false, error = "Network error. Check your connection.") + it.copy(isSubmitting = false, error = networkError(result)) } } } } } - private fun pairingError(code: Int, message: String): String = when (code) { - 401 -> "Sign in before approving this device." - 404 -> "This device sign-in request was not found." - 409 -> message.ifBlank { "This device sign-in request is no longer active." } - 410 -> "This device sign-in request has expired." - else -> message.ifBlank { "Device pairing failed." } + /** + * After approving, follow the request until the TV collects its sign-in + * (or it ends another way), so the result can move from "Your TV is + * signing in" to what happened. Best effort and bounded; it never changes + * the decision's outcome. + */ + private fun followTv(token: String?, code: String?, server: DeviceApprovalServer?) { + followJob?.cancel() + followJob = viewModelScope.launch { + repeat(FOLLOW_ATTEMPTS) { + delay(FOLLOW_INTERVAL_MS) + val status = (requestLookup(token, code, server) as? ApiResult.Success)?.data?.status ?: return@repeat + val outcome = when (status) { + "consumed" -> DeviceFollowOutcome.SignedIn + "canceled" -> DeviceFollowOutcome.Canceled + "expired" -> DeviceFollowOutcome.Expired + "denied" -> DeviceFollowOutcome.Denied + else -> null + } + if (outcome != null) { + _uiState.update { it.copy(followOutcome = outcome) } + return@launch + } + } + _uiState.update { it.copy(followOutcome = DeviceFollowOutcome.TimedOut) } + } + } + + private suspend fun requestLookup( + token: String?, + code: String?, + server: DeviceApprovalServer?, + ): ApiResult { + return if (token == null && code != null && server != null && servers != null) { + repository.lookup(servers.scope(server), code) + } else { + repository.lookup(token = token, code = code) + } + } + + private suspend fun requestDecision( + approve: Boolean, + token: String?, + code: String?, + server: DeviceApprovalServer?, + ): ApiResult { + return if (token == null && code != null && server != null && servers != null) { + val scope = servers.scope(server) + if (approve) repository.approve(scope, code) else repository.deny(scope, code) + } else if (approve) { + repository.approve(token = token, code = code) + } else { + repository.deny(token = token, code = code) + } + } + + private suspend fun loadAccountName() { + val server = _uiState.value.selectedServer ?: return + _uiState.update { it.copy(checkingAccount = true) } + var providerUnavailable = false + val name = try { + servers?.accountName(server) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + providerUnavailable = SiloAuthUnavailableException.isProviderUnavailable(e) + null + } + _uiState.update { + if (it.selectedServerId != server.id) return@update it.copy(checkingAccount = false) + it.copy( + checkingAccount = false, + accountName = name, + accountError = DevicePairingError.ProviderUnavailable.takeIf { providerUnavailable }, + ) + } + } + + /** A lookup that answered, but for a request that can't be decided any more. */ + private fun statusProblem(status: String): DevicePairingError? = when (status) { + "pending" -> null + "approved", "consumed" -> DevicePairingError.AlreadySignedIn + "denied" -> DevicePairingError.Declined + "canceled" -> DevicePairingError.Canceled + "expired" -> DevicePairingError.Expired + // A status this app doesn't know: don't offer a decision on it. + else -> DevicePairingError.Server(null) + } + + /** An exchange that never got an answer; a provider outage keeps the session and says so. */ + private fun networkError(result: ApiResult.NetworkError): DevicePairingError = + if (SiloAuthUnavailableException.isProviderUnavailable(result.exception)) { + DevicePairingError.ProviderUnavailable + } else { + DevicePairingError.Network + } + + private fun pairingError(error: ApiResult.Error, state: DevicePairingUiState): DevicePairingError = + if (error.code == 503 && error.error == SiloAuthUnavailableException.PROVIDER_UNAVAILABLE_PROBLEM) { + DevicePairingError.ProviderUnavailable + } else { + pairingError(error.code, error.message, state) + } + + private fun pairingError(code: Int, message: String, state: DevicePairingUiState): DevicePairingError = when (code) { + 401 -> DevicePairingError.SignInFirst + 404 -> state.selectedServer?.takeIf { state.canChooseServer }?.let { DevicePairingError.NotFoundOn(it.displayName) } + ?: DevicePairingError.NotFound + 409 -> DevicePairingError.AlreadySignedIn + 410 -> DevicePairingError.Expired + else -> DevicePairingError.Server(message.takeIf { it.isNotBlank() }) + } + + override fun onCleared() { + followJob?.cancel() + super.onCleared() + } + + private companion object { + const val FOLLOW_ATTEMPTS = 40 + const val FOLLOW_INTERVAL_MS = 3_000L } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormatTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormatTest.kt new file mode 100644 index 0000000000..d8d3e359d7 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/auth/DeviceCodeFormatTest.kt @@ -0,0 +1,39 @@ +package org.siloserver.silo.model.auth + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeviceCodeFormatTest { + @Test + fun groupsEightCharacterCodesFourPlusFour() { + assertEquals("4821 7730", DeviceCodeFormat.display("4821-7730")) + assertEquals("4821 7730", DeviceCodeFormat.display("48217730")) + // Older servers issue letters; they are shown the same way. + assertEquals("ABCD EFGH", DeviceCodeFormat.display("abcd-efgh")) + // Anything else is shown as sent. + assertEquals("WXYZ 12", DeviceCodeFormat.display("WXYZ-12")) + } + + @Test + fun normalizesTypedInputAndSpellsItOut() { + assertEquals("48217730", DeviceCodeFormat.normalize(" 4821 77-30 ")) + assertEquals("4 8 2 1 7 7 3 0", DeviceCodeFormat.spoken("4821-7730")) + } + + @Test + fun activateTextDropsHttpsButKeepsHttpAndPaths() { + assertEquals( + "silo.example.test/activate", + DeviceCodeFormat.activateText("https://silo.example.test/activate", ""), + ) + assertEquals( + "http://192.168.1.5:8090/activate", + DeviceCodeFormat.activateText("http://192.168.1.5:8090/activate", ""), + ) + assertEquals( + "media.example.com/silo/activate", + DeviceCodeFormat.activateText("", "https://media.example.com/silo/activate?code=48217730"), + ) + assertEquals("silo.example.test", DeviceCodeFormat.host("https://silo.example.test/activate")) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshActiveRefreshTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshActiveRefreshTest.kt new file mode 100644 index 0000000000..1617c637bc --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshActiveRefreshTest.kt @@ -0,0 +1,71 @@ +package org.siloserver.silo.network + +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.client.request.post +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.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The active session's side of [freshSiloAuth]: a TV approval from a token + * link, or from the TV's own pair screen, goes out on the active session + * rather than a pinned scope. When its expired token's refresh is refused + * with 503 `provider_unavailable`, the approval isn't sent with a bearer that + * would come back 401 and read as "sign in again"; it fails with the reason. + */ +class SiloAuthPluginFreshActiveRefreshTest { + private suspend fun expiredSession() = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens(accessToken = "stale", refreshToken = "refresh-1", expiresIn = 0) + } + + private fun client(tokens: TokenManager, paths: MutableList) = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + """{"type":"https://siloserver.org/docs/api/v2/problems/provider_unavailable","title":"Provider unavailable",""" + + """"status":503,"detail":"Try again later; the session stays valid."}""", + HttpStatusCode.ServiceUnavailable, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + + @Test + fun aProviderOutageFailsAFreshRequestWithItsReason() = runTest { + val tokens = expiredSession() + val paths = mutableListOf() + val thrown = assertFailsWith { + client(tokens, paths).post("/api/v2/auth/device/approve") { freshSiloAuth() } + } + assertTrue(SiloAuthUnavailableException.isProviderUnavailable(thrown)) + assertEquals(listOf("/api/v2/auth/refresh"), paths, "the approval never went out") + assertEquals("stale", tokens.getAccessToken(), "the session is kept") + assertEquals("refresh-1", tokens.getRefreshToken()) + } + + @Test + fun anUnmarkedRequestStillSpendsTheBearer() = runTest { + val tokens = expiredSession() + val paths = mutableListOf() + client(tokens, paths).post("/api/v2/progress") + assertEquals(listOf("/api/v2/auth/refresh", "/api/v2/progress"), paths) + assertEquals("stale", tokens.getAccessToken()) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshPinnedRefreshTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshPinnedRefreshTest.kt new file mode 100644 index 0000000000..41eca1b1f1 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginFreshPinnedRefreshTest.kt @@ -0,0 +1,251 @@ +package org.siloserver.silo.network + +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.client.request.post +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.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * A pinned request marked [freshSiloAuth] (a phone approving a TV on a saved + * server, active or not) renews that scope's access token first when it is + * expiring, through the same refresh the 401 path uses. Other pinned requests + * keep the reactive behaviour. + */ +class SiloAuthPluginFreshPinnedRefreshTest { + private val scope = AuthScopeSnapshot.profileless("cabin", "https://cabin.example", identityGeneration = 0L) + + private class Recorded(val path: String, val authorization: String?) + + private fun client(tokens: ScopeTokens, calls: MutableList) = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + val json = headersOf(HttpHeaders.ContentType, "application/json") + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond("""{"access_token":"fresh","refresh_token":"refresh-2","expires_in":3600}""", HttpStatusCode.OK, json) + } else { + respond("{}", HttpStatusCode.OK, json) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + + @Test + fun anExpiringScopeIsRenewedBeforeTheApproval() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf() + client(tokens, calls).post("/api/v2/auth/device/approve") { + authScope(scope) + freshSiloAuth() + } + assertEquals(listOf("/api/v2/auth/refresh", "/api/v2/auth/device/approve"), calls.map { it.path }) + assertEquals(null, calls[0].authorization, "the refresh carries only its refresh token") + assertEquals("Bearer fresh", calls[1].authorization) + assertEquals("refresh-2", tokens.refresh, "the rotated pair is kept for that server") + assertEquals(0, tokens.activeWrites, "the active server's slot is untouched") + } + + @Test + fun aFreshScopeGoesStraightOut() = runTest { + val tokens = ScopeTokens(expiring = false) + val calls = mutableListOf() + client(tokens, calls).post("/api/v2/auth/device/approve") { + authScope(scope) + freshSiloAuth() + } + assertEquals(listOf("/api/v2/auth/device/approve"), calls.map { it.path }) + assertEquals("Bearer stale", calls[0].authorization) + } + + @Test + fun aSingleAttemptRequestIsRenewedFirstAndSentOnce() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf() + client(tokens, calls).post("/api/v2/account/identities/link-complete") { + authScope(scope) + freshSiloAuth() + singleAttempt() + } + assertEquals( + listOf("/api/v2/auth/refresh", "/api/v2/account/identities/link-complete"), + calls.map { it.path }, + ) + assertEquals("Bearer fresh", calls[1].authorization) + } + + @Test + fun aSingleAttemptRequestIsNotReplayedAfterA401() = runTest { + val tokens = ScopeTokens(expiring = false) + val calls = mutableListOf() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + respond("{}", HttpStatusCode.Unauthorized, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + client.post("/api/v2/account/identities/link-complete") { + authScope(scope) + freshSiloAuth() + singleAttempt() + } + assertEquals(listOf("/api/v2/account/identities/link-complete"), calls.map { it.path }) + } + + /** + * The approver's refresh is refused with 503 `provider_unavailable` (the + * provider couldn't re-check the session under `fail_closed`) and its + * token has expired: the approval isn't sent with a bearer that would + * come back 401 and read as "sign in again". The session is kept. + */ + @Test + fun aProviderOutageOnTheApproversRefreshFailsWithItsReason() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + """{"type":"https://siloserver.org/docs/api/v2/problems/provider_unavailable","title":"Provider unavailable",""" + + """"status":503,"detail":"Try again later; the session stays valid."}""", + HttpStatusCode.ServiceUnavailable, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + val thrown = assertFailsWith { + client.post("/api/v2/auth/device/approve") { + authScope(scope) + freshSiloAuth() + } + } + assertTrue(SiloAuthUnavailableException.isProviderUnavailable(thrown)) + assertEquals(listOf("/api/v2/auth/refresh"), calls.map { it.path }, "the approval never went out") + assertEquals("stale", tokens.access, "the session is kept") + assertEquals("refresh-1", tokens.refresh) + } + + @Test + fun anotherRefreshFailureStillSpendsTheBearer() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond("bad gateway", HttpStatusCode.BadGateway) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + client.post("/api/v2/auth/device/approve") { + authScope(scope) + freshSiloAuth() + } + assertEquals(listOf("/api/v2/auth/refresh", "/api/v2/auth/device/approve"), calls.map { it.path }) + } + + /** + * The scope is signed out while its refresh is in flight, and the refresh + * fails: the approval isn't sent with the bearer captured before, which + * may still be valid for the account that left. + */ + @Test + fun aScopeSignedOutDuringItsRefreshSendsNothing() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf() + val client = HttpClient( + MockEngine { request -> + calls += Recorded(request.url.encodedPath, request.headers[HttpHeaders.Authorization]) + if (request.url.encodedPath.endsWith("/auth/refresh")) { + tokens.access = null + respond("bad gateway", HttpStatusCode.BadGateway) + } else { + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + assertFailsWith { + client.post("/api/v2/auth/device/approve") { + authScope(scope) + freshSiloAuth() + } + } + assertEquals(listOf("/api/v2/auth/refresh"), calls.map { it.path }, "the approval never went out") + } + + @Test + fun unmarkedPinnedRequestsStayReactive() = runTest { + val tokens = ScopeTokens(expiring = true) + val calls = mutableListOf() + client(tokens, calls).post("/api/v2/progress") { authScope(scope) } + assertEquals(listOf("/api/v2/progress"), calls.map { it.path }) + } + + /** One saved, non-active server ("cabin") whose token is or isn't expiring. */ + private class ScopeTokens(private val expiring: Boolean) : TokenManager { + var access: String? = "stale" + var refresh = "refresh-1" + var activeWrites = 0 + + override suspend fun accessTokenExpiresWithin(scope: AuthScopeSnapshot, marginMs: Long) = + expiring && access == "stale" + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = access + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String = refresh + override suspend fun saveTokensForScope( + scope: AuthScopeSnapshot, + accessToken: String, + refreshToken: String, + expiresIn: Long, + ) { + access = accessToken + refresh = refreshToken + } + + override val sessionExpired = MutableSharedFlow() + override suspend fun getAccessToken(): String = "home-access" + override suspend fun getRefreshToken(): String = "home-refresh" + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) { + activeWrites++ + } + override suspend fun clearTokens() = Unit + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String? = null + 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://home.example" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String = "home" + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginRefreshFailureTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginRefreshFailureTest.kt index fbbb9fbcba..27260eaab2 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginRefreshFailureTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginRefreshFailureTest.kt @@ -36,6 +36,69 @@ class SiloAuthPluginRefreshFailureTest { assertEquals("refresh-token", tokenManager.getRefreshToken()) } + /** + * External sign-in (B1): a refresh the provider couldn't re-check answers + * 503 `provider_unavailable`. The session stays, and the next request's + * refresh asks again instead of signing the person out. + */ + @Test + fun providerUnavailableRefreshKeepsSessionAndRetriesLater() = runTest { + val tokenManager = activeTokenManager() + val paths = mutableListOf() + var refreshes = 0 + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + when { + request.url.encodedPath.endsWith("/auth/refresh") -> { + refreshes += 1 + if (refreshes == 1) { + respond( + content = """{"type":"https://siloserver.org/docs/api/v2/problems/provider_unavailable",""" + + """"title":"Provider unavailable","status":503,"detail":"Try again later; the session stays valid."}""", + status = HttpStatusCode.ServiceUnavailable, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } else { + respond( + content = """{"access_token":"fresh-access","refresh_token":"fresh-refresh","expires_in":3600}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + } + request.headers[HttpHeaders.Authorization] == "Bearer fresh-access" -> + respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + else -> respond( + content = """{"error":"unauthorized","message":"expired"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { this.tokenManager = tokenManager } + } + + val first = client.get("/api/v2/account/me") + assertEquals(HttpStatusCode.Unauthorized, first.status) + assertEquals("expired-access", tokenManager.getAccessToken(), "the session is kept") + assertEquals("refresh-token", tokenManager.getRefreshToken()) + + val second = client.get("/api/v2/account/me") + assertEquals(HttpStatusCode.OK, second.status) + assertEquals(2, refreshes, "the next request refreshes again") + assertEquals("fresh-access", tokenManager.getAccessToken()) + assertEquals( + listOf( + "/api/v2/account/me", "/api/v2/auth/refresh", + "/api/v2/account/me", "/api/v2/auth/refresh", "/api/v2/account/me", + ), + paths, + ) + } + @Test fun unauthorizedRefreshFailureInvalidatesActiveSession() = runTest { val tokenManager = activeTokenManager() diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt new file mode 100644 index 0000000000..9ade18c27f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt @@ -0,0 +1,356 @@ +package org.siloserver.silo.network.api + +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.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +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.content.TextContent +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.siloserver.silo.model.auth.AccountIdentities +import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.PasswordLoginFailure +import org.siloserver.silo.model.auth.SignInOptions +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeAttributeKey +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.FreshSiloAuthAttributeKey +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.SingleAttemptAttributeKey +import org.siloserver.silo.network.SkipSiloAuthAttributeKey +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.ExternalSignInRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * External sign-in on the wire: what the apps send to silo-server's + * `listAuthProviders`, `completeOAuthLogin`, the identity operations and + * `linkAccountIdentityWithCredentials`, and how each refusal comes back. + */ +class ExternalSignInApiTest { + private val scope = AuthScopeSnapshot("server-a", "p1", "https://silo.example.test", null, identityGeneration = 1) + + private fun api(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): Pair { + val client = HttpClient(MockEngine(handler)) { install(ContentNegotiation) { json(SiloJson) } } + return client to DefaultExternalSignInApi(client, ApiV2Gate.Unrestricted) + } + + private fun MockRequestHandleScope.json(body: String, status: HttpStatusCode = HttpStatusCode.OK) = + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + + private fun MockRequestHandleScope.problem(code: String, status: Int, location: String? = null) = respond( + buildString { + append("""{"type":"https://siloserver.org/docs/api/v2/problems/$code","title":"x","status":$status,"detail":"d"""") + if (location != null) append(""","errors":[{"location":"$location","code":"invalid","detail":"x"}]""") + append("}") + }, + HttpStatusCode.fromValue(status), + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + + private fun HttpRequestData.jsonBody() = Json.parseToJsonElement((body as TextContent).text).jsonObject + + @Test + fun providersAndCapabilitiesAreReadUnauthenticatedAtTheGivenServer() = runTest { + val paths = mutableListOf() + val (client, api) = api { request -> + paths += request.url.toString() + assertEquals(true, request.attributes.getOrNull(SkipSiloAuthAttributeKey)) + when (request.url.encodedPath) { + "/api/v2/auth/providers" -> json("""{"items":[{"id":"local","display_name":"Silo","mode":"credentials","default":true}],"password_login":true}""") + else -> json("""{"state":"available","available":true,"native":true,"linking":true,"provider_logout":false,"revision":"r"}""") + } + } + try { + val providers = assertIs>(api.listProviders("https://lan.example:8096/")) + assertEquals("local", providers.data.providers.single().id) + val handshake = assertIs>(api.oauthCapabilities("https://lan.example:8096")) + assertTrue(handshake.data.native) + assertEquals( + listOf("https://lan.example:8096/api/v2/auth/providers", "https://lan.example:8096/api/v2/auth/oauth/capabilities"), + paths, + ) + } finally { client.close() } + } + + @Test + fun nativeCompletionSendsTheVerifierOnceAndReturnsTheUser() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("https://silo.example.test/api/v2/auth/oauth/complete", request.url.toString()) + assertEquals(true, request.attributes.getOrNull(SkipSiloAuthAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals("one-time", body["code"]?.jsonPrimitive?.content) + assertEquals("v".repeat(43), body["code_verifier"]?.jsonPrimitive?.content) + json( + """{"access_token":"acc","refresh_token":"ref","expires_in":3600,"next":"/", + "user":{"id":"1","username":"alice","email":"a@example.test","role":"user"}}""", + ) + } + try { + val login = assertIs>(api.completeOAuthLogin("https://silo.example.test", "one-time", "v".repeat(43))) + assertEquals("alice", (login.data as org.siloserver.silo.model.auth.LoginResponse).user.username) + } finally { client.close() } + } + + @Test + fun nativeCompletionRefusalsKeepTheirProblemCodes() = runTest { + for ((code, status) in listOf("invalid_grant" to 400, "invalid_token" to 401)) { + val (client, api) = api { problem(code, status) } + try { + val error = assertIs(api.completeOAuthLogin("https://silo.example.test", "c", "v".repeat(43))) + assertEquals(status, error.code) + assertEquals(code, error.error) + } finally { client.close() } + } + } + + @Test + fun linkTicketConfirmsThePasswordThroughTheAccountScope() = runTest { + val (client, api) = api { request -> + assertEquals("/api/v2/account/identities/link-ticket", request.url.encodedPath) + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + assertEquals(true, request.attributes.getOrNull(FreshSiloAuthAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals("3", body["installation_id"]?.jsonPrimitive?.content) + if (body["password"]?.jsonPrimitive?.content == "right") { + json("""{"ticket":"t-123","expires_at":"2026-01-02T03:09:05.678Z"}""") + } else { + problem("validation_failed", 422, "body.password") + } + } + try { + val ticket = assertIs>(api.createLinkTicket(scope, "3", "right")) + assertFalse(ticket.data.toString().contains("t-123"), "the ticket stays out of logs") + val wrong = assertIs(api.createLinkTicket(scope, "3", "wrong")) + assertEquals(DefaultExternalSignInApi.WRONG_PASSWORD, wrong.error) + } finally { client.close() } + } + + @Test + fun linkWithCredentialsSendsAllFourMembersAndReadsTheCreatedIdentity() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("/api/v2/account/identities/link-credentials", request.url.encodedPath) + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals(setOf("installation_id", "password", "username", "directory_password"), body.keys) + assertEquals("6", body["installation_id"]?.jsonPrimitive?.content) + assertEquals("local-pw", body["password"]?.jsonPrimitive?.content) + assertEquals("alice", body["username"]?.jsonPrimitive?.content) + assertEquals("dir-pw", body["directory_password"]?.jsonPrimitive?.content) + respond( + """{"id":"9","installation_id":"6","provider_id":"plugin:6:ldap","provider_name":"Directory", + "username":"alice","email":"","display_name":"Alice","linked_at":"2026-01-02T03:04:05.678Z", + "last_sign_in_at":null}""", + HttpStatusCode.Created, + headersOf(HttpHeaders.ContentType to listOf("application/json"), HttpHeaders.Location to listOf("/api/v2/account/identities")), + ) + } + try { + val linked = assertIs>(api.linkWithCredentials(scope, "6", "local-pw", "alice", "dir-pw")) + assertEquals("Directory", linked.data.providerName) + assertNull(linked.data.lastSignInAt) + } finally { client.close() } + } + + @Test + fun linkWithCredentialsRefusalsAreDistinguishable() = runTest { + val cases = listOf( + Triple("validation_failed", 422, "body.password") to DefaultExternalSignInApi.WRONG_PASSWORD, + Triple("validation_failed", 422, "body.directory_password") to DefaultExternalSignInApi.DIRECTORY_REFUSED, + Triple("local_password_required", 409, null) to "local_password_required", + Triple("not_permitted", 403, null) to "not_permitted", + Triple("account_disabled", 403, null) to "account_disabled", + Triple("password_expired", 403, null) to "password_expired", + Triple("permission_denied", 403, null) to "permission_denied", + Triple("identity_linked_elsewhere", 409, null) to "identity_linked_elsewhere", + Triple("already_linked", 409, null) to "already_linked", + Triple("not_found", 404, null) to "not_found", + Triple("provider_unavailable", 503, null) to "provider_unavailable", + Triple("rate_limited", 429, null) to "rate_limited", + ) + for ((problem, expected) in cases) { + val (code, status, location) = problem + val (client, api) = api { problem(code, status, location) } + try { + val error = assertIs(api.linkWithCredentials(scope, "6", "a", "b", "c")) + assertEquals(status, error.code, code) + assertEquals(expected, error.error, code) + } finally { client.close() } + } + // A success other than 201 is not an identity. + val (client, api) = api { json("{}") } + try { + assertEquals("invalid_response", assertIs(api.linkWithCredentials(scope, "6", "a", "b", "c")).error) + } finally { client.close() } + } + + @Test + fun identitiesAreListedAndDisconnectedThroughTheAccountScope() = runTest { + val calls = mutableListOf() + val (client, api) = api { request -> + calls += "${request.method.value} ${request.url.encodedPath}" + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + when (request.method) { + HttpMethod.Get -> json( + """{"items":[{"id":"4","installation_id":"3","provider_id":"plugin:3:oidc","provider_name":"SSO", + "username":"","email":"alice@example.test","display_name":"Alice","linked_at":"2026-01-02T03:04:05.000Z", + "last_sign_in_at":null}],"can_unlink":false}""", + ) + else -> problem("last_sign_in_method", 409) + } + } + try { + val listed = assertIs>(api.listIdentities(scope)).data + assertEquals(false, listed.canUnlink) + val identity = listed.items.single() + assertEquals("alice@example.test", identity.accountLabel, "the email stands in for an empty username") + val refused = assertIs(api.deleteIdentity(scope, "4")) + assertEquals("last_sign_in_method", refused.error) + assertEquals(listOf("GET /api/v2/account/identities", "DELETE /api/v2/account/identities/4"), calls) + } finally { client.close() } + } + + @Test + fun wireModelsNeverPrintSecrets() { + val strings = listOf( + CompleteOAuthLoginV2(code = "secret-code", codeVerifier = "secret-verifier").toString(), + LinkTicketRequestV2(installationId = "3", password = "secret-pw").toString(), + LinkCredentialsRequestV2("6", "secret-pw", "secret-user", "secret-dir").toString(), + ) + strings.forEach { assertFalse(it.contains("secret"), it) } + } +} + +class SignInOptionsTest { + private val local = SignInProvider("local", "Silo account", SignInProvider.Mode.Credentials, true, null, null, null) + private val ldap = SignInProvider("plugin:6:ldap", "Directory", SignInProvider.Mode.Credentials, false, null, "6", null) + private val oidc = SignInProvider( + "plugin:5:oidc", "Keycloak", SignInProvider.Mode.OAuth, false, null, "5", + "/api/v2/auth/oauth/5/native/start", + ) + private val native = OAuthHandshakeCapabilities(available = true, native = true, linking = true) + + @Test + fun oidcOnlyServerWithLocalPasswordsOffHidesThePasswordForm() { + val options = SignInOptions.of(SignInProviders(listOf(oidc), passwordLogin = false), native) + assertFalse(options.showPasswordForm) + assertEquals(listOf(oidc), options.oauthProviders) + } + + @Test + fun ldapKeepsThePasswordFormWithoutAProviderField() { + // Local sign-in off, directory on: the password form stays, and it + // is the server that routes a password login to the directory. + val options = SignInOptions.of(SignInProviders(listOf(ldap), passwordLogin = true), native) + assertTrue(options.showPasswordForm) + assertEquals(ldap, options.directoryProvider) + // Even if a server said password_login=false, a listed credentials provider takes a password. + assertTrue(SignInOptions.of(SignInProviders(listOf(ldap), passwordLogin = false), native).showPasswordForm) + } + + @Test + fun localAndOidcShowsBoth() { + val options = SignInOptions.of(SignInProviders(listOf(local, oidc), passwordLogin = true), native) + assertTrue(options.showPasswordForm) + assertEquals(listOf(oidc), options.oauthProviders) + assertNull(options.directoryProvider) + } + + @Test + fun providerButtonsNeedTheNativeHandoff() { + val noNative = OAuthHandshakeCapabilities(available = true, native = false, linking = false) + assertTrue(SignInOptions.of(SignInProviders(listOf(local, oidc), true), noNative).oauthProviders.isEmpty()) + assertTrue(SignInOptions.of(SignInProviders(listOf(local, oidc), true), OAuthHandshakeCapabilities.None).oauthProviders.isEmpty()) + val noStart = oidc.copy(nativeStartPath = null) + assertTrue(SignInOptions.of(SignInProviders(listOf(noStart), false), native).oauthProviders.isEmpty()) + } + + @Test + fun passwordLoginFailuresMapByProblemCodeFirst() { + assertEquals(PasswordLoginFailure.LocalLoginDisabled, PasswordLoginFailure.of(403, "local_login_disabled")) + assertEquals(PasswordLoginFailure.NotPermitted, PasswordLoginFailure.of(403, "not_permitted")) + assertEquals(PasswordLoginFailure.PasswordExpired, PasswordLoginFailure.of(403, "password_expired")) + assertEquals(PasswordLoginFailure.ProviderUnavailable, PasswordLoginFailure.of(503, "provider_unavailable")) + assertEquals(PasswordLoginFailure.InvalidCredentials, PasswordLoginFailure.of(401, "invalid_credentials")) + assertEquals(PasswordLoginFailure.AccountDisabled, PasswordLoginFailure.of(403, "account_disabled")) + assertEquals(PasswordLoginFailure.ProviderUnavailable, PasswordLoginFailure.of(503, "")) + assertEquals(PasswordLoginFailure.Other, PasswordLoginFailure.of(500, "internal")) + assertEquals(PasswordLoginFailure.EmailInUse, PasswordLoginFailure.of(409, "email_in_use")) + assertEquals(PasswordLoginFailure.IdentityLinkedElsewhere, PasswordLoginFailure.of(409, "identity_linked_elsewhere")) + assertEquals(PasswordLoginFailure.RateLimited, PasswordLoginFailure.of(429, "rate_limited")) + assertEquals(PasswordLoginFailure.RateLimited, PasswordLoginFailure.of(429, "")) + } + + @Test + fun selectAccountNeedsAProviderButton() { + val choosing = native.copy(selectAccount = true) + assertTrue(SignInOptions.of(SignInProviders(listOf(oidc), false), choosing).selectAccount) + assertFalse(SignInOptions.of(SignInProviders(listOf(oidc), false), native).selectAccount) + assertFalse(SignInOptions.of(SignInProviders(listOf(local), true), choosing).selectAccount) + } + + @Test + fun optionsAreUnknownOnlyWhenTheServerCouldNotBeAsked() = runTest { + suspend fun optionsWith( + providers: ApiResult, + handshake: ApiResult, + ): SignInOptions? = ExternalSignInRepository(StubDiscovery(providers, handshake)).signInOptions("https://silo.example.test") + + val listed = ApiResult.Success(SignInProviders(listOf(local, oidc), passwordLogin = true)) + val offline = ApiResult.NetworkError(RuntimeException("offline")) + assertNull(optionsWith(offline, ApiResult.Success(native)), "a network failure leaves the options unknown") + assertNull(optionsWith(listed, offline)) + assertEquals(SignInOptions.PasswordOnly, optionsWith(ApiResult.Error(404, "not_found", ""), ApiResult.Success(native))) + assertEquals( + SignInOptions.PasswordOnly, + optionsWith(ApiResult.Error(0, ApiV2Gate.UPDATE_REQUIRED_ERROR, ""), ApiResult.Success(native)), + ) + val noHandshake = optionsWith(listed, ApiResult.Error(404, "not_found", "")) + assertTrue(noHandshake!!.oauthProviders.isEmpty(), "a server without the handshake shows no provider buttons") + assertEquals(listOf(oidc), optionsWith(listed, ApiResult.Success(native))!!.oauthProviders) + } + + private class StubDiscovery( + private val providers: ApiResult, + private val handshake: ApiResult, + ) : ExternalSignInApi { + override suspend fun listProviders(serverUrl: String) = providers + override suspend fun oauthCapabilities(serverUrl: String) = handshake + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot) = TODO() + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String) = TODO() + override suspend fun listIdentities(scope: AuthScopeSnapshot) = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String) = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String) = TODO() + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String) = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ) = TODO() + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt index 02cb455c8c..68a0d4d229 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt @@ -8,9 +8,27 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.siloserver.silo.network.apiv2.ApiV2Fixtures.plusUnknown import org.siloserver.silo.network.apiv2.ApiV2Fixtures.with +import org.siloserver.silo.model.auth.DeviceLoginCancelResponse +import org.siloserver.silo.model.auth.DeviceLoginLookupResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.network.api.DeviceCapabilityV2 +import org.siloserver.silo.network.api.DevicePollV2 +import org.siloserver.silo.network.api.ServerConnectionsV2 +import org.siloserver.silo.network.api.ServerIdentityV2 +import org.siloserver.silo.network.api.AccountIdentityCollectionV2 +import org.siloserver.silo.network.api.AuthProviderCollectionV2 +import org.siloserver.silo.network.api.DefaultExternalSignInApi +import org.siloserver.silo.network.api.ExternalSignInCapabilitiesV2 +import org.siloserver.silo.network.api.LinkTicketV2 +import org.siloserver.silo.network.api.OAuthHandshakeCapabilitiesV2 +import org.siloserver.silo.network.api.TokenPairV2 +import org.siloserver.silo.model.auth.SignInOptions +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.pairing.PairingEndpoint /** * Decodes every vendored API v2 fixture with the production `SiloJson` and @@ -39,13 +57,26 @@ class ApiV2ContractTest { assertEquals(entry.expectedStatus, problem.status, entry.name) assertTrue(problem.code.isNotBlank(), entry.name) } else { - assertEquals(200, entry.expectedStatus, entry.name) + assertTrue(entry.expectedStatus in 200..299, entry.name) when (entry.operationId) { "getSetupStatus" -> ApiV2Fixtures.decode(body) "getCurrentUser" -> ApiV2Fixtures.decode(body) "listProgress" -> Unit // Android does not read /api/v2/progress. "updateProfile" -> ApiV2Fixtures.decode(body) "getSystemInfo" -> ApiV2Fixtures.decode(body) + "startDeviceLogin" -> ApiV2Fixtures.decode(body) + "pollDeviceLogin" -> ApiV2Fixtures.decode(body) + "cancelDeviceLogin" -> ApiV2Fixtures.decode(body) + "getDeviceLogin" -> ApiV2Fixtures.decode(body) + "getDeviceLoginCapability" -> ApiV2Fixtures.decode(body) + "getServerIdentity" -> ApiV2Fixtures.decode(body) + "getServerConnections" -> ApiV2Fixtures.decode(body) + "listAuthProviders" -> ApiV2Fixtures.decode(body) + "getOAuthHandshakeCapabilities" -> ApiV2Fixtures.decode(body) + "getExternalSignInCapabilities" -> ApiV2Fixtures.decode(body) + "completeOAuthLogin" -> ApiV2Fixtures.decode(body) + "listAccountIdentities" -> ApiV2Fixtures.decode(body) + "createAccountIdentityLinkTicket" -> ApiV2Fixtures.decode(body) else -> error("unhandled success fixture ${entry.name} (${entry.operationId})") } } @@ -253,4 +284,244 @@ class ApiV2ContractTest { assertEquals(emptyList(), problem.errors) assertEquals("x", problem.code) } + + // --- Device sign-in (TV) --- + + @Test + fun deviceLoginStartFixtureCarriesTheDigitCodeAndActivateLink() { + val start = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("start_device_login_ok").plusUnknown()) + assertEquals("4821-7730", start.userCode) + assertEquals("https://silo.example.test/activate", start.verificationUri) + assertEquals("https://silo.example.test/activate?code=48217730", start.verificationUriComplete) + assertEquals(900, start.expiresIn) + } + + @Test + fun deviceLoginPollFixturesCarryOpenedAndTokens() { + val opened = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("poll_device_login_opened")).domain() + assertEquals("pending", opened.status) + assertTrue(opened.opened) + assertNull(opened.accessToken) + assertEquals("2026-01-02T03:14:05.678Z", opened.expiresAt, "a pending poll carries the current expiry") + + val approved = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("poll_device_login_ok")).domain() + assertEquals("approved", approved.status) + assertFalse(approved.opened) + assertEquals("acc", approved.accessToken) + assertEquals("ref", approved.refreshToken) + + // Servers that predate the opened signal omit it. + val legacy = ApiV2Fixtures.decode( + ApiV2Fixtures.bodyObject("poll_device_login_opened").let { body -> + kotlinx.serialization.json.JsonObject(body.filterKeys { it != "opened" }) + }, + ).domain() + assertFalse(legacy.opened) + } + + @Test + fun deviceLoginCancelLookupAndCapabilityFixtures() { + assertEquals("canceled", ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("cancel_device_login_ok")).status) + + val lookup = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("get_device_login_ok")) + assertEquals("3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", lookup.serverId) + assertEquals("2026-01-02T03:04:05.678Z", lookup.requestedAt) + assertEquals("Silo", lookup.serverName) + assertEquals("4821-7730", lookup.userCode) + + val capability = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("get_device_login_capability_ok")).domain() + assertTrue(capability.deviceLoginAvailable) + assertTrue(capability.cancel) + assertTrue(capability.openedSignal) + + val unconfigured = ApiV2Fixtures.decode( + with(ApiV2Fixtures.bodyObject("get_device_login_capability_ok"), "state" to JsonPrimitive("not_configured")), + ).domain() + assertFalse(unconfigured.deviceLoginAvailable) + assertFalse(unconfigured.cancel) + } + + @Test + fun serverIdentityAndConnectionsFixtures() { + assertEquals( + "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("server_identity_ok")).serverId, + ) + val connections = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("server_connections_ok")) + assertTrue(connections.isAvailable) + // The provider without a URL (plugin not running) is not offered. + assertEquals( + listOf( + PairingEndpoint.of("https://silo.example.test", PairingEndpoint.Kind.Public), + PairingEndpoint.of( + "https://silo.overlay.example.test", + PairingEndpoint.Kind.Provider, + provider = "stub", + displayName = "Stub Overlay", + ), + ), + connections.domain().endpoints, + ) + } + + // --- External sign-in (listAuthProviders, OAuth handshake, identities) --- + + @Test + fun authProvidersFixtureResolvesTheNativeStartAndIcon() { + val providers = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("list_auth_providers_ok").plusUnknown()) + .domain("https://silo.lan:8080") + assertTrue(providers.passwordLogin) + val (local, sso) = providers.providers + assertEquals("local", local.id) + assertEquals(SignInProvider.Mode.Credentials, local.mode) + assertNull(local.installationId) + assertNull(local.nativeStartPath) + assertEquals(SignInProvider.Mode.OAuth, sso.mode) + assertEquals("Example SSO", sso.displayName) + assertEquals("3", sso.installationId) + // Only the path is kept: apps open it on their own saved base, never on the listed origin. + assertEquals("/api/v2/auth/oauth/3/native/start", sso.nativeStartPath) + assertEquals("https://plugins.example.test/icon.svg", sso.iconUrl) + } + + @Test + fun authProviderRelativeIconResolvesAgainstTheListingServer() { + val body = ApiV2Fixtures.json.parseToJsonElement( + """{"items":[{"id":"plugin:3:oidc","display_name":"SSO","mode":"oauth","default":false, + "icon_url":"/api/v2/plugin-content/3/assets/sso.svg","installation_id":"3"}], + "password_login":false}""", + ).jsonObject + val provider = ApiV2Fixtures.decode(body).domain("https://silo.lan:8080/").providers.single() + assertEquals("https://silo.lan:8080/api/v2/plugin-content/3/assets/sso.svg", provider.iconUrl) + assertNull(provider.nativeStartPath, "no native_start_path offers no native sign-in") + } + + /** + * Whatever path prefix the listing names, apps get the start relative to + * the server base, to resolve against their saved base URL. Only + * `native_start_path` enables native sign-in. + */ + @Test + fun authProviderNativeStartIsReducedToTheBaseRelativePath() { + fun path(fields: String) = ApiV2Fixtures.decode( + ApiV2Fixtures.json.parseToJsonElement( + """{"items":[{"id":"plugin:3:oidc","display_name":"SSO","mode":"oauth","installation_id":"3"$fields}]}""", + ).jsonObject, + ).domain("http://192.168.1.10:8096").providers.single().nativeStartPath + val route = "/api/v2/auth/oauth/3/native/start" + assertEquals(route, path(""","native_start_path":"/api/v2/auth/oauth/3/native/start"""")) + assertEquals(route, path(""","native_start_path":"/silo/api/v2/auth/oauth/3/native/start#x"""")) + assertEquals("$route?x=1", path(""","native_start_path":"/api/v2/auth/oauth/3/native/start?x=1"""")) + listOf( + ""","native_start_path":"/api/v2/auth/oauth/3/other"""", + ""","native_start_path":"//evil.example.test/api/v2/auth/oauth/3/native/start"""", + ""","native_start_path":"https://public.example.test/api/v2/auth/oauth/3/native/start"""", + ""","native_start_path":"/?next=/api/v2/auth/oauth/3/native/start"""", + "", + ).forEach { assertNull(path(it), it) } + } + + @Test + fun oauthHandshakeFixtureServesAppsAndLinking() { + val handshake = ApiV2Fixtures.decode( + ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok").plusUnknown(), + ).domain() + assertTrue(handshake.available) + assertTrue(handshake.native) + assertTrue(handshake.linking) + assertTrue(handshake.selectAccount) + + // An older server's body without the newer members: absent means not served. + val older = ApiV2Fixtures.decode( + with(ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok"), "select_account" to null), + ).domain() + assertTrue(older.native) + assertFalse(older.selectAccount, "absent select_account means sign-in starts don't take it") + + val disabled = ApiV2Fixtures.decode( + with(ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok"), "state" to JsonPrimitive("disabled")), + ).domain() + assertFalse(disabled.native) + assertFalse(disabled.linking) + } + + @Test + fun externalSignInCapabilitiesFixtureServesIdentities() { + val capabilities = ApiV2Fixtures.decode( + ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok").plusUnknown(), + ).domain() + assertTrue(capabilities.identities) + // Directory linking needs no OAuth handshake, so this document reports it. + assertTrue(capabilities.credentialsLinking) + val older = ApiV2Fixtures.decode( + with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "credentials_linking" to null), + ).domain() + assertFalse(older.credentialsLinking, "absent credentials_linking means the operation isn't served") + val unsupported = ApiV2Fixtures.decode( + with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "state" to JsonPrimitive("unsupported")), + ).domain() + assertFalse(unsupported.identities) + assertFalse(unsupported.credentialsLinking) + } + + @Test + fun nativeCompletionFixtureCarriesTokensAndUser() { + val login = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("complete_oauth_login_native_ok").plusUnknown()).domain() + assertEquals("acc", login.accessToken) + assertEquals("ref", login.refreshToken) + assertEquals(3600L, login.expiresIn) + assertEquals("laura", login.user.username) + } + + @Test + fun accountIdentitiesAndLinkTicketFixtures() { + val collection = ApiV2Fixtures.decode( + ApiV2Fixtures.bodyObject("list_account_identities_ok").plusUnknown(), + ) + // The only identity and no local password: Disconnect is not offered. + assertEquals(false, collection.canUnlink) + val identity = collection.items.single().domain() + assertEquals("4", identity.id) + assertEquals("3", identity.installationId) + assertEquals("Company SSO", identity.providerName) + assertEquals("alice", identity.accountLabel) + assertEquals("2026-01-03T04:05:06.000Z", identity.lastSignInAt) + + val ticket = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("create_account_identity_link_ticket_ok")) + assertTrue(ticket.ticket.isNotBlank()) + assertFalse(ticket.toString().contains(ticket.ticket), "the ticket never reaches a log line") + } + + @Test + fun externalSignInProblemFixturesMapToTheirCodes() { + fun raw(name: String) = ApiV2Fixtures.bodyObject(name).toString() + val wrongPassword = DefaultExternalSignInApi.locatedError(422, raw("create_account_identity_link_ticket_wrong_password")) + assertEquals(DefaultExternalSignInApi.WRONG_PASSWORD, wrongPassword.error) + assertEquals( + "last_sign_in_method", + DefaultExternalSignInApi.locatedError(409, raw("delete_account_identity_last_sign_in_method")).error, + ) + assertEquals("invalid_grant", ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("complete_oauth_login_invalid_grant")).code) + assertEquals( + "invalid_grant", + ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("complete_account_identity_link_invalid_grant")).code, + ) + assertEquals( + "provider_unavailable", + ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("refresh_session_provider_unavailable")).code, + ) + } + + @Test + fun signInOptionsFromTheProvidersFixture() { + val providers = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("list_auth_providers_ok")) + .domain("https://silo.example.test") + val handshake = ApiV2Fixtures.decode( + ApiV2Fixtures.bodyObject("get_oauth_handshake_capabilities_ok"), + ).domain() + val options = SignInOptions.of(providers, handshake) + assertTrue(options.showPasswordForm) + assertEquals(listOf("Example SSO"), options.oauthProviders.map { it.displayName }) + assertNull(options.directoryProvider) + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2NoFallbackTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2NoFallbackTest.kt index 25c18efc2e..ca1c71fea3 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2NoFallbackTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2NoFallbackTest.kt @@ -20,9 +20,11 @@ import org.siloserver.silo.model.profile.UpdateProfileRequest import org.siloserver.silo.model.server.ServerContract import org.siloserver.silo.model.server.ServerEntry import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.DefaultDeviceLoginApi import org.siloserver.silo.network.api.ProfileApi /** A failed v2 mutation is never replayed against another API major, and the update-server state blocks it outright. */ @@ -102,6 +104,30 @@ class ApiV2NoFallbackTest { assertEquals(emptyList(), recorded) } + @Test + fun aCallScopedToAnotherSavedServerIsGatedOnThatServersVerdict() = runTest { + val cabinCurrent = ServerEntry(id = "cabin", url = "https://cabin.example", contract = ServerContract.V2) + val staleActive = ApiV2Gate(ContractRegistry(ServerContract.UPDATE_REQUIRED, listOf(cabinCurrent))) + assertEquals(null, staleActive.forServer("cabin").blocked(), "the active server's verdict says nothing about cabin") + assertEquals(ApiV2Gate.UPDATE_REQUIRED_ERROR, staleActive.blocked()?.error) + + val cabinOutdated = cabinCurrent.copy(contract = ServerContract.UPDATE_REQUIRED) + val currentActive = ApiV2Gate(ContractRegistry(ServerContract.V2, listOf(cabinOutdated))) + assertEquals(ApiV2Gate.UPDATE_REQUIRED_ERROR, currentActive.forServer("cabin").blocked()?.error) + assertEquals(null, currentActive.blocked()) + + // Through the scoped device-login approval: cabin is reached although + // the active server needs an update. + val recorded = mutableListOf() + val api = DefaultDeviceLoginApi(client(recorded, HttpStatusCode.OK, "{}"), staleActive) + api.lookupDeviceLoginForScope(AuthScopeSnapshot("cabin", null, "https://cabin.example", null), "48217730") + assertEquals(listOf("GET /api/v2/auth/device"), recorded) + val blocked = DefaultDeviceLoginApi(client(recorded, HttpStatusCode.OK, "{}"), currentActive) + .approveDeviceLoginForScope(AuthScopeSnapshot("cabin", null, "https://cabin.example", null), "48217730") + assertEquals(ApiV2Gate.UPDATE_REQUIRED_ERROR, assertIs(blocked).error) + assertEquals(1, recorded.size, "an outdated target is never called") + } + @Test fun unknownAndV2StatesDoNotBlock() { assertEquals(null, ApiV2Gate(ContractRegistry(ServerContract.UNKNOWN)).blocked()) @@ -110,9 +136,13 @@ class ApiV2NoFallbackTest { } } -private class ContractRegistry(contract: ServerContract) : ServerRegistry { +private class ContractRegistry( + contract: ServerContract, + /** Other saved servers, which scoped calls may target without switching. */ + others: List = emptyList(), +) : ServerRegistry { private val entry = ServerEntry(id = "active", url = "https://silo.example", contract = contract) - override val entries: StateFlow> = MutableStateFlow(listOf(entry)) + override val entries: StateFlow> = MutableStateFlow(listOf(entry) + others) override val activeServerId: StateFlow = MutableStateFlow("active") override val activeEntry: StateFlow = MutableStateFlow(entry) override suspend fun addOrUpdate(url: String, fetchedName: String?): String = "active" diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/pairing/PairingProtocolGoldenFramesTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/pairing/PairingProtocolGoldenFramesTest.kt new file mode 100644 index 0000000000..672bcd6407 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/pairing/PairingProtocolGoldenFramesTest.kt @@ -0,0 +1,148 @@ +package org.siloserver.silo.pairing + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * Literal frames shared with silo-apple (`iosApp/Tests/PairingProtocolTests.swift`, + * `testLiteralWireFramesDecodeAndEncodeExactly`). A round trip passes even when + * a key is renamed on both sides of one codec, which would strand the other + * platform's phones and TVs; these literals fail instead. Frames compare as + * JSON objects because the two encoders order keys differently. + * + * Keep the two lists identical. When a frame changes here, change it in + * silo-apple in the same release. + */ +class PairingProtocolGoldenFramesTest { + private val url = "https://media.example.com" + + /** Copied verbatim from silo-apple. */ + private val appleFrames: List> = listOf( + """{"type":"hello","v":1,"tvName":"Living Room","tvDeviceId":"ABC-123","state":"setup","supportedVersions":[1]}""" to + PairingMessage.Hello("Living Room", "ABC-123", PairingReceiverState.Setup, listOf(1)), + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home"}""" to + PairingMessage.PushServer(serverURL = url, serverName = "Home"), + """{"type":"deviceStarted","v":1,"serverURL":"https://media.example.com","userCode":"WXYZ-12","matchCode":"brave-otter"}""" to + PairingMessage.DeviceStarted(serverURL = url, userCode = "WXYZ-12", matchCode = "brave-otter"), + """{"type":"serverResult","v":1,"serverURL":"https://media.example.com","status":"signedIn"}""" to + PairingMessage.ServerResult(serverURL = url, status = PairingServerStatus.SignedIn, error = null), + """{"type":"serverResult","v":1,"serverURL":"https://media.example.com","status":"failed","error":"unreachable"}""" to + PairingMessage.ServerResult( + serverURL = url, + status = PairingServerStatus.Failed, + error = PairingFailureCode.Unreachable.wire, + ), + """{"type":"done","v":1}""" to PairingMessage.Done, + """{"type":"cancel","v":1,"reason":"user_declined"}""" to PairingMessage.Cancel(reason = "user_declined"), + ) + + /** + * Frames Android adds for the Phase 1 sign-in work. The pushServer frame + * is the shape silo-apple's encoder writes for `pushServer(serverIdentity: + * endpoints:)`; the hello frame is a signed-out TV (`st=login`). Send these + * to silo-apple for the same literal check. + */ + private val androidFrames: List> = listOf( + """{"type":"hello","v":1,"tvName":"Den","tvDeviceId":"id-9","state":"login","supportedVersions":[1]}""" to + PairingMessage.Hello("Den", "id-9", PairingReceiverState.Login, listOf(1)), + ( + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home",""" + + """"serverIdentity":"96c1bd08-b839-4d47-980e-57d4e7a44cfa","endpoints":[""" + + """{"url":"https://media.example.com","kind":"public"},""" + + """{"url":"https://media.overlay.example","kind":"provider","provider":"tailscale","displayName":"Tailscale"}]}""" + ) to PairingMessage.PushServer( + serverURL = url, + serverName = "Home", + serverIdentity = "96c1bd08-b839-4d47-980e-57d4e7a44cfa", + endpoints = listOf( + PairingEndpoint.of(url, PairingEndpoint.Kind.Public), + PairingEndpoint.of( + "https://media.overlay.example", + PairingEndpoint.Kind.Provider, + provider = "tailscale", + displayName = "Tailscale", + ), + ), + ), + """{"type":"serverResult","v":1,"serverURL":"https://media.example.com","status":"failed","error":"identity_mismatch"}""" to + PairingMessage.ServerResult( + serverURL = url, + status = PairingServerStatus.Failed, + error = PairingFailureCode.IdentityMismatch.wire, + ), + ) + + @Test + fun literalFramesDecodeAndEncodeExactly() { + for ((frame, message) in appleFrames + androidFrames) { + assertEquals(message, PairingMessageCodec.decode(frame), frame) + assertEquals(json(frame), json(PairingMessageCodec.encode(message)), frame) + } + } + + @Test + fun pushServerIdentityFieldsAreOptionalOnTheWire() { + val legacy = PairingMessageCodec.decode( + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":"Home"}""", + ) as PairingMessage.PushServer + assertNull(legacy.serverIdentity) + assertNull(legacy.endpoints) + + val encoded = json(PairingMessageCodec.encode(PairingMessage.PushServer(url, null))) + assertNull(encoded["serverIdentity"]) + assertNull(encoded["endpoints"]) + + // Endpoint URLs cross the wire without a trailing slash, as silo-apple normalizes them. + val full = json( + PairingMessageCodec.encode( + PairingMessage.PushServer( + serverURL = url, + serverName = "Home", + serverIdentity = "S", + endpoints = listOf( + PairingEndpoint.of( + "https://media.overlay.example/", + PairingEndpoint.Kind.Provider, + provider = "tailscale", + displayName = "Tailscale", + ), + ), + ), + ), + ) + val endpoint = (full["endpoints"] as kotlinx.serialization.json.JsonArray)[0].jsonObject + assertEquals("\"https://media.overlay.example\"", endpoint["url"].toString()) + assertEquals("\"provider\"", endpoint["kind"].toString()) + } + + @Test + fun explicitNullOptionalFieldsDecodeAsAbsent() { + val push = PairingMessageCodec.decode( + """{"type":"pushServer","v":1,"serverURL":"https://media.example.com","serverName":null,"serverIdentity":null}""", + ) as PairingMessage.PushServer + assertNull(push.serverName) + assertNull(push.serverIdentity) + } + + @Test + fun failureCodesReadUnknownAsGenericFailure() { + assertEquals(PairingFailureCode.Unreachable, PairingFailureCode.fromWire("unreachable")) + assertEquals(PairingFailureCode.IdentityMismatch, PairingFailureCode.fromWire("identity_mismatch")) + assertEquals(PairingFailureCode.AuthFailed, PairingFailureCode.fromWire(null)) + assertEquals(PairingFailureCode.AuthFailed, PairingFailureCode.fromWire("something_new")) + // Older Android TVs sent human text here; it reads as the generic failure. + assertEquals(PairingFailureCode.AuthFailed, PairingFailureCode.fromWire("Sign-in was denied on the other device.")) + } + + @Test + fun unknownTypeFailsToDecode() { + assertFailsWith { PairingMessageCodec.decode("""{"type":"bogus","v":1}""") } + } + + private fun json(text: String): JsonObject = Json.parseToJsonElement(text).jsonObject +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceLoginRepositoryTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceLoginRepositoryTest.kt index 85b296c856..9c95cb01c4 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceLoginRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceLoginRepositoryTest.kt @@ -98,7 +98,7 @@ class DeviceLoginRepositoryTest { } @Test - fun `start failure transitions to Failed(StartFailed)`() = runTest(UnconfinedTestDispatcher()) { + fun `start 5xx transitions to Failed(Unreachable) without raw server text`() = runTest(UnconfinedTestDispatcher()) { val api = object : DeviceLoginApi { override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = ApiResult.Error(code = 500, error = "", message = "boom") @@ -115,7 +115,8 @@ class DeviceLoginRepositoryTest { repo.begin("d", "p") val state = repo.state.value assertIs(state) - assertEquals(DeviceLoginRepository.FailureReason.StartFailed, state.reason) + assertEquals(DeviceLoginRepository.FailureReason.Unreachable, state.reason) + assertEquals(null, state.message) } @Test @@ -236,7 +237,7 @@ class DeviceLoginRepositoryTest { } @Test - fun `network error on start transitions to Failed(StartFailed)`() = runTest(UnconfinedTestDispatcher()) { + fun `network error on start transitions to Failed(Unreachable)`() = runTest(UnconfinedTestDispatcher()) { val api = object : DeviceLoginApi { override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = ApiResult.NetworkError(exception = RuntimeException("offline")) @@ -252,6 +253,97 @@ class DeviceLoginRepositoryTest { repo.begin("d", "p") val state = repo.state.value assertIs(state) - assertEquals(DeviceLoginRepository.FailureReason.StartFailed, state.reason) + assertEquals(DeviceLoginRepository.FailureReason.Unreachable, state.reason) + } + + @Test + fun `persistent network errors stop at the local deadline with backoff`() = runTest { + val pollTimes = mutableListOf() + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session().copy(expiresIn = 120, interval = 1)) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult { + pollTimes += testScheduler.currentTime + return ApiResult.NetworkError(RuntimeException("offline")) + } + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api, clock = DeviceLoginClock { testScheduler.currentTime }) + repo.begin("d", "p") + val state = repo.state.value + assertIs(state) + assertEquals(DeviceLoginRepository.FailureReason.Expired, state.reason) + assertTrue(testScheduler.currentTime <= 120_000L, "must not poll past the local deadline") + val gaps = pollTimes.zipWithNext { a, b -> b - a } + assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 30_000L), gaps.take(6)) + } + + @Test + fun `canceled status ends the attempt`() = runTest(UnconfinedTestDispatcher()) { + var polls = 0 + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session()) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult { + polls++ + return ApiResult.Success( + if (polls == 1) DeviceLoginPollResponse(status = "pending", opened = true) + else DeviceLoginPollResponse(status = "canceled"), + ) + } + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api) + repo.begin("d", "p") + val state = repo.state.value + assertIs(state) + assertEquals(DeviceLoginRepository.FailureReason.Canceled, state.reason) + } + + @Test + fun `a pending answer's expires_at keeps the attempt alive past the start answer's expiry`() = runTest { + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session().copy(expiresIn = 60, expiresAt = "2026-01-01T00:01:00Z", interval = 5)) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult = + ApiResult.Success( + when { + testScheduler.currentTime >= 200_000L -> + DeviceLoginPollResponse(status = "approved", accessToken = "at", refreshToken = "rt") + // An approver's lookup extended the request to 00:05:00. + else -> DeviceLoginPollResponse(status = "pending", opened = true, expiresAt = "2026-01-01T00:05:00Z") + }, + ) + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api, clock = DeviceLoginClock { testScheduler.currentTime }) + repo.begin("d", "p") + assertIs(repo.state.value) + } + + @Test + fun `the attempt polls once more at its deadline before giving up`() = runTest { + val pollTimes = mutableListOf() + val api = object : DeviceLoginApi { + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = + ApiResult.Success(session().copy(expiresIn = 12, interval = 5)) + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult { + pollTimes += testScheduler.currentTime + return ApiResult.Success(DeviceLoginPollResponse(status = "pending")) + } + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unreachable") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unreachable") + } + val repo = DeviceLoginRepository(api, clock = DeviceLoginClock { testScheduler.currentTime }) + repo.begin("d", "p") + assertEquals(listOf(0L, 5_000L, 10_000L, 12_000L), pollTimes) + assertEquals(DeviceLoginRepository.FailureReason.Expired, (repo.state.value as DeviceLoginRepository.DeviceLoginState.Failed).reason) } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceSignInMachineTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceSignInMachineTest.kt new file mode 100644 index 0000000000..c214bfc74f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DeviceSignInMachineTest.kt @@ -0,0 +1,471 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.auth.DeviceLoginCancelResponse +import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse +import org.siloserver.silo.model.auth.DeviceLoginDecisionResponse +import org.siloserver.silo.model.auth.DeviceLoginLookupResponse +import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.api.DeviceLoginApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class DeviceSignInMachineTest { + + private class ScriptedApi( + var capability: ApiResult = ApiResult.Success( + DeviceLoginCapabilityResponse(cancel = true, openedSignal = true), + ), + var start: (Int) -> ApiResult = { n -> ApiResult.Success(session("dev-$n")) }, + var poll: (String) -> ApiResult = { ApiResult.Success(DeviceLoginPollResponse("pending")) }, + /** How long each poll call takes, so a test can stop the machine mid-call. */ + var pollDurationMs: Long = 0L, + /** How long each cancel call takes; a cancel is recorded once it has completed. */ + var cancelDurationMs: Long = 0L, + ) : DeviceLoginApi { + var starts = 0 + var polls = 0 + val canceled = mutableListOf() + + override suspend fun deviceLoginCapabilityAt(serverUrl: String) = capability + override suspend fun startDeviceLoginAt(serverUrl: String, deviceName: String?, devicePlatform: String?) = + start(++starts) + override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult { + polls++ + if (pollDurationMs > 0) delay(pollDurationMs) + return poll(deviceCode) + } + override suspend fun cancelDeviceLoginAt(serverUrl: String, deviceCode: String): ApiResult { + if (cancelDurationMs > 0) delay(cancelDurationMs) + canceled += deviceCode + return ApiResult.Success(DeviceLoginCancelResponse("canceled")) + } + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unscoped") + override suspend fun pollDeviceLogin(deviceCode: String) = error("unscoped") + override suspend fun lookupDeviceLogin(token: String?, code: String?): ApiResult = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?): ApiResult = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?): ApiResult = error("unused") + } + + private fun TestScope.machine(api: ScriptedApi, config: DeviceSignInConfig = DeviceSignInConfig()) = + DeviceSignInMachine( + repository = DeviceLoginRepository(api), + serverUrl = "https://silo.test", + deviceName = "Shield", + devicePlatform = "android-tv", + clock = DeviceLoginClock { testScheduler.currentTime }, + config = config, + ) + + @Test + fun showsCodeThenApproves() = runTest { + var polls = 0 + val api = ScriptedApi(poll = { + polls++ + ApiResult.Success( + if (polls < 3) DeviceLoginPollResponse("pending") + else DeviceLoginPollResponse("approved", accessToken = "at", refreshToken = "rt"), + ) + }) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + val showing = assertIs(m.state.value) + assertEquals("4821-7730", showing.session.userCode) + advanceUntilIdle() + assertIs(m.state.value) + assertTrue(job.isCompleted) + } + + @Test + fun expiredCodeIsRenewedInPlace() = runTest { + val api = ScriptedApi(poll = { code -> + ApiResult.Success(DeviceLoginPollResponse(if (code == "dev-1") "expired" else "pending")) + }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(10_000) + val showing = assertIs(m.state.value) + assertEquals("dev-2", showing.session.deviceCode) + assertTrue(showing.renewed) + job.cancel() + } + + @Test + fun localDeadlinePollsOnceMoreThenWithdrawsAndRenews() = runTest { + val pollTimes = mutableListOf() + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 62)) }, + poll = { pollTimes += testScheduler.currentTime; ApiResult.Success(DeviceLoginPollResponse("pending")) }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(62_001) + // The wait before the deadline is clamped to it, and the code is only + // replaced after that last poll still found it pending. + assertTrue(62_000L in pollTimes, "polls at the deadline before replacing: $pollTimes") + assertEquals("dev-2", assertIs(m.state.value).session.deviceCode) + assertEquals(listOf("dev-1"), api.canceled, "the replaced code is withdrawn on the server") + job.cancel() + } + + @Test + fun aPendingPollsExpiresAtMovesTheDeadline() = runTest { + // The start answer says the code lives 60 s (server clock 00:01:00); + // a lookup in the last poll interval extends it to 00:05:00. + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 60, expiresAt = "2026-01-01T00:01:00Z")) }, + poll = { + val extended = testScheduler.currentTime >= 60_000L + ApiResult.Success( + DeviceLoginPollResponse( + "pending", + opened = extended, + expiresAt = if (extended) "2026-01-01T00:05:00Z" else "2026-01-01T00:01:00Z", + ), + ) + }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(4 * 60_000L) + val showing = assertIs(m.state.value) + assertEquals("dev-1", showing.session.deviceCode, "the code someone is approving stays") + assertTrue(showing.opened) + assertEquals(emptyList(), api.canceled) + + advanceTimeBy(60_001) + assertEquals("dev-2", assertIs(m.state.value).session.deviceCode) + assertEquals(listOf("dev-1"), api.canceled) + job.cancel() + } + + @Test + fun aReplacedLiveCodeIsWithdrawnWhileCancelSupportIsUnknown() = runTest { + // The capability read keeps failing: the same rule as abandon applies, + // so the code the TV moved past is still withdrawn. + val api = ScriptedApi( + capability = ApiResult.NetworkError(RuntimeException("offline")), + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(10_001) + assertEquals("dev-2", assertIs(m.state.value).session.deviceCode) + assertEquals(listOf("dev-1"), api.canceled) + job.cancel() + } + + @Test + fun aReplacedLiveCodeIsNotWithdrawnWhenTheServerHasNoCancel() = runTest { + val api = ScriptedApi( + capability = ApiResult.Success(DeviceLoginCapabilityResponse(cancel = false)), + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + ) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(10_001) + assertEquals("dev-2", assertIs(m.state.value).session.deviceCode) + assertEquals(emptyList(), api.canceled) + job.cancel() + } + + @Test + fun aFailedCapabilityReadIsRetriedWhenTheCodeIsReplaced() = runTest { + val api = ScriptedApi( + capability = ApiResult.NetworkError(RuntimeException("offline")), + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + ) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + api.capability = ApiResult.Success(DeviceLoginCapabilityResponse(cancel = true)) + advanceTimeBy(10_001) + assertEquals(listOf("dev-1"), api.canceled) + job.cancel() + } + + @Test + fun pausesAfterAnHourOfRenewals() = runTest { + val api = ScriptedApi(start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 900)) }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(61 * 60_000L) + assertEquals(DeviceSignInState.Paused, m.state.value) + assertTrue(job.isCompleted) + assertEquals(4, api.starts, "15-minute codes renew three times in an hour") + + m.restart() + val again = launch { m.run() } + runCurrent() + assertIs(m.state.value) + again.cancel() + } + + @Test + fun networkFailuresBackOffAndReportUnreachableThenRecover() = runTest { + var offline = true + val pollTimes = mutableListOf() + val api = ScriptedApi(poll = { + pollTimes += testScheduler.currentTime + if (offline) ApiResult.NetworkError(RuntimeException("offline")) else ApiResult.Success(DeviceLoginPollResponse("pending")) + }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(29_000) + assertIs(m.state.value) + advanceTimeBy(40_000) + val unreachable = assertIs(m.state.value) + assertEquals("dev-1", unreachable.session?.deviceCode) + val gaps = pollTimes.zipWithNext { a, b -> b - a } + assertEquals(listOf(5_000L, 10_000L, 20_000L, 30_000L), gaps.take(4)) + + offline = false + advanceTimeBy(31_000) + assertIs(m.state.value) + job.cancel() + } + + @Test + fun startFailuresReportUnreachableAfterTenSeconds() = runTest { + val api = ScriptedApi(start = { ApiResult.Error(503, "", "") }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(5_000) + assertEquals(DeviceSignInState.GettingCode, m.state.value) + advanceTimeBy(11_000) + assertEquals(DeviceSignInState.Unreachable(null), m.state.value) + job.cancel() + } + + @Test + fun persistentRateLimitIsReported() = runTest { + val api = ScriptedApi(poll = { ApiResult.Error(429, "rate_limited", "") }) + val m = machine(api) + val job = launch { m.run() } + advanceTimeBy(59_000) + assertIs(m.state.value) + advanceTimeBy(40_000) + assertIs(m.state.value) + job.cancel() + } + + @Test + fun openedIsReportedWithoutMovingTheDeadlineOnItsOwn() = runTest { + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 60)) }, + poll = { ApiResult.Success(DeviceLoginPollResponse("pending", opened = true)) }, + ) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + assertTrue(assertIs(m.state.value).opened) + // Only the server's expires_at extends a code; opened alone doesn't. + advanceTimeBy(61_000) + assertEquals("dev-2", assertIs(m.state.value).session.deviceCode) + job.cancel() + } + + @Test + fun deniedAndMissingTokensSettle() = runTest { + val denied = machine(ScriptedApi(poll = { ApiResult.Success(DeviceLoginPollResponse("denied")) })) + denied.run() + assertEquals(DeviceSignInState.Denied, denied.state.value) + + val missing = machine(ScriptedApi(poll = { ApiResult.Success(DeviceLoginPollResponse("approved")) })) + missing.run() + assertEquals(DeviceSignInState.Failed, missing.state.value) + } + + @Test + fun serverWithoutDeviceSignInFallsBackToPassword() = runTest { + val unconfigured = machine( + ScriptedApi(capability = ApiResult.Success(DeviceLoginCapabilityResponse(deviceLoginAvailable = false))), + ) + unconfigured.run() + assertEquals(DeviceSignInState.NoDeviceSignIn, unconfigured.state.value) + + val noRoute = machine( + ScriptedApi( + capability = ApiResult.Error(404, "", ""), + start = { ApiResult.Error(404, "not_found", "") }, + ), + ) + noRoute.run() + assertEquals(DeviceSignInState.NoDeviceSignIn, noRoute.state.value) + + val retired = machine(ScriptedApi(start = { ApiResult.Error(410, "gone", "") })) + retired.run() + assertEquals(DeviceSignInState.UpdateRequired, retired.state.value) + } + + @Test + fun stoppingKeepsTheCodeAndResumingPollsItImmediately() = runTest { + val api = ScriptedApi() + val m = machine(api) + val first = launch { m.run() } + advanceTimeBy(12_000) + first.cancel() + val pollsWhenStopped = api.polls + advanceTimeBy(60_000) + assertEquals(pollsWhenStopped, api.polls, "no polling while stopped") + + val resumed = launch { m.run() } + runCurrent() + assertEquals(pollsWhenStopped + 1, api.polls, "polls at once on resume") + assertEquals(1, api.starts, "a live code is kept") + resumed.cancel() + } + + @Test + fun requestStopLetsAnInFlightPollFinishSoItsTokensAreKept() = runTest { + val api = ScriptedApi( + poll = { ApiResult.Success(DeviceLoginPollResponse("approved", accessToken = "at", refreshToken = "rt")) }, + pollDurationMs = 1_000L, + ) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + assertEquals(1, api.polls, "the poll is in flight") + m.requestStop() + advanceUntilIdle() + assertIs(m.state.value) + assertTrue(job.isCompleted) + } + + @Test + fun requestStopEndsTheWaitBetweenPolls() = runTest { + val api = ScriptedApi() + val m = machine(api) + val job = launch { m.run() } + runCurrent() + m.requestStop() + runCurrent() + assertTrue(job.isCompleted, "stops without waiting out the interval") + assertEquals(1, api.polls) + advanceTimeBy(60_000) + assertEquals(1, api.polls) + + val resumed = launch { m.run() } + runCurrent() + assertEquals(2, api.polls, "polls at once on resume") + assertEquals(1, api.starts) + resumed.cancel() + } + + @Test + fun abandonCancelsOnlyWhenTheServerSupportsIt() = runTest { + val api = ScriptedApi() + val m = machine(api) + val job = launch { m.run() } + runCurrent() + job.cancel() + m.abandon(this) + advanceUntilIdle() + assertEquals(listOf("dev-1"), api.canceled) + + val old = ScriptedApi(capability = ApiResult.Success(DeviceLoginCapabilityResponse(cancel = false))) + val m2 = machine(old) + val job2 = launch { m2.run() } + runCurrent() + job2.cancel() + m2.abandon(this) + advanceUntilIdle() + assertEquals(emptyList(), old.canceled) + } + + @Test + fun abandonWithdrawsTheCodeWhenTheCapabilityCouldNotBeRead() = runTest { + val api = ScriptedApi(capability = ApiResult.NetworkError(RuntimeException("offline"))) + val m = machine(api) + val job = launch { m.run() } + runCurrent() + assertIs(m.state.value) + job.cancel() + m.abandon(this) + advanceUntilIdle() + assertEquals(listOf("dev-1"), api.canceled, "unknown support still gets the cancel; an old server answers 404") + } + + @Test + fun cancellingTheRunDuringARenewalsWithdrawStillWithdrawsTheCode() = runTest { + val api = ScriptedApi( + start = { n -> ApiResult.Success(session("dev-$n", expiresIn = 10)) }, + cancelDurationMs = 1_000L, + ) + val m = machine(api) + val job = launch { m.run() } + // The poll at the deadline finds dev-1 pending: the withdraw starts. + advanceTimeBy(10_500) + assertEquals(emptyList(), api.canceled, "the withdraw is in flight") + job.cancel() + m.abandon(this) + advanceUntilIdle() + assertTrue("dev-1" in api.canceled, "the withdraw in flight survives cancelling the run: ${api.canceled}") + assertEquals(1, api.starts, "no code after the one withdrawn") + } + + @Test + fun aRunSupersededDuringASlowPollDropsItsLateAnswer() = runTest { + for (late in listOf("pending", "expired")) { + val api = ScriptedApi( + poll = { code -> ApiResult.Success(DeviceLoginPollResponse(if (code == "dev-1") late else "pending")) }, + // Answers at 12 s, between the new code's polls at 10 s and 15 s. + pollDurationMs = 12_000L, + ) + val m = machine(api) + // Never cancelled: it stands for a run left blocked in its call. + val stale = launch { m.run() } + runCurrent() + assertEquals(1, api.polls, "dev-1's poll is in flight") + + m.requestStop() + m.abandon(this) + m.restart() + api.pollDurationMs = 0L + val fresh = launch { m.run() } + runCurrent() + assertEquals("dev-2", assertIs(m.state.value).session.deviceCode) + + advanceTimeBy(12_001) + assertTrue(stale.isCompleted, "the superseded run returned ($late)") + assertEquals( + "dev-2", + assertIs(m.state.value).session.deviceCode, + "the withdrawn code never comes back ($late)", + ) + advanceTimeBy(10_000) + assertEquals("dev-2", assertIs(m.state.value).session.deviceCode) + assertEquals(2, api.starts, "the new code is kept, not replaced ($late)") + assertEquals(listOf("dev-1"), api.canceled) + fresh.cancel() + } + } + + private companion object { + fun session(deviceCode: String, expiresIn: Int = 900, expiresAt: String = "2099-01-01T00:00:00Z") = DeviceLoginStartResponse( + deviceCode = deviceCode, + userCode = "4821-7730", + matchCode = "warm pony", + verificationUri = "https://silo.test/activate", + verificationUriComplete = "https://silo.test/activate?code=48217730", + expiresAt = expiresAt, + expiresIn = expiresIn, + interval = 5, + deviceName = "Shield", + devicePlatform = "android-tv", + ) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingServerChoiceTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingServerChoiceTest.kt new file mode 100644 index 0000000000..51224a39a2 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingServerChoiceTest.kt @@ -0,0 +1,354 @@ +package org.siloserver.silo.viewmodel + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.siloserver.silo.model.auth.DeviceLoginDecisionResponse +import org.siloserver.silo.model.auth.DeviceLoginLookupResponse +import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.api.DeviceLoginApi +import org.siloserver.silo.repository.DeviceLoginRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * "Sign in a TV": a typed code is looked up on ONE chosen server (the active + * one preselected), never on every saved server; a miss says so and offers + * the others. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DevicePairingServerChoiceTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private val home = DeviceApprovalServer("a", "https://home.example", "Home", isActive = true) + private val cabin = DeviceApprovalServer("b", "https://cabin.example", "Cabin") + + private class ScopedApi(private val codesOn: Map) : DeviceLoginApi { + val lookedUpOn = mutableListOf() + val approvedOn = mutableListOf>() + var unscopedLookups = 0 + + /** What a lookup answers after this phone approved; "pending" until then. */ + var statusAfterApproval = "pending" + private var approved = false + + var onLookup: (String) -> Unit = {} + + /** Servers whose session is dead: approving there answers 401. */ + var signedOutOn: Set = emptySet() + + /** Servers whose provider can't re-check the approver's session. */ + var providerDownOn: Set = emptySet() + + override suspend fun lookupDeviceLoginForScope(scope: AuthScopeSnapshot, code: String): ApiResult { + lookedUpOn += scope.serverId + onLookup(scope.serverId) + return if (codesOn[code] == scope.serverId) { + ApiResult.Success( + DeviceLoginLookupResponse( + status = if (approved) statusAfterApproval else "pending", + userCode = "4821-7730", + deviceName = "Living Room TV", + devicePlatform = "android-tv", + serverName = "Home", + ), + ) + } else { + ApiResult.Error(404, "not_found", "Device login request not found") + } + } + + override suspend fun approveDeviceLoginForScope(scope: AuthScopeSnapshot, code: String): ApiResult { + if (scope.serverId in signedOutOn) return ApiResult.Error(401, "unauthorized", "") + if (scope.serverId in providerDownOn) { + return ApiResult.NetworkError( + org.siloserver.silo.network.SiloAuthUnavailableException( + org.siloserver.silo.network.SiloAuthUnavailableException.PROVIDER_UNAVAILABLE, + ), + ) + } + approvedOn += scope.serverId to code + approved = true + return ApiResult.Success(DeviceLoginDecisionResponse("approved")) + } + + override suspend fun lookupDeviceLogin(token: String?, code: String?): ApiResult { + unscopedLookups++ + return ApiResult.Error(404, "not_found", "Device login request not found") + } + override suspend fun approveDeviceLogin(token: String?, code: String?): ApiResult = error("unscoped") + override suspend fun denyDeviceLogin(token: String?, code: String?): ApiResult = error("unscoped") + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?): ApiResult = error("unused") + override suspend fun pollDeviceLogin(deviceCode: String): ApiResult = error("unused") + } + + /** Every account read, in order, with the lookups: the approver's refresh point. */ + private val events = mutableListOf() + + /** The account each server's own scope reads; a missing entry is a failed read. */ + private val accounts = mutableMapOf("a" to "laura") + + /** Servers whose provider can't re-check the session the account read renews. */ + private val providerDownFor = mutableSetOf() + + private fun servers(vararg list: DeviceApprovalServer) = object : DeviceApprovalServers { + override suspend fun list() = list.toList() + override fun scope(server: DeviceApprovalServer) = AuthScopeSnapshot(server.id, null, server.url, null) + override suspend fun accountName(server: DeviceApprovalServer): String? { + events += "account:${server.id}" + if (server.id in providerDownFor) { + throw org.siloserver.silo.network.SiloAuthUnavailableException( + org.siloserver.silo.network.SiloAuthUnavailableException.PROVIDER_UNAVAILABLE, + ) + } + return accounts[server.id] + } + } + + @Test + fun activeServerIsPreselectedAndTheCodeIsLookedUpThereOnly() = runTest { + val api = ScopedApi(mapOf("48217730" to "a")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(cabin, home)) + assertEquals("a", vm.uiState.value.selectedServerId) + assertEquals("laura", vm.uiState.value.accountName) + + vm.onCodeChanged("4821 7730") + assertEquals("48217730", vm.uiState.value.code) + vm.lookup() + assertEquals(listOf("a"), api.lookedUpOn) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + + vm.approve() + assertEquals(listOf("a" to "48217730"), api.approvedOn) + assertEquals("approved", vm.uiState.value.completedStatus) + } + + /** Older servers issue letter codes; the phone's code field must still take them as typed. */ + @Test + fun aLetterCodeFromAnOlderServerIsTypedAndLookedUp() = runTest { + val api = ScopedApi(mapOf("ABCDEFGH" to "a")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(cabin, home)) + vm.onCodeChanged("abcd-efgh") + assertEquals("ABCDEFGH", vm.uiState.value.code) + vm.lookup() + assertEquals(listOf("a"), api.lookedUpOn) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + } + + @Test + fun aMissSaysSoAndOffersTheOtherServers() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home, cabin)) + vm.onCodeChanged("48217730") + vm.lookup() + assertEquals(listOf("a"), api.lookedUpOn, "never looked up on every server") + assertTrue(vm.uiState.value.notFound) + assertEquals(DevicePairingError.NotFoundOn("Home"), vm.uiState.value.error) + assertEquals(listOf(cabin), vm.uiState.value.otherServers) + + vm.selectServer("b") + assertEquals(listOf("a", "b"), api.lookedUpOn) + assertNull(vm.uiState.value.error) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + assertNull(vm.uiState.value.accountName) + } + + @Test + fun aLinkPreselectsItsServer() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, "4821-7730", servers(home, cabin), initialServerId = "b") + assertEquals("b", vm.uiState.value.selectedServerId) + assertEquals(listOf("b"), api.lookedUpOn) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + } + + @Test + fun aTokenLinkUsesTheActiveServerWithoutAChooserOrOtherServers() = runTest { + val api = ScopedApi(emptyMap()) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), "tok", null, servers(cabin, home), initialServerId = "b") + assertEquals("a", vm.uiState.value.selectedServerId, "the card names the server the token goes to") + assertEquals(false, vm.uiState.value.canChooseServer) + assertEquals(1, api.unscopedLookups) + assertEquals(DevicePairingError.NotFound, vm.uiState.value.error) + assertEquals(false, vm.uiState.value.notFound, "no 'Try ' for a token") + + vm.selectServer("b") + assertEquals("a", vm.uiState.value.selectedServerId) + assertEquals(1, api.unscopedLookups) + } + + @Test + fun afterApprovingTheResultFollowsHowTheRequestEnded() = runTest(StandardTestDispatcher()) { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + for ((status, outcome) in listOf( + "consumed" to DeviceFollowOutcome.SignedIn, + "canceled" to DeviceFollowOutcome.Canceled, + "expired" to DeviceFollowOutcome.Expired, + "denied" to DeviceFollowOutcome.Denied, + "approved" to DeviceFollowOutcome.TimedOut, + )) { + val api = ScopedApi(mapOf("48217730" to "a")).apply { statusAfterApproval = status } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, "48217730", servers(home)) + advanceUntilIdle() + vm.approve() + runCurrent() + assertNull(vm.uiState.value.followOutcome, "signing in until the TV says otherwise") + advanceUntilIdle() + assertEquals(outcome, vm.uiState.value.followOutcome, status) + } + } + + @Test + fun aLinkNamingAnotherSavedServerApprovesThereOnly() = runTest { + accounts["b"] = "laura.cabin" + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home, cabin), + initialServerId = "b", lockServer = true, + ) + assertEquals("b", vm.uiState.value.selectedServerId) + assertEquals(false, vm.uiState.value.canChooseServer, "the link named the server") + assertEquals(listOf("b"), api.lookedUpOn) + + vm.approve() + assertEquals(listOf("b" to "48217730"), api.approvedOn, "approved with that server's own scope") + assertEquals(0, api.unscopedLookups) + } + + @Test + fun aLockedServerThisPhoneIsntSignedInToAsksToSignInRatherThanTryingAnother() = runTest { + val api = ScopedApi(mapOf("48217730" to "a")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home), + initialServerId = "b", lockServer = true, + ) + assertEquals(DevicePairingError.SignInFirst, vm.uiState.value.error) + assertNull(vm.uiState.value.selectedServerId) + assertEquals(emptyList(), api.lookedUpOn, "never looked up on the active server instead") + assertEquals(0, api.unscopedLookups) + + vm.lookup() + vm.approve() + assertEquals(emptyList(), api.lookedUpOn) + assertEquals(emptyList(), api.approvedOn) + } + + @Test + fun theAccountIsReadBeforeEveryLookup() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + api.onLookup = { events += "lookup:$it" } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home, cabin)) + events.clear() + vm.onCodeChanged("48217730") + vm.lookup() + vm.selectServer("b") + assertEquals(listOf("account:a", "lookup:a", "account:b", "lookup:b"), events) + } + + @Test + fun anUnreadableAccountOnAnotherServerHoldsApprovalUntilARetryReadsIt() = runTest { + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home, cabin), + initialServerId = "b", lockServer = true, + ) + assertEquals("Living Room TV", vm.uiState.value.lookup?.deviceName) + assertNull(vm.uiState.value.accountName) + assertTrue(vm.uiState.value.accountUnconfirmed, "the card says it couldn't confirm the account") + assertEquals(false, vm.uiState.value.canApprove) + assertEquals(true, vm.uiState.value.canDecide, "declining is still possible") + vm.approve() + assertEquals(emptyList(), api.approvedOn, "never approved for an account nobody saw") + + accounts["b"] = "laura.cabin" + vm.retryAccount() + assertEquals("laura.cabin", vm.uiState.value.accountName) + assertEquals(false, vm.uiState.value.accountUnconfirmed) + vm.approve() + assertEquals(listOf("b" to "48217730"), api.approvedOn) + } + + @Test + fun theActiveServersAccountNeedNotBeReadToApprove() = runTest { + accounts.clear() + val api = ScopedApi(mapOf("48217730" to "a")) + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, "48217730", servers(home, cabin)) + assertEquals(false, vm.uiState.value.accountUnconfirmed) + assertEquals(true, vm.uiState.value.canApprove) + } + + @Test + fun aSignInFirstOnTheChosenServerKeepsThatServerAndTheTypedCode() = runTest { + accounts["b"] = "laura.cabin" + val api = ScopedApi(mapOf("48217730" to "b")).apply { signedOutOn = setOf("b") } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home, cabin)) + vm.selectServer("b") + vm.onCodeChanged("4821 7730") + vm.lookup() + vm.approve() + val state = vm.uiState.value + assertEquals(DevicePairingError.SignInFirst, state.error) + // What "Sign in" carries to the sign-in flow: that server, not the active one. + assertEquals("b", state.selectedServerId) + assertEquals("48217730", state.code) + } + + /** + * The sign-in provider couldn't re-check this phone's session when it + * renewed its bearer to approve: the session is kept and the person is + * told to try again, not to sign in again. + */ + @Test + fun aProviderOutageKeepsTheSessionAndSaysSo() = runTest { + val api = ScopedApi(mapOf("48217730" to "a")).apply { providerDownOn = setOf("a") } + val vm = DevicePairingViewModel(DeviceLoginRepository(api), null, null, servers(home)) + vm.onCodeChanged("4821 7730") + vm.lookup() + vm.approve() + assertEquals(DevicePairingError.ProviderUnavailable, vm.uiState.value.error) + assertEquals(emptyList(), api.approvedOn) + } + + /** + * The account read on another saved server renews its bearer first, and + * the provider couldn't re-check that session: the card says so rather + * than "couldn't confirm the account", and approving waits for a retry. + */ + @Test + fun aProviderOutageOnTheAccountReadSaysSo() = runTest { + providerDownFor += "b" + val api = ScopedApi(mapOf("48217730" to "b")) + val vm = DevicePairingViewModel( + DeviceLoginRepository(api), null, "48217730", servers(home, cabin), + initialServerId = "b", lockServer = true, + ) + assertTrue(vm.uiState.value.accountUnconfirmed) + assertEquals(DevicePairingError.ProviderUnavailable, vm.uiState.value.accountError) + assertEquals(false, vm.uiState.value.canApprove) + + providerDownFor.clear() + accounts["b"] = "laura.cabin" + vm.retryAccount() + assertNull(vm.uiState.value.accountError) + assertEquals(true, vm.uiState.value.canApprove) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingUiStateTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingUiStateTest.kt index 88557d5ece..1eb7477742 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingUiStateTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/DevicePairingUiStateTest.kt @@ -49,7 +49,7 @@ class DevicePairingUiStateTest { val state = DevicePairingUiState( token = "tok_abc", lookup = null, - error = "That code has expired.", + error = DevicePairingError.Expired, ) assertFalse(state.canDecide) } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE index 8b95472fab..69700c7695 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE +++ b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE @@ -1,9 +1,15 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/api/v2/fixtures -commit=83ac7a9b3da25914a04176185fe4f3a0a98eab45 -ref=HEAD +commit=9493206e837b3adfc16e4d404ae06342ca7baa64 +ref=t3code/oidc-sso-auth-support +snapshot=committed api_major=2 +snapshot=uncommitted means the bodies were copied from a server checkout with +uncommitted fixture changes on top of that commit, so the commit alone does +not reproduce them. Re-vendor from a server commit that contains them before +relying on this record. + Byte-identical copies of a subset of the server's native API v2 contract fixtures; do not hand-edit any of them. The server generates them through its real v2 router (make apiv2-fixtures) and validates the bodies against the diff --git a/shared/src/commonTest/resources/api/v2/fixtures/cancel_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/cancel_device_login_ok.json new file mode 100644 index 0000000000..9e3a84b7d6 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/cancel_device_login_ok.json @@ -0,0 +1,3 @@ +{ + "status": "canceled" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json new file mode 100644 index 0000000000..87488f3c05 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/invalid_grant", + "title": "Invalid grant", + "status": 400, + "detail": "The code_verifier does not fit this link code.", + "instance": "urn:silo:request:0000000000000000000002bf" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json new file mode 100644 index 0000000000..4e37607071 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/invalid_grant", + "title": "Invalid grant", + "status": 400, + "detail": "The code_verifier does not fit this completion code.", + "instance": "urn:silo:request:0000000000000000000002b9" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_native_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_native_ok.json new file mode 100644 index 0000000000..bc9e2164bb --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_native_ok.json @@ -0,0 +1,17 @@ +{ + "access_token": "acc", + "refresh_token": "ref", + "expires_in": 3600, + "next": "/", + "user": { + "id": "1", + "username": "laura", + "email": "laura@example.test", + "role": "user", + "permissions": [ + "marker_edit" + ], + "download_allowed": true, + "password_change_required": false + } +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_ok.json new file mode 100644 index 0000000000..c123915d87 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_ok.json @@ -0,0 +1,4 @@ +{ + "ticket": "9d2c5f0e8b7a41c3a6e5d4c3b2a19087f6e5d4c3b2a1908f7e6d5c4b3a291807", + "expires_at": "2026-01-02T03:09:05.678Z" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json new file mode 100644 index 0000000000..30d22dea04 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json @@ -0,0 +1,14 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/validation_failed", + "title": "Validation failed", + "status": 422, + "detail": "The request did not pass validation; see errors.", + "instance": "urn:silo:request:0000000000000000000002bb", + "errors": [ + { + "location": "body.password", + "code": "invalid", + "detail": "The password is incorrect." + } + ] +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json b/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json new file mode 100644 index 0000000000..78639be6a6 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/last_sign_in_method", + "title": "Last sign-in method", + "status": 409, + "detail": "The account has no other way to sign in; ask an administrator to set a password for it first", + "instance": "urn:silo:request:0000000000000000000002b0" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_capability_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_capability_ok.json new file mode 100644 index 0000000000..7e1e93602e --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_capability_ok.json @@ -0,0 +1,10 @@ +{ + "revision": "620a144f15ea20c4b95fc4a79b8bb310bc35997a9a18d070f7d708f1cc0aab2f", + "state": "available", + "remote_playback_handoff": true, + "protocol_versions": [ + 2 + ], + "cancel": true, + "opened_signal": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_ok.json new file mode 100644 index 0000000000..2ef3ee0701 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_device_login_ok.json @@ -0,0 +1,14 @@ +{ + "status": "pending", + "user_code": "4821-7730", + "match_code": "warm pony", + "device_name": "Living room TV", + "device_platform": "tvos", + "ip_address_hint": "192.168.1.x", + "expires_at": "2026-01-02T03:14:05.678Z", + "requested_at": "2026-01-02T03:04:05.678Z", + "client_purpose": "device_login", + "temporary": false, + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + "server_name": "Silo" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json new file mode 100644 index 0000000000..439f441f48 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json @@ -0,0 +1,12 @@ +{ + "revision": "39f58f9b45a058cfae314f94a03b66f3f100a02c7a4d45aac56c1c1a7e7600bb", + "state": "available", + "available": true, + "identities": true, + "admin_identities": true, + "break_glass": true, + "connection_test": true, + "live_provider_changes": true, + "provider_recheck": true, + "credentials_linking": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_oauth_handshake_capabilities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_oauth_handshake_capabilities_ok.json new file mode 100644 index 0000000000..77c35a38cf --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_oauth_handshake_capabilities_ok.json @@ -0,0 +1,9 @@ +{ + "revision": "536468a609e2f1c6316fa61622c8d9fad4b447d965efcf21e5d7c979dfdbd605", + "state": "available", + "available": true, + "native": true, + "linking": true, + "provider_logout": true, + "select_account": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json index 1b092beaaf..82a00b67c9 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "b0aa2de57df576605f4fa662ab418004140bca91b115c8ce6aadea60c12518bd", + "contract_digest": "0536b7227b71a593baed0be1dc81a9603517c8948b429ab5d34a7fa3bf064c39", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/index.json b/shared/src/commonTest/resources/api/v2/fixtures/index.json index 71a2a58e0d..7acf67dcd9 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/index.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/index.json @@ -18,6 +18,143 @@ "schema": "#/components/schemas/Problem", "body_file": "authentication_required.json" }, + { + "name": "cancel_device_login_ok", + "operation_id": "cancelDeviceLogin", + "scenario": "The device leaves its sign-in screen and withdraws its request, so the code can no longer be approved.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/cancel", + "body": "{\"device_code\":\"dev-pending\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginCancel", + "body_file": "cancel_device_login_ok.json" + }, + { + "name": "complete_account_identity_link_invalid_grant", + "operation_id": "completeAccountIdentityLink", + "scenario": "A link code redeemed with a verifier that does not fit is refused and stays redeemable.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-complete", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"code\":\"l1nk\",\"code_verifier\":\"wrong-verifier-wrong-verifier-wrong-verifier-x\"}" + }, + "expected_status": 400, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "complete_account_identity_link_invalid_grant.json" + }, + { + "name": "complete_oauth_login_invalid_grant", + "operation_id": "completeOAuthLogin", + "scenario": "A native code redeemed without its code verifier is refused and stays redeemable.", + "request": { + "method": "POST", + "path": "/api/v2/auth/oauth/complete", + "body": "{\"code\":\"n4tive\"}" + }, + "expected_status": 400, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "complete_oauth_login_invalid_grant.json" + }, + { + "name": "complete_oauth_login_native_ok", + "operation_id": "completeOAuthLogin", + "scenario": "A native app redeems the code the app redirect carried with its PKCE code verifier.", + "request": { + "method": "POST", + "path": "/api/v2/auth/oauth/complete", + "body": "{\"code\":\"n4tive\",\"code_verifier\":\"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/OAuthCompletion", + "body_file": "complete_oauth_login_native_ok.json" + }, + { + "name": "create_account_identity_link_ticket_ok", + "operation_id": "createAccountIdentityLinkTicket", + "scenario": "The account confirms its password and gets a ticket that starts a provider linking flow.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-ticket", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"installation_id\":\"3\",\"password\":\"right password\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AccountIdentityLinkTicket", + "body_file": "create_account_identity_link_ticket_ok.json" + }, + { + "name": "create_account_identity_link_ticket_wrong_password", + "operation_id": "createAccountIdentityLinkTicket", + "scenario": "A wrong password is a validation problem at the password member.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-ticket", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"installation_id\":\"3\",\"password\":\"wrong password\"}" + }, + "expected_status": 422, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "create_account_identity_link_ticket_wrong_password.json" + }, + { + "name": "delete_account_identity_last_sign_in_method", + "operation_id": "deleteAccountIdentity", + "scenario": "Disconnecting the only way the account can sign in is refused.", + "request": { + "method": "DELETE", + "path": "/api/v2/account/identities/4", + "headers": { + "Authorization": "Bearer tok-member" + } + }, + "expected_status": 409, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "delete_account_identity_last_sign_in_method.json" + }, { "name": "get_current_user_ok", "operation_id": "getCurrentUser", @@ -38,6 +175,74 @@ "schema": "#/components/schemas/Account", "body_file": "get_current_user_ok.json" }, + { + "name": "get_device_login_capability_ok", + "operation_id": "getDeviceLoginCapability", + "scenario": "Device pairing support before login.", + "request": { + "method": "GET", + "path": "/api/v2/auth/device/capability" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginCapability", + "body_file": "get_device_login_capability_ok.json" + }, + { + "name": "get_device_login_ok", + "operation_id": "getDeviceLogin", + "scenario": "A browser looks a pairing request up by its user code before deciding.", + "request": { + "method": "GET", + "path": "/api/v2/auth/device?code=4821-7730" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLogin", + "body_file": "get_device_login_ok.json" + }, + { + "name": "get_external_sign_in_capabilities_ok", + "operation_id": "getExternalSignInCapabilities", + "scenario": "An app discovers that the server links external sign-in identities and tests auth plugins.", + "request": { + "method": "GET", + "path": "/api/v2/auth/external-sign-in/capabilities" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ExternalSignInCapabilities", + "body_file": "get_external_sign_in_capabilities_ok.json" + }, + { + "name": "get_oauth_handshake_capabilities_ok", + "operation_id": "getOAuthHandshakeCapabilities", + "scenario": "An app discovers that the server hands OAuth sign-in off to native apps and links identities.", + "request": { + "method": "GET", + "path": "/api/v2/auth/oauth/capabilities" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/OAuthHandshakeCapabilitiesOutputBody", + "body_file": "get_oauth_handshake_capabilities_ok.json" + }, { "name": "get_setup_status_ok", "operation_id": "getSetupStatus", @@ -76,6 +281,43 @@ "schema": "#/components/schemas/SystemInfo", "body_file": "get_system_info_ok.json" }, + { + "name": "list_account_identities_ok", + "operation_id": "listAccountIdentities", + "scenario": "The account's Sign-in section lists the provider identity it is linked to.", + "request": { + "method": "GET", + "path": "/api/v2/account/identities", + "headers": { + "Authorization": "Bearer tok-member" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AccountIdentityCollection", + "body_file": "list_account_identities_ok.json" + }, + { + "name": "list_auth_providers_ok", + "operation_id": "listAuthProviders", + "scenario": "The sign-in providers a client may offer before login: the built-in one and a plugin-backed OAuth provider.", + "request": { + "method": "GET", + "path": "/api/v2/auth/providers" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AuthProviderCollection", + "body_file": "list_auth_providers_ok.json" + }, { "name": "list_progress_offset_rejected", "operation_id": "listProgress", @@ -175,6 +417,42 @@ "schema": "#/components/schemas/Problem", "body_file": "not_found.json" }, + { + "name": "poll_device_login_ok", + "operation_id": "pollDeviceLogin", + "scenario": "The device polls an approved request and collects its token pair under tokens.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/poll", + "body": "{\"device_code\":\"dev-approved\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginPoll", + "body_file": "poll_device_login_ok.json" + }, + { + "name": "poll_device_login_opened", + "operation_id": "pollDeviceLogin", + "scenario": "The device polls a pending request an approver has open: it keeps its code and says to continue on the phone.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/poll", + "body": "{\"device_code\":\"dev-opened\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginPoll", + "body_file": "poll_device_login_opened.json" + }, { "name": "profile_verification_required", "operation_id": null, @@ -219,6 +497,84 @@ "schema": "#/components/schemas/Problem", "body_file": "rate_limited.json" }, + { + "name": "refresh_session_provider_unavailable", + "operation_id": "refreshSession", + "scenario": "A session opened through the external sign-in provider is due for its provider re-check, the provider cannot be reached, and the outage policy is fail_closed: the session stays valid and the client retries later.", + "request": { + "method": "POST", + "path": "/api/v2/auth/refresh", + "body": "{\"refresh_token\":\"provider-down\"}" + }, + "expected_status": 503, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "refresh_session_provider_unavailable.json" + }, + { + "name": "server_connections_ok", + "operation_id": "getServerConnections", + "scenario": "A signed-in client reads the addresses the deployment offers: the public URL, a connected provider with its overlay origin, and an installed provider whose plugin is not running.", + "request": { + "method": "GET", + "path": "/api/v2/system/connections", + "headers": { + "Authorization": "Bearer tok-member" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json", + "ETag": "\"251d40c6578ba4f568f974b66faed17c5fd6ea82251fdc08ebc30e12c2be477f\"" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ServerConnectionsDocument", + "body_file": "server_connections_ok.json" + }, + { + "name": "server_identity_ok", + "operation_id": "getServerIdentity", + "scenario": "Discovery before login: the deployment's stable server identity, the same at every address.", + "request": { + "method": "GET", + "path": "/api/v2/system/identity" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-cache", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ServerIdentity", + "body_file": "server_identity_ok.json" + }, + { + "name": "start_device_login_ok", + "operation_id": "startDeviceLogin", + "scenario": "A device opens a pairing request: the codes it shows and polls with, and the request's expiry.", + "request": { + "method": "POST", + "path": "/api/v2/auth/device/start", + "headers": { + "X-Forwarded-Host": "silo.example.test", + "X-Forwarded-Proto": "https" + }, + "body": "{\"device_name\":\"Living room TV\",\"device_platform\":\"tvos\"}" + }, + "expected_status": 201, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/DeviceLoginStart", + "body_file": "start_device_login_ok.json" + }, { "name": "update_profile_null_not_clearable", "operation_id": "updateProfile", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/list_account_identities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/list_account_identities_ok.json new file mode 100644 index 0000000000..0b490f5996 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/list_account_identities_ok.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "id": "4", + "installation_id": "3", + "provider_id": "plugin:3:oidc", + "provider_name": "Company SSO", + "username": "alice", + "email": "alice@example.test", + "display_name": "Alice Example", + "linked_at": "2026-01-02T03:04:05.000Z", + "last_sign_in_at": "2026-01-03T04:05:06.000Z", + "last_checked_at": "2026-01-03T16:07:08.000Z" + } + ], + "can_unlink": false +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/list_auth_providers_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/list_auth_providers_ok.json new file mode 100644 index 0000000000..08bfffab29 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/list_auth_providers_ok.json @@ -0,0 +1,20 @@ +{ + "items": [ + { + "id": "local", + "display_name": "Silo account", + "mode": "credentials", + "default": true + }, + { + "id": "plugin-3", + "display_name": "Example SSO", + "mode": "oauth", + "default": false, + "icon_url": "https://plugins.example.test/icon.svg", + "installation_id": "3", + "native_start_path": "/api/v2/auth/oauth/3/native/start" + } + ], + "password_login": true +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_ok.json new file mode 100644 index 0000000000..f056d6feaf --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_ok.json @@ -0,0 +1,22 @@ +{ + "status": "approved", + "poll_after": 5, + "opened": false, + "tokens": { + "access_token": "acc", + "refresh_token": "ref", + "expires_in": 3600, + "user": { + "id": "1", + "username": "laura", + "email": "laura@example.test", + "role": "user", + "permissions": [], + "download_allowed": true, + "password_change_required": false + } + }, + "profile_id": "", + "profile_token": "", + "temporary": false +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_opened.json b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_opened.json new file mode 100644 index 0000000000..ef3337d606 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/poll_device_login_opened.json @@ -0,0 +1,9 @@ +{ + "status": "pending", + "poll_after": 5, + "opened": true, + "expires_at": "2026-01-02T03:14:05.678Z", + "profile_id": "", + "profile_token": "", + "temporary": false +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json b/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json new file mode 100644 index 0000000000..dcaf2258e7 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/provider_unavailable", + "title": "Provider unavailable", + "status": 503, + "detail": "The sign-in provider could not confirm the account. Try again later; the session stays valid.", + "instance": "urn:silo:request:0000000000000000000002c1" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/server_connections_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/server_connections_ok.json new file mode 100644 index 0000000000..dbb21bb184 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/server_connections_ok.json @@ -0,0 +1,28 @@ +{ + "revision": "82278f19ff5dc619091bee7b0efe5f375a0b25ec07002a424a5d15efbdc66900", + "state": "available", + "allowed": true, + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", + "current": { + "kind": "default" + }, + "endpoints": [ + { + "kind": "public", + "url": "https://silo.example.test" + }, + { + "kind": "provider", + "provider": "down", + "display_name": "Down Overlay", + "state": "unavailable" + }, + { + "kind": "provider", + "url": "https://silo.overlay.example.test", + "provider": "stub", + "display_name": "Stub Overlay", + "state": "connected" + } + ] +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/server_identity_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/server_identity_ok.json new file mode 100644 index 0000000000..9ea4b63399 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/server_identity_ok.json @@ -0,0 +1,3 @@ +{ + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/start_device_login_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/start_device_login_ok.json new file mode 100644 index 0000000000..b1cf4e1c74 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/start_device_login_ok.json @@ -0,0 +1,14 @@ +{ + "device_code": "dev-1", + "user_code": "4821-7730", + "match_code": "warm pony", + "verification_uri": "https://silo.example.test/activate", + "verification_uri_complete": "https://silo.example.test/activate?code=48217730", + "expires_at": "2026-01-02T03:19:05.678Z", + "expires_in": 900, + "interval": 5, + "device_name": "Living room TV", + "device_platform": "tvos", + "client_purpose": "device_login", + "temporary": false +} From be56fd573f2a13ef76a0fa273668e7cd973a93bd Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:18:44 -0400 Subject: [PATCH 14/87] ci: run CI on GitHub-hosted runners (#453) Member PRs and default-branch builds ran on the private Linux runners. Route them to GitHub-hosted runners while the private runner setup is rebuilt; the reusable workflow already supports the hosted path, which non-member PRs use today. Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/android-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index daaebbc194..788acbfb13 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -28,5 +28,5 @@ jobs: ci: uses: Silo-Server/silo-android/.github/workflows/trusted-linux-ci.yml@main with: - self_hosted: ${{ github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main')) }} + self_hosted: false build_ffmpeg_aar: ${{ inputs.build_ffmpeg_aar || false }} From b26dddaca8d4766f3dd9479b8d5affe2abd6c67b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:59:53 -0400 Subject: [PATCH 15/87] feat(auth): sign in with network identity on phone and TV (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): sign in with network identity on phone and TV When the server lists a network provider (such as the Tailscale plugin) for this device, phone and TV offer "Continue as " above the other ways in. One press posts {} to the listed network_sign_in_path on the saved server base and saves the returned token pair exactly as a password sign-in does. The TV button takes initial focus; the device code, QR and password form stay available. Refusals (network_identity_required, not_permitted, email_in_use and the shared ones) have readable text that names the provider. Phone Settings > Sign-in offers "Connect " for a listed network provider when getExternalSignInCapabilities reports network_sign_in, asking only for the account password (linkAccountIdentityWithNetwork). Vendors only the network sign-in contract fixtures and the updated capabilities fixture, and adds them to the sync script's selection. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): free the network button after a device approval and offer it in the TV password form A phone approval that cancelled a pending "Continue as" sign-in left the network button busy when saving the approval failed, which also blocked the password form. The device handoff now frees both buttons when it ends. On servers without device sign-in, the TV opens on the password form, which had no "Continue as" button. The form now shows it above the fields and focuses it, including when the providers answer before the device check. The two-line button uses less vertical padding so the form still fits a 960x540dp screen. The TV's email_in_use message for network sign-in no longer points to Settings, which the TV does not have. It points to the phone or web app. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): stop a cancelled link and keep busy network sign-ins blocking Cancel in the Settings directory and network prompts now stops the link call still under way, like the browser prompt already did, and reloads the section so a link the server made first still shows. Before, the link kept running and could report "Connected" after the prompt was gone. On the phone, "Continue as" keeps its own busy flag. Reloading the sign-in options no longer frees the password form and provider buttons while the request is still out. On the TV, "Sign in with a password" waits while "Continue as" is signing in, so the form can't hide the "Signing in…" label. Once "Continue as" is refused, the device code's recovery action ("Try again", "Show a new code") takes focus again. Cleanups: the phone's network refusals in Settings map through NetworkSignInFailure and share their copy with the sign-in screen; the TV error text takes one provider name for the attempt; the network sign-in call trusts the path discovery already validated; and the unused OAuthHandshakeCapabilities.networkProvider is gone. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): keep a late Sign-in reload from undoing a newer one Cancelling a link prompt reloads the Sign-in section, and so does every link that succeeds. If the cancelled prompt's reload answered after the next link's reload, it showed the identities from before that link. Each reload now takes a generation, and only the newest publishes. Co-Authored-By: Claude Opus 5.5 (1M context) * test(contracts): re-vendor the API v2 fixtures from the network sign-in server commit SOURCE named the earlier OIDC server commit, which doesn't contain the network sign-in fixtures. A full re-sync from the server branch that adds them reproduces every vendored body; the only other change is the system info fixture's contract digest. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): disable the TV password form while Continue as signs in On a server without device sign-in, the password form shows "Continue as" above the fields. The view model already ignored a password submission while the network sign-in waited, but the fields, the IME Done action and Sign in stayed enabled, so Sign in looked live and did nothing. They now follow one signingIn state (password or network busy) that the view model's guards also use. Focus stays on the waiting "Continue as", and the D-pad steps between it and the secondary actions past the disabled controls. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(auth): disable the phone password form during any sign-in The phone view model ignores a password submission while any sign-in is under way (signInBusy: password, browser provider start, or Continue as). The Sign in button only followed the network wait, though, and the fields, the show-password toggle and the IME Go action never did, so they looked live and did nothing. All of them now follow signInBusy, as the TV form does. AuroraTextField gains an enabled flag that dims its text when off. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../ui/components/aurora/AuroraChrome.kt | 8 +- .../android/ui/screens/auth/LoginScreen.kt | 26 +- .../android/ui/screens/auth/LoginViewModel.kt | 100 +++++- .../ui/screens/auth/SignInProviderButton.kt | 41 ++- .../ui/screens/settings/SignInSection.kt | 27 +- .../settings/SignInSettingsViewModel.kt | 158 +++++++-- .../android/auth/NativeSignInCompleterTest.kt | 3 + .../ui/screens/auth/LoginViewModelSsoTest.kt | 154 +++++++++ .../ui/screens/settings/SignInSettingsTest.kt | 320 ++++++++++++++++++ .../silo/tv/ui/components/TvAuroraChrome.kt | 32 +- .../silo/tv/ui/screens/auth/TvLoginScreen.kt | 217 +++++++++--- .../tv/ui/screens/auth/TvLoginViewModel.kt | 166 +++++++-- .../src/androidMain/res/values/strings.xml | 12 + .../auth/TvLoginScreenNetworkSignInTest.kt | 225 ++++++++++++ .../auth/TvLoginViewModelDeviceSignInTest.kt | 174 +++++++++- scripts/sync-apiv2-fixtures.sh | 3 + .../silo/model/auth/ExternalSignInModels.kt | 93 ++++- .../silo/network/api/ExternalSignInApi.kt | 95 +++++- .../silo/repository/AuthRepository.kt | 26 +- .../repository/ExternalSignInRepository.kt | 18 +- .../silo/network/api/ExternalSignInApiTest.kt | 204 ++++++++++- .../silo/network/apiv2/ApiV2ContractTest.kt | 85 +++++ .../resources/api/v2/fixtures/SOURCE | 4 +- .../get_external_sign_in_capabilities_ok.json | 5 +- .../api/v2/fixtures/get_system_info_ok.json | 2 +- .../resources/api/v2/fixtures/index.json | 58 ++++ ...link_account_identity_with_network_ok.json | 12 + ..._in_with_network_identity_off_overlay.json | 7 + .../sign_in_with_network_identity_ok.json | 16 + 29 files changed, 2133 insertions(+), 158 deletions(-) create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/link_account_identity_with_network_ok.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json create mode 100644 shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_ok.json diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt index d7b7079ddd..8cf175962c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt @@ -255,10 +255,13 @@ fun AuroraTextField( onImeAction: (() -> Unit)? = null, visualTransformation: VisualTransformation = VisualTransformation.None, trailing: (@Composable () -> Unit)? = null, + enabled: Boolean = true, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val shape = RoundedCornerShape(AuroraControlCorner) + // A disabled field's text dims like a disabled sign-in button's label. + val inkAlpha = if (enabled) 1f else 0.6f Column(modifier = modifier.fillMaxWidth()) { AuroraFieldLabel(label) @@ -290,17 +293,18 @@ fun AuroraTextField( Text( text = placeholder, fontSize = 17.sp, - color = if (isFocused) AuroraActivePlaceholder else AuroraInkTertiary, + color = (if (isFocused) AuroraActivePlaceholder else AuroraInkTertiary).let { it.copy(alpha = it.alpha * inkAlpha) }, ) } BasicTextField( value = value, onValueChange = onValueChange, modifier = Modifier.fillMaxWidth(), + enabled = enabled, singleLine = true, textStyle = LocalTextStyle.current.merge( TextStyle( - color = if (isFocused) AuroraActiveInk else AuroraInk, + color = (if (isFocused) AuroraActiveInk else AuroraInk).let { it.copy(alpha = it.alpha * inkAlpha) }, fontSize = 17.sp, ), ), diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt index 4856e40d76..bdcc3207dc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt @@ -56,7 +56,9 @@ import org.koin.compose.viewmodel.koinViewModel /** * Sign-in. Mirrors silo-apple iOS phone `LoginView` (Aurora): wordmark, - * "Step 02 — Sign in" eyebrow + "Welcome back", then a glass card with a + * "Step 02 — Sign in" eyebrow + "Welcome back", then a glass card with + * "Continue as " first when the server lists a network provider for + * this device (such as Tailscale: no browser, no password), a * "Sign in with " button per external provider the server lists * (OIDC, run in a Custom Tab), "Use a different account" when the server * takes `prompt=select_account`, Username + Password fields and the cream @@ -154,13 +156,23 @@ fun LoginScreen( Text(text = "Loading sign-in options…", color = AuroraInkTertiary, fontSize = 14.sp) } } + state.networkProvider?.let { provider -> + SignInProviderButton( + provider = provider, + label = continueAsLabel(provider), + supportingText = continueViaLabel(provider), + onClick = viewModel::onNetworkSignIn, + busy = state.networkSignInBusy, + enabled = !state.signInBusy, + ) + } state.providers.forEach { provider -> SignInProviderButton( provider = provider, label = signInWithLabel(provider), onClick = { viewModel.onProviderClick(provider) }, busy = state.providerBusy == provider.id, - enabled = !state.isLoading && state.providerBusy == null, + enabled = !state.signInBusy, ) } if (state.offersAccountChoice) { @@ -187,11 +199,11 @@ fun LoginScreen( AuroraGhostButton(label = "Retry", onClick = viewModel::loadOptions) } } - if (state.providers.isNotEmpty() && state.showPasswordForm) { + if ((state.providers.isNotEmpty() || state.networkProvider != null) && state.showPasswordForm) { OrDivider() } if (!state.showPasswordForm) { - if (state.options != null && state.providers.isEmpty()) { + if (state.options != null && state.providers.isEmpty() && state.networkProvider == null) { // Password sign-in is off and the provider can't run in this app. AuroraErrorLabel("This server doesn't offer a sign-in this app can use. Sign in on the web, or ask the server's admin.") } @@ -213,6 +225,7 @@ fun LoginScreen( modifier = Modifier.focusRequester(usernameFocus), placeholder = "yourname", imeAction = ImeAction.Next, + enabled = !state.signInBusy, ) AuroraTextField( label = "Password", @@ -227,8 +240,9 @@ fun LoginScreen( } else { PasswordVisualTransformation() }, + enabled = !state.signInBusy, trailing = { - IconButton(onClick = { showPassword = !showPassword }) { + IconButton(onClick = { showPassword = !showPassword }, enabled = !state.signInBusy) { Icon( imageVector = if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, contentDescription = if (showPassword) "Hide password" else "Show password", @@ -244,6 +258,8 @@ fun LoginScreen( label = if (state.isLoading) "Signing in…" else "Sign in", onClick = viewModel::onLoginClick, isLoading = state.isLoading, + // Any sign-in under way: the view model takes no password until it ends. + enabled = !state.signInBusy, modifier = Modifier.fillMaxWidth(), ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModel.kt index 64133404db..fbb4e0b96f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModel.kt @@ -8,6 +8,7 @@ import org.siloserver.silo.android.auth.NativeSignInProtocol import org.siloserver.silo.android.auth.NativeSignInPurpose import org.siloserver.silo.android.auth.NativeSignInResult import org.siloserver.silo.android.auth.NativeSignInStart +import org.siloserver.silo.model.auth.NetworkSignInFailure import org.siloserver.silo.model.auth.PasswordLoginFailure import org.siloserver.silo.model.auth.SignInOptions import org.siloserver.silo.model.auth.SignInProvider @@ -42,6 +43,12 @@ data class LoginUiState( val optionsUnavailable: Boolean = false, /** The provider whose browser sign-in is starting or finishing. */ val providerBusy: String? = null, + /** + * "Continue as …" is waiting for the server: the password form and the + * provider buttons wait too. Its own flag, so reloading the options + * can't free them while the request is still out. + */ + val networkSignInBusy: Boolean = false, /** A start URL for the screen to open in a Custom Tab, once. */ val browserLaunch: String? = null, /** "Use a different account" with several providers: which one to sign in with. */ @@ -50,8 +57,14 @@ data class LoginUiState( val showPasswordForm: Boolean get() = options?.showPasswordForm == true val providers: List get() = options?.oauthProviders.orEmpty() + /** "Continue as ": the network provider the server listed for this device, above everything else. */ + val networkProvider: SignInProvider? get() = options?.networkProvider + /** "Use a different account" under the provider buttons, when the server takes `prompt=select_account`. */ val offersAccountChoice: Boolean get() = options?.selectAccount == true && providers.isNotEmpty() + + /** A sign-in on this screen is under way, so no other may start. */ + val signInBusy: Boolean get() = isLoading || providerBusy != null || networkSignInBusy } class LoginViewModel( @@ -125,7 +138,7 @@ class LoginViewModel( } fun onLoginClick() { - if (_uiState.value.isLoading) return + if (_uiState.value.signInBusy) return val current = _uiState.value if (current.username.isBlank()) { _uiState.update { it.copy(error = "Username is required") } @@ -141,9 +154,7 @@ class LoginViewModel( when (val result = authRepository.login(current.username, current.password)) { is ApiResult.Success -> { - screenServerId?.let(nativeSignIn::clearAccountChoice) - // A provider flow left open in the browser no longer speaks for this session. - nativeSignIn.discardPending() + onSignedIn() _uiState.update { it.copy(isLoading = false, loginSuccess = true) } } @@ -169,6 +180,42 @@ class LoginViewModel( } } + /** A sign-in on this screen (password or network identity) saved its session. */ + private suspend fun onSignedIn() { + screenServerId?.let(nativeSignIn::clearAccountChoice) + // A provider flow left open in the browser no longer speaks for this session. + nativeSignIn.discardPending() + } + + /** + * "Continue as ": sign in as whoever the server's network provider + * (such as Tailscale) says owns this device. No browser and no password: + * one POST to the listed path on the saved server base, and the session + * lands exactly like a password sign-in's. + */ + fun onNetworkSignIn() { + val state = _uiState.value + val provider = state.networkProvider ?: return + val path = provider.networkSignInPath ?: return + if (state.signInBusy) return + _uiState.update { it.copy(networkSignInBusy = true, error = null) } + viewModelScope.launch { + val result = authRepository.signInWith { serverUrl -> externalSignIn.signInWithNetworkIdentity(serverUrl, path) } + when (result) { + is ApiResult.Success -> { + onSignedIn() + _uiState.update { it.copy(networkSignInBusy = false, loginSuccess = true) } + } + is ApiResult.Error -> _uiState.update { + it.copy(networkSignInBusy = false, error = networkSignInMessage(result, provider.displayName)) + } + is ApiResult.NetworkError -> _uiState.update { + it.copy(networkSignInBusy = false, error = "Network error. Please check your connection.") + } + } + } + } + /** * "Sign in with ": record a native flow for the active server * and hand its start URL to the screen. The redirect must name this @@ -179,7 +226,7 @@ class LoginViewModel( */ fun onProviderClick(provider: SignInProvider, chooseAccount: Boolean = false) { val startPath = provider.nativeStartPath ?: return - if (_uiState.value.isLoading || _uiState.value.providerBusy != null) return + if (_uiState.value.signInBusy) return _uiState.update { it.copy(providerBusy = provider.id, error = null, choosingAccountProvider = false) } viewModelScope.launch { val entry = serverRegistry.activeEntry.value @@ -223,7 +270,7 @@ class LoginViewModel( */ fun onUseDifferentAccount() { val state = _uiState.value - if (!state.offersAccountChoice || state.isLoading || state.providerBusy != null) return + if (!state.offersAccountChoice || state.signInBusy) return val only = state.providers.singleOrNull() if (only != null) onProviderClick(only, chooseAccount = true) else _uiState.update { it.copy(choosingAccountProvider = true) } } @@ -312,6 +359,47 @@ internal fun passwordLoginMessage( PasswordLoginFailure.Other -> error.message.ifBlank { "Login failed" } } +/** + * Text for a refused network identity sign-in ("Continue as …") through + * [providerName], such as Tailscale. Refusals other operations share read as + * they do there. + */ +internal fun networkSignInMessage(error: ApiResult.Error, providerName: String): String = + when (NetworkSignInFailure.of(error.code, error.error)) { + NetworkSignInFailure.NetworkIdentityRequired -> ProviderRefusalText.addressRequired(providerName, "sign in this way") + NetworkSignInFailure.NotPermitted -> ProviderRefusalText.notPermitted(providerName) + NetworkSignInFailure.EmailInUse -> + "An account with your email already exists. Sign in with your password, then connect $providerName in Settings → Sign-in." + NetworkSignInFailure.AccountRequired -> NativeSignInMessages.forReason(NativeSignInMessages.ACCOUNT_REQUIRED) + NetworkSignInFailure.AccountDisabled -> NativeSignInMessages.forReason("account_disabled") + NetworkSignInFailure.IdentityLinkedElsewhere -> NativeSignInMessages.forReason("identity_linked_elsewhere", providerName) + NetworkSignInFailure.NotFound -> ProviderRefusalText.providerGone(providerName) + NetworkSignInFailure.ProviderUnavailable -> NativeSignInMessages.forReason("provider_unavailable", providerName) + NetworkSignInFailure.RateLimited -> NativeSignInMessages.forReason(NativeSignInMessages.RATE_LIMITED) + NetworkSignInFailure.Other -> when { + // AuthRepository's code for a sign-in whose account or server changed underneath it. + error.error == "identity_changed" -> NativeSignInMessages.forReason(NativeSignInMessages.ACCOUNT_CHANGED) + error.code == 0 && error.error == NativeSignInMessages.UPDATE_REQUIRED -> + NativeSignInMessages.forReason(NativeSignInMessages.UPDATE_REQUIRED) + else -> NativeSignInMessages.forReason("login_failed", providerName) + } + } + +/** + * The phone's copy for provider refusals that sign-in ("Continue as …") and + * "Connect " in Settings share. + */ +internal object ProviderRefusalText { + /** `network_identity_required`: the app reached the server some other way; [toDo] is what needs that address. */ + fun addressRequired(providerName: String, toDo: String) = "Open this server at its $providerName address to $toDo." + + /** `not_permitted`: a tagged device, or one the provider's policy leaves out. */ + fun notPermitted(providerName: String) = "$providerName doesn't allow this device on this server." + + /** The provider is no longer enabled on the server (404). */ + fun providerGone(providerName: String) = "$providerName is no longer available on this server." +} + internal fun loginServerHostLabel(serverUrl: String): String? { val trimmed = serverUrl.trim() if (trimmed.isBlank()) return null diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt index 71363c2190..e07303184c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -39,6 +40,17 @@ import org.siloserver.silo.model.auth.SignInProvider /** "Sign in with ". */ fun signInWithLabel(provider: SignInProvider): String = "Sign in with ${provider.displayName}" +/** + * A network provider's button: "Continue as " when the provider named + * who owns this device, "Continue with " when it named nobody. + */ +fun continueAsLabel(provider: SignInProvider): String = + provider.networkIdentity?.label?.let { "Continue as $it" } ?: "Continue with ${provider.displayName}" + +/** The line under "Continue as " naming the provider; none when the label already names it. */ +fun continueViaLabel(provider: SignInProvider): String? = + provider.networkIdentity?.label?.let { "via ${provider.displayName}" } + /** * The account-choice link under the provider buttons. It picks another * account at the provider, not another way into Silo, so it names the @@ -49,9 +61,10 @@ fun differentAccountLabel(providers: List): String = /** * One external sign-in provider's button: its icon (SVG or bitmap from the - * plugin, a generic sign-in glyph when it has none or it can't be drawn) and - * [label]. Glass-outlined so the cream password button stays the primary - * action when both are offered. + * plugin, a generic sign-in glyph when it has none or it can't be drawn), + * [label] and, when given, a smaller [supportingText] line under it. + * Glass-outlined so the cream password button stays the primary action when + * both are offered. The button reads as one element, both lines together. */ @Composable fun SignInProviderButton( @@ -61,6 +74,7 @@ fun SignInProviderButton( modifier: Modifier = Modifier, busy: Boolean = false, enabled: Boolean = true, + supportingText: String? = null, ) { val shape = RoundedCornerShape(14.dp) val active = enabled && !busy @@ -86,12 +100,21 @@ fun SignInProviderButton( } else { ProviderIcon(provider) } - Text( - text = label, - color = AuroraInk.copy(alpha = if (active) 1f else 0.6f), - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - ) + Column { + Text( + text = label, + color = AuroraInk.copy(alpha = if (active) 1f else 0.6f), + fontWeight = FontWeight.SemiBold, + fontSize = 17.sp, + ) + if (supportingText != null) { + Text( + text = supportingText, + color = AuroraInk.copy(alpha = if (active) 0.68f else 0.42f), + fontSize = 13.sp, + ) + } + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt index f9672a1431..e7d28187e3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt @@ -91,6 +91,14 @@ fun SignInSection( enabled = !state.busy, ) } + state.network?.let { provider -> + SettingsNavigationRow( + label = "Connect ${provider.displayName}", + description = SignInSettingsViewModel.connectDescription(provider.displayName), + onClick = { viewModel.onConnectNetwork(provider) }, + enabled = !state.busy, + ) + } // Connect and disconnect finish after the browser or a dialog closes: // announce the outcome, which appears away from where focus is. val announced = Modifier.semantics { liveRegion = LiveRegionMode.Polite } @@ -101,12 +109,25 @@ fun SignInSection( state.passwordPrompt?.let { provider -> ConfirmPasswordDialog( providerName = provider.displayName, + body = SignInSettingsViewModel.connectPrompt(provider.displayName), + confirmLabel = "Continue", error = state.passwordError, busy = state.busy, onConfirm = viewModel::onConfirmPassword, onDismiss = viewModel::onDismissPasswordPrompt, ) } + state.networkPrompt?.let { provider -> + ConfirmPasswordDialog( + providerName = provider.displayName, + body = SignInSettingsViewModel.connectNetworkPrompt(provider.displayName, provider.networkIdentity?.label), + confirmLabel = "Connect", + error = state.passwordError, + busy = state.busy, + onConfirm = viewModel::onConfirmNetwork, + onDismiss = viewModel::onDismissNetworkPrompt, + ) + } state.directoryPrompt?.let { provider -> ConnectDirectoryDialog( providerName = provider.displayName, @@ -145,6 +166,8 @@ private fun formatDate(instant: String): String? = runCatching { @Composable private fun ConfirmPasswordDialog( providerName: String, + body: String, + confirmLabel: String, error: String?, busy: Boolean, onConfirm: (String) -> Unit, @@ -160,7 +183,7 @@ private fun ConfirmPasswordDialog( title = { Text("Connect $providerName") }, text = { Column { - Text(SignInSettingsViewModel.connectPrompt(providerName)) + Text(body) OutlinedTextField( value = password, onValueChange = { password = it }, @@ -177,7 +200,7 @@ private fun ConfirmPasswordDialog( }, confirmButton = { TextButton(enabled = !busy && password.isNotEmpty(), onClick = { onConfirm(password) }) { - Text(if (busy) "Checking…" else "Continue") + Text(if (busy) "Checking…" else confirmLabel) } }, dismissButton = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt index 3ba2fd4f1b..0d566edaeb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt @@ -16,7 +16,9 @@ import org.siloserver.silo.android.auth.NativeSignInMessages import org.siloserver.silo.android.auth.NativeSignInPurpose import org.siloserver.silo.android.auth.NativeSignInResult import org.siloserver.silo.android.auth.NativeSignInStart +import org.siloserver.silo.android.ui.screens.auth.ProviderRefusalText import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.NetworkSignInFailure import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities import org.siloserver.silo.model.auth.SignInOptions import org.siloserver.silo.model.auth.SignInProvider @@ -44,6 +46,14 @@ data class SignInSettingsUiState( val directory: SignInProvider? = null, /** Asking for the account password and the directory credentials before connecting [directory]. */ val directoryPrompt: SignInProvider? = null, + /** + * A network provider (such as Tailscale) this account can connect: the + * server lists one only to a device on that provider's network, and links + * the person who owns this device there. + */ + val network: SignInProvider? = null, + /** Asking for the account password before connecting [network]. */ + val networkPrompt: SignInProvider? = null, /** Asking for the local password before connecting this provider. */ val passwordPrompt: SignInProvider? = null, val passwordError: String? = null, @@ -57,15 +67,16 @@ data class SignInSettingsUiState( val browserLaunch: String? = null, ) { /** The section shows once the server has an external provider or the account has an identity. */ - val visible: Boolean get() = identities.isNotEmpty() || connectable.isNotEmpty() || directory != null + val visible: Boolean get() = identities.isNotEmpty() || connectable.isNotEmpty() || directory != null || network != null } /** * Account settings "Sign-in" section (silo-server external sign-in spec): * the provider identity linked to this account, "Connect " after * the account re-enters its local password (a link ticket, then the native - * browser flow with `link_ticket`), and "Disconnect", which the server allows - * only while the account can still sign in another way. + * browser flow with `link_ticket`; for a directory or a network provider, one + * call with no browser), and "Disconnect", which the server allows only while + * the account can still sign in another way. */ class SignInSettingsViewModel( private val externalSignIn: ExternalSignInRepository, @@ -77,8 +88,14 @@ class SignInSettingsViewModel( private val _uiState = MutableStateFlow(SignInSettingsUiState()) - /** The password prompt's ticket request and flow start, until the browser opens. */ + /** + * The open prompt's work: the ticket request and flow start until the + * browser opens, or a directory or network link until it answers. + */ private var connectJob: Job? = null + + /** Bumped by each [load]; only the newest read publishes, so an older one can't overwrite it. */ + private var loadGeneration = 0 val uiState: StateFlow = _uiState.asStateFlow() init { @@ -98,11 +115,20 @@ class SignInSettingsViewModel( * signed in on the same server; otherwise the section is read again. */ private suspend fun load() { + val generation = ++loadGeneration val scope = tokenManager.snapshotCurrentScope() val loginSessionId = scope?.let { tokenManager.loginSessionId(it.serverId) } if (scope == null) { + if (generation != loadGeneration) return _uiState.update { - it.copy(loading = false, identities = emptyList(), canUnlink = null, connectable = emptyList(), directory = null) + it.copy( + loading = false, + identities = emptyList(), + canUnlink = null, + connectable = emptyList(), + directory = null, + network = null, + ) } return } @@ -120,10 +146,14 @@ class SignInSettingsViewModel( handshake = (handshake.await() as? ApiResult.Success)?.data ?: OAuthHandshakeCapabilities.None, credentialsLinking = served?.credentialsLinking == true, canUnlink = listed?.canUnlink, + networkSignIn = served?.networkSignIn == true, ) } val now = tokenManager.snapshotCurrentScope() - if (now?.serverId != scope.serverId || tokenManager.loginSessionId(scope.serverId) != loginSessionId) { + val sameLogin = now?.serverId == scope.serverId && tokenManager.loginSessionId(scope.serverId) == loginSessionId + // A later read (a reload after a link, say) started meanwhile: it has the newer answer. + if (generation != loadGeneration) return + if (!sameLogin) { load() return } @@ -134,6 +164,7 @@ class SignInSettingsViewModel( canUnlink = section.canUnlink, connectable = section.connectable, directory = section.directory, + network = section.network, ) } } @@ -142,9 +173,7 @@ class SignInSettingsViewModel( _uiState.update { it.copy(directoryPrompt = provider, passwordError = null, message = null, error = null) } } - fun onDismissDirectoryPrompt() { - _uiState.update { it.copy(directoryPrompt = null, passwordError = null) } - } + fun onDismissDirectoryPrompt() = dismissPrompt { it.copy(directoryPrompt = null) } /** * Links the directory (LDAP) account whose credentials the person typed, @@ -159,26 +188,68 @@ class SignInSettingsViewModel( _uiState.update { it.copy(passwordError = "Fill in all three fields.") } return } + linkInOneCall( + provider = provider, + closePrompt = { it.copy(directoryPrompt = null) }, + refusal = { directoryLinkMessage(it, provider.displayName) }, + ) { scope -> externalSignIn.linkWithCredentials(scope, installationId, password, username.trim(), directoryPassword) } + } + + fun onConnectNetwork(provider: SignInProvider) { + _uiState.update { it.copy(networkPrompt = provider, passwordError = null, message = null, error = null) } + } + + fun onDismissNetworkPrompt() = dismissPrompt { it.copy(networkPrompt = null) } + + /** + * Links the person who owns this device at the network provider, after + * confirming this account's own password. Like a directory link, the + * server checks both in one call; the device says who the person is. + */ + fun onConfirmNetwork(password: String) { + val provider = _uiState.value.networkPrompt ?: return + val installationId = provider.installationId ?: return + if (_uiState.value.busy) return + if (password.isEmpty()) { + _uiState.update { it.copy(passwordError = "Enter your password.") } + return + } + linkInOneCall( + provider = provider, + closePrompt = { it.copy(networkPrompt = null) }, + refusal = { networkLinkMessage(it, provider.displayName) }, + ) { scope -> externalSignIn.linkWithNetwork(scope, installationId, password) } + } + + /** + * A link the server makes in one call, with no browser (directory and + * network providers): the open prompt closes on success and on refusals + * that typing again won't fix, and stays open for the rest. + */ + private fun linkInOneCall( + provider: SignInProvider, + closePrompt: (SignInSettingsUiState) -> SignInSettingsUiState, + refusal: (ApiResult.Error) -> String, + link: suspend (AuthScopeSnapshot) -> ApiResult, + ) { _uiState.update { it.copy(busy = true, passwordError = null) } - viewModelScope.launch { + connectJob = viewModelScope.launch { val scope = tokenManager.snapshotCurrentScope() if (scope == null) { - _uiState.update { it.copy(busy = false, directoryPrompt = null, error = ACCOUNT_CHANGED) } + _uiState.update { closePrompt(it).copy(busy = false, error = ACCOUNT_CHANGED) } return@launch } - when (val linked = externalSignIn.linkWithCredentials(scope, installationId, password, username.trim(), directoryPassword)) { + when (val linked = link(scope)) { is ApiResult.Success -> { load() - _uiState.update { - it.copy(busy = false, directoryPrompt = null, message = "Connected ${provider.displayName}.") - } + _uiState.update { closePrompt(it).copy(busy = false, message = "Connected ${provider.displayName}.") } } is ApiResult.Error -> { - val message = directoryLinkMessage(linked, provider.displayName) + val message = refusal(linked) if (keepsPromptOpen(linked)) { _uiState.update { it.copy(busy = false, passwordError = message) } } else { - _uiState.update { it.copy(busy = false, directoryPrompt = null, error = message) } + _uiState.update { closePrompt(it).copy(busy = false, error = message) } } } is ApiResult.NetworkError -> _uiState.update { @@ -192,12 +263,19 @@ class SignInSettingsViewModel( _uiState.update { it.copy(passwordPrompt = provider, passwordError = null, message = null, error = null) } } - /** Cancel also stops a ticket or identity check still under way, so no browser opens once the prompt is gone. */ - fun onDismissPasswordPrompt() { + fun onDismissPasswordPrompt() = dismissPrompt { it.copy(passwordPrompt = null) } + + /** + * Cancel also stops the prompt's work still under way: no browser opens + * and nothing reports back once the prompt is gone. A link the server + * made before the cancel shows when the section reloads. + */ + private fun dismissPrompt(close: (SignInSettingsUiState) -> SignInSettingsUiState) { val stopped = connectJob?.isActive == true connectJob?.cancel() connectJob = null - _uiState.update { it.copy(passwordPrompt = null, passwordError = null, busy = it.busy && !stopped) } + _uiState.update { close(it).copy(passwordError = null, busy = it.busy && !stopped) } + if (stopped) refresh() } /** @@ -338,6 +416,7 @@ class SignInSettingsViewModel( val connectable: List, val directory: SignInProvider?, val canUnlink: Boolean? = null, + val network: SignInProvider? = null, ) companion object { @@ -359,6 +438,16 @@ class SignInSettingsViewModel( "Confirm this account's password, then enter your $providerName username and password. " + "After connecting, you sign in with your $providerName username and password instead." + /** + * The password prompt for a network provider. [owner] is who the + * provider says owns this device, the person being connected. + */ + fun connectNetworkPrompt(providerName: String, owner: String?): String { + val who = owner?.let { "$it on $providerName" } ?: "the $providerName account this device uses" + return "Enter this account's password to connect $who. " + + "After connecting, you sign in with $providerName instead of your password." + } + /** * Linking turns the account's own password off, so Disconnect can't * promise it unless the server said up front ([canUnlink]) that the @@ -385,7 +474,9 @@ class SignInSettingsViewModel( * OAuth provider is connectable through the browser when the OAuth * handshake also serves app linking, a directory when the external * sign-in document serves [credentialsLinking] (directory linking - * needs no handshake). + * needs no handshake). A network provider is connectable when the + * document serves [networkSignIn] and the server listed one, which + * it does only to a device on that provider's network. */ fun sectionOf( identitiesServed: Boolean, @@ -394,6 +485,7 @@ class SignInSettingsViewModel( handshake: OAuthHandshakeCapabilities, credentialsLinking: Boolean, canUnlink: Boolean? = null, + networkSignIn: Boolean = false, ): Section { if (!identitiesServed) return Section(emptyList(), emptyList(), null) val linked = identities.map { it.installationId }.toSet() @@ -401,7 +493,8 @@ class SignInSettingsViewModel( val options = SignInOptions.of(SignInProviders(unlinked, passwordLogin = false), handshake) val connectable = if (handshake.linking) options.oauthProviders else emptyList() val directory = options.directoryProvider.takeIf { credentialsLinking } - return Section(identities, connectable, directory, canUnlink.takeIf { identities.isNotEmpty() }) + val network = options.networkProvider.takeIf { networkSignIn } + return Section(identities, connectable, directory, canUnlink.takeIf { identities.isNotEmpty() }, network) } fun providerLabel(identity: AccountIdentity): String = @@ -413,7 +506,7 @@ class SignInSettingsViewModel( private fun noLocalPassword(providerName: String) = "This account has no password to confirm with. Ask an admin to connect it to $providerName." - private fun noLongerAvailable(providerName: String) = "$providerName is no longer available on this server." + private fun noLongerAvailable(providerName: String) = ProviderRefusalText.providerGone(providerName) /** Text for a refused link ticket (`createAccountIdentityLinkTicket`). */ fun linkTicketMessage(error: ApiResult.Error, providerName: String): String = when { @@ -442,6 +535,25 @@ class SignInSettingsViewModel( } } + /** + * Text for a refused network link (`linkAccountIdentityWithNetwork`), + * by problem code: the contract names one for every refusal. + */ + fun networkLinkMessage(error: ApiResult.Error, providerName: String): String = when (error.error) { + DefaultExternalSignInApi.WRONG_PASSWORD -> "That password is incorrect." + "local_password_required" -> noLocalPassword(providerName) + else -> when (NetworkSignInFailure.of(error.code, error.error)) { + NetworkSignInFailure.NetworkIdentityRequired -> ProviderRefusalText.addressRequired(providerName, "connect $providerName") + NetworkSignInFailure.NotPermitted -> ProviderRefusalText.notPermitted(providerName) + NetworkSignInFailure.NotFound -> ProviderRefusalText.providerGone(providerName) + else -> if (error.code == 422) { + CHECK_ENTRIES + } else { + NativeSignInMessages.forProblem(error, providerName) ?: "Couldn't connect $providerName. Try again." + } + } + } + /** Text for a refused disconnect (`deleteAccountIdentity`). */ fun disconnectMessage(error: ApiResult.Error, identity: AccountIdentity): String = when { error.error == "last_sign_in_method" || error.code == 409 -> diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt index 6265c6e40c..4bf38bfd3a 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/NativeSignInCompleterTest.kt @@ -173,6 +173,9 @@ class NativeSignInCompleterTest { username: String, directoryPassword: String, ): ApiResult = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult = TODO() + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() } /** diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt index 757bb46e73..400710a79c 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginViewModelSsoTest.kt @@ -8,6 +8,7 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respondError import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,6 +35,7 @@ import org.siloserver.silo.model.auth.AccountIdentity import org.siloserver.silo.model.auth.AccountIdentityLinkTicket import org.siloserver.silo.model.auth.ExternalSignInCapabilities import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.NetworkIdentity import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities import org.siloserver.silo.model.auth.SignInProvider import org.siloserver.silo.model.auth.SignInProviders @@ -75,6 +77,11 @@ class LoginViewModelSsoTest { "/api/v2/auth/oauth/5/native/start", ) private val native = OAuthHandshakeCapabilities(available = true, native = true, linking = true, selectAccount = true) + private val tailscale = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) private class Fixture( scope: CoroutineScope, @@ -89,6 +96,13 @@ class LoginViewModelSsoTest { val coordinator = NativeSignInCoordinator(store, SignsIn(), scope, InMemoryAccountChoiceStore()) private val http = HttpClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }) val authRepository = AuthRepository(AuthApi(http, ApiV2Gate.Unrestricted), tokens) + + /** What the network sign-in answers, and where each one was sent (saved base, path). */ + var networkAnswer: ApiResult = ApiResult.Error(403, "network_identity_required", "") + val networkCalls = mutableListOf>() + + /** When set, the network sign-in answers only once this completes. */ + var networkGate: CompletableDeferred? = null val api = object : ExternalSignInApi { override suspend fun listProviders(serverUrl: String) = providers override suspend fun oauthCapabilities(serverUrl: String) = handshake @@ -106,6 +120,13 @@ class LoginViewModelSsoTest { username: String, directoryPassword: String, ): ApiResult = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult { + networkCalls += serverUrl to signInPath + networkGate?.await() + return networkAnswer + } + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() } val identityApi = object : ServerIdentityApi { override suspend fun probeIdentity(serverUrl: String) = identity @@ -431,6 +452,139 @@ class LoginViewModelSsoTest { vm.close(); f.close() } + // --- Network identity ("Continue as …") --- + + /** + * Opened at its Tailscale address, the server lists Tailscale with the + * device's owner: one tap signs in, with no browser and no password, on + * the saved base (path prefix included), and the session lands like a + * password sign-in's. + */ + @Test + fun continueAsSignsInWithoutABrowserOnTheSavedBase() = runTest(dispatcher) { + val f = fixture(serverUrl = "https://silo.tailnet.ts.net/silo", providers = listOf(keycloak, tailscale)) + f.networkAnswer = ApiResult.Success(LoginResponse("net-access", "net-refresh", 3600, User("1", "alice", "a@example.test", "user"))) + val vm = f.viewModel() + advanceUntilIdle() + assertEquals(tailscale, vm.uiState.value.networkProvider) + assertEquals(listOf(keycloak), vm.uiState.value.providers, "the network provider is not a browser sign-in") + assertTrue(vm.uiState.value.showPasswordForm, "the password form stays") + assertEquals("Continue as Alice Example", continueAsLabel(tailscale)) + assertEquals("via Tailscale", continueViaLabel(tailscale)) + + vm.onNetworkSignIn() + assertTrue(vm.uiState.value.networkSignInBusy) + advanceUntilIdle() + assertEquals(listOf("https://silo.tailnet.ts.net/silo" to "/api/v2/auth/network/7/sign-in"), f.networkCalls) + assertTrue(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.networkSignInBusy) + assertNull(vm.uiState.value.error) + assertEquals("net-access", f.tokens.getAccessToken()) + assertEquals("net-refresh", f.tokens.getRefreshToken()) + assertNull(vm.uiState.value.browserLaunch) + assertNull(f.store.load(), "no native flow was started") + vm.close(); f.close() + } + + @Test + fun aRefusedNetworkSignInSaysWhyAndSavesNothing() = runTest(dispatcher) { + val f = fixture(providers = listOf(tailscale)) + f.networkAnswer = ApiResult.Error(403, "network_identity_required", "raw detail") + val vm = f.viewModel() + advanceUntilIdle() + vm.onNetworkSignIn() + advanceUntilIdle() + assertEquals("Open this server at its Tailscale address to sign in this way.", vm.uiState.value.error) + assertFalse(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.networkSignInBusy, "the button can be pressed again") + assertNull(f.tokens.getAccessToken()) + + f.networkAnswer = ApiResult.NetworkError(RuntimeException("offline")) + vm.onNetworkSignIn() + advanceUntilIdle() + assertEquals("Network error. Please check your connection.", vm.uiState.value.error) + assertEquals(2, f.networkCalls.size) + vm.close(); f.close() + } + + /** Reloading the options while "Continue as …" waits doesn't free the other ways in. */ + @Test + fun theOtherWaysInWaitForContinueAsAcrossAnOptionsReload() = runTest(dispatcher) { + val f = fixture(providers = listOf(keycloak, tailscale)) + val answer = CompletableDeferred() + f.networkGate = answer + val vm = f.viewModel() + advanceUntilIdle() + vm.onNetworkSignIn() + advanceUntilIdle() + + vm.loadOptions() + vm.onUsernameChanged("alice") + vm.onPasswordChanged("pw") + vm.onLoginClick() + assertTrue(vm.uiState.value.networkSignInBusy) + assertTrue(vm.uiState.value.signInBusy, "the password form is disabled while it waits") + assertFalse(vm.uiState.value.isLoading, "no password sign-in starts while the options reload") + advanceUntilIdle() + vm.onProviderClick(keycloak) + assertNull(vm.uiState.value.providerBusy, "no browser sign-in starts either") + + answer.complete(Unit) + advanceUntilIdle() + assertFalse(vm.uiState.value.networkSignInBusy) + assertFalse(vm.uiState.value.signInBusy) + assertEquals(1, f.networkCalls.size) + vm.close(); f.close() + } + + @Test + fun noContinueAsWithoutANetworkProvider() = runTest(dispatcher) { + val f = fixture(providers = listOf(keycloak, tailscale.copy(networkSignInPath = null))) + val vm = f.viewModel() + advanceUntilIdle() + assertNull(vm.uiState.value.networkProvider) + vm.onNetworkSignIn() + advanceUntilIdle() + assertTrue(f.networkCalls.isEmpty()) + vm.close(); f.close() + } + + @Test + fun theButtonNamesTheProviderWhenItNamesNobody() { + val nobody = tailscale.copy(networkIdentity = NetworkIdentity("", "")) + assertEquals("Continue with Tailscale", continueAsLabel(nobody)) + assertNull(continueViaLabel(nobody)) + assertEquals("Continue as alice", continueAsLabel(tailscale.copy(networkIdentity = NetworkIdentity("", "alice")))) + } + + @Test + fun networkSignInRefusalsHaveTheirOwnText() { + val cases = listOf( + 403 to "network_identity_required", + 403 to "not_permitted", + 403 to "account_required", + 403 to "permission_denied", + 409 to "email_in_use", + 409 to "identity_linked_elsewhere", + 404 to "not_found", + 503 to "provider_unavailable", + 429 to "rate_limited", + 500 to "internal", + ) + val texts = cases.map { (status, code) -> networkSignInMessage(ApiResult.Error(status, code, "server detail"), "Tailscale") } + assertEquals(texts.size, texts.toSet().size, texts.joinToString("\n")) + texts.forEach { assertFalse(it.contains("server detail"), "the server's detail is never shown") } + assertEquals("Tailscale doesn't allow this device on this server.", texts[1]) + assertEquals( + "An account with your email already exists. Sign in with your password, then connect Tailscale in Settings → Sign-in.", + texts[4], + ) + // Refusals other operations share read the same here. + assertEquals(NativeSignInMessages.forReason(NativeSignInMessages.ACCOUNT_REQUIRED), texts[2]) + assertEquals(NativeSignInMessages.forReason(NativeSignInMessages.RATE_LIMITED), texts[8]) + assertEquals(NativeSignInMessages.forReason("identity_linked_elsewhere", "Tailscale"), texts[5]) + } + @Test fun passwordRefusalsFromADirectoryHaveTheirOwnText() { assertEquals( diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt index 81de2ee820..60f74939da 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt @@ -1,11 +1,49 @@ package org.siloserver.silo.android.ui.screens.settings +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.siloserver.silo.android.auth.InMemoryAccountChoiceStore +import org.siloserver.silo.android.auth.InMemoryPendingNativeSignInStore +import org.siloserver.silo.android.auth.NativeSignInCompleter +import org.siloserver.silo.android.auth.NativeSignInCoordinator +import org.siloserver.silo.android.auth.PendingNativeSignIn import org.siloserver.silo.android.ui.screens.auth.passwordLoginMessage +import org.siloserver.silo.android.ui.screens.auth.networkSignInMessage +import org.siloserver.silo.model.auth.AccountIdentities import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.AccountIdentityLinkTicket +import org.siloserver.silo.model.auth.ExternalSignInCapabilities +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.NetworkIdentity import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.model.auth.User +import org.siloserver.silo.model.server.ServerEntry import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl import org.siloserver.silo.network.api.DefaultExternalSignInApi +import org.siloserver.silo.network.api.ExternalSignInApi +import org.siloserver.silo.network.api.ServerConnections +import org.siloserver.silo.network.api.ServerIdentityApi +import org.siloserver.silo.network.api.ServerIdentityProbe +import org.siloserver.silo.repository.ExternalSignInRepository +import org.siloserver.silo.repository.ServerIdentityRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -21,6 +59,65 @@ class SignInSettingsTest { ) private val identity = AccountIdentity("4", "5", "plugin:5:oidc", "Keycloak", "alice", "", "", "2026-01-02T03:04:05Z", null) private val all = OAuthHandshakeCapabilities(available = true, native = true, linking = true) + private val tailscale = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + + /** Offered only where the server serves network sign-in and listed the provider (over its network); no OAuth handshake needed. */ + @Test + fun aNetworkProviderIsConnectableOnlyWhereNetworkSignInIsServed() { + val offered = SignInSettingsViewModel.sectionOf( + true, emptyList(), listOf(local, tailscale), OAuthHandshakeCapabilities.None, credentialsLinking = false, networkSignIn = true, + ) + assertEquals(tailscale, offered.network) + assertNull(offered.directory, "a network provider is not a directory") + assertTrue(offered.connectable.isEmpty(), "nor a browser sign-in") + assertNull(SignInSettingsViewModel.sectionOf(true, emptyList(), listOf(tailscale), all, credentialsLinking = true).network) + val linked = identity.copy(installationId = "7", providerName = "Tailscale") + assertNull( + SignInSettingsViewModel.sectionOf(true, listOf(linked), listOf(tailscale), all, credentialsLinking = true, networkSignIn = true).network, + "already connected", + ) + assertNull(SignInSettingsViewModel.sectionOf(false, emptyList(), listOf(tailscale), all, credentialsLinking = true, networkSignIn = true).network) + assertTrue(SignInSettingsUiState(loading = false, network = tailscale).visible) + } + + @Test + fun networkLinkRefusalsHaveTheirOwnText() { + val codes = listOf( + 422 to DefaultExternalSignInApi.WRONG_PASSWORD, + 403 to "network_identity_required", + 409 to "local_password_required", + 403 to "not_permitted", + 403 to "permission_denied", + 409 to "identity_linked_elsewhere", + 409 to "already_linked", + 404 to "not_found", + 503 to "provider_unavailable", + 429 to "rate_limited", + ) + val texts = codes.map { (status, code) -> + SignInSettingsViewModel.networkLinkMessage(ApiResult.Error(status, code, "server detail"), "Tailscale") + } + assertEquals(texts.size, texts.toSet().size, texts.joinToString("\n")) + texts.forEach { assertFalse(it.contains("server detail"), "the server's detail is never shown") } + assertEquals("Open this server at its Tailscale address to connect Tailscale.", texts[1]) + assertEquals("Tailscale doesn't allow this device on this server.", texts[3]) + // The network refusals read as they do on the sign-in screen. + assertEquals(networkSignInMessage(ApiResult.Error(403, "not_permitted", ""), "Tailscale"), texts[3]) + assertEquals(networkSignInMessage(ApiResult.Error(404, "not_found", ""), "Tailscale"), texts[7]) + // Shared refusals read as in the other links. + assertEquals(SignInSettingsViewModel.directoryLinkMessage(ApiResult.Error(409, "identity_linked_elsewhere", ""), "Tailscale"), texts[5]) + } + + @Test + fun theNetworkPromptNamesWhoIsConnected() { + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", "Alice Example").contains("connect Alice Example on Tailscale")) + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null).contains("the Tailscale account this device uses")) + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null).contains("instead of your password")) + } @Test fun aServerWithoutIdentitiesShowsNoSection() { @@ -153,3 +250,226 @@ class SignInSettingsTest { assertTrue(passwordLoginMessage(ApiResult.Error(503, "provider_unavailable", "")).contains("can't be reached")) } } + +/** "Connect " in the account's Sign-in settings: the password-only link. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SignInSettingsNetworkLinkTest { + private val dispatcher = StandardTestDispatcher() + + @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest fun tearDown() = Dispatchers.resetMain() + + private val scope = AuthScopeSnapshot("a", "p1", "https://silo.tailnet.ts.net", null, identityGeneration = 1) + private val tailscale = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + private val linkedIdentity = AccountIdentity( + "8", "7", "plugin:7:tailscale", "Tailscale", "alice@example.test", "alice@example.test", "Alice Example", + "2026-01-02T03:04:05.000Z", null, + ) + + private class Api(private val provider: SignInProvider, private val linked: AccountIdentity) : ExternalSignInApi { + var identities = emptyList() + var linkAnswer: ApiResult = ApiResult.Success(linked) + val links = mutableListOf>() + + /** When set, the link answers only once this completes. */ + var linkGate: CompletableDeferred? = null + + override suspend fun listProviders(serverUrl: String): ApiResult = + ApiResult.Success(SignInProviders(listOf(provider), passwordLogin = true)) + override suspend fun oauthCapabilities(serverUrl: String): ApiResult = + ApiResult.Error(404, "not_found", "") + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot): ApiResult = + ApiResult.Success(ExternalSignInCapabilities(identities = true, networkSignIn = true)) + /** Each read takes the next gate, if any, and answers with the identities as they were when asked. */ + val identityGates = ArrayDeque>() + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult { + val asked = identities + identityGates.removeFirstOrNull()?.await() + return ApiResult.Success(AccountIdentities(asked, canUnlink = null)) + } + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String): ApiResult { + links += Triple(scope, installationId, password) + linkGate?.await() + return linkAnswer.also { if (it is ApiResult.Success) identities = listOf(it.data) } + } + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult = TODO() + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String): ApiResult = TODO() + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String): + ApiResult = TODO() + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String): ApiResult = TODO() + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + username: String, + directoryPassword: String, + ): ApiResult = TODO() + } + + /** Signed in on saved server "a". */ + private inner class SignedIn : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + override suspend fun loginSessionId(serverId: String): String = "login-1" + } + + private object NoBrowser : NativeSignInCompleter { + override suspend fun signIn(pending: PendingNativeSignIn, code: String): ApiResult = error("no browser flow here") + override suspend fun link(pending: PendingNativeSignIn, code: String): ApiResult = error("no browser flow here") + } + + private class OneServer(private val entry: ServerEntry) : ServerRegistry { + override val entries: StateFlow> = MutableStateFlow(listOf(entry)) + override val activeServerId = MutableStateFlow(entry.id) + override val activeEntry: StateFlow = MutableStateFlow(entry) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = entry.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + @Test + fun connectingAsksForThePasswordOnlyAndShowsTheLinkedIdentity() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val registry = OneServer(ServerEntry(id = "a", url = "https://silo.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + externalSignIn = ExternalSignInRepository(api), + tokenManager = SignedIn(), + serverRegistry = registry, + serverIdentities = ServerIdentityRepository(registry, identityApi), + nativeSignIn = NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + assertEquals(tailscale, vm.uiState.value.network) + assertTrue(vm.uiState.value.visible) + + vm.onConnectNetwork(tailscale) + assertEquals(tailscale, vm.uiState.value.networkPrompt) + vm.onConfirmNetwork("") + assertEquals("Enter your password.", vm.uiState.value.passwordError) + assertTrue(api.links.isEmpty()) + + // A wrong password keeps the prompt open to try again. + api.linkAnswer = ApiResult.Error(422, DefaultExternalSignInApi.WRONG_PASSWORD, "") + vm.onConfirmNetwork("wrong") + advanceUntilIdle() + assertEquals("That password is incorrect.", vm.uiState.value.passwordError) + assertEquals(tailscale, vm.uiState.value.networkPrompt) + assertEquals(false, vm.uiState.value.busy) + + api.linkAnswer = ApiResult.Success(linkedIdentity) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertEquals(Triple(scope, "7", "local-pw"), api.links.last()) + assertNull(vm.uiState.value.networkPrompt) + assertEquals("Connected Tailscale.", vm.uiState.value.message) + assertEquals(listOf(linkedIdentity), vm.uiState.value.identities) + assertNull(vm.uiState.value.network, "connected: not offered again") + vm.viewModelScope.cancel() + } + + @Test + fun aRefusalThatTypingWontFixClosesThePrompt() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val registry = OneServer(ServerEntry(id = "a", url = "https://silo.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + ExternalSignInRepository(api), SignedIn(), registry, ServerIdentityRepository(registry, identityApi), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + vm.onConnectNetwork(tailscale) + api.linkAnswer = ApiResult.Error(403, "network_identity_required", "") + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertNull(vm.uiState.value.networkPrompt) + assertEquals("Open this server at its Tailscale address to connect Tailscale.", vm.uiState.value.error) + assertTrue(vm.uiState.value.identities.isEmpty()) + vm.viewModelScope.cancel() + } + + /** Cancel while "Checking…" stops the link: nothing reports back, and the section shows what the server has. */ + @Test + fun cancellingTheNetworkPromptStopsTheLink() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val gate = CompletableDeferred() + api.linkGate = gate + val registry = OneServer(ServerEntry(id = "a", url = "https://silo.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + ExternalSignInRepository(api), SignedIn(), registry, ServerIdentityRepository(registry, identityApi), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + vm.onConnectNetwork(tailscale) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertTrue(vm.uiState.value.busy) + assertEquals(1, api.links.size) + + vm.onDismissNetworkPrompt() + assertNull(vm.uiState.value.networkPrompt) + assertFalse(vm.uiState.value.busy, "the section can be used again") + gate.complete(Unit) + advanceUntilIdle() + assertNull(vm.uiState.value.message, "a cancelled link doesn't report \"Connected\"") + assertNull(vm.uiState.value.error) + assertEquals(tailscale, vm.uiState.value.network, "the server made no link: still offered") + vm.viewModelScope.cancel() + } + + /** The reload after a cancelled link answers late: it doesn't undo the link made after it. */ + @Test + fun aLateReloadDoesNotOverwriteANewerOne() = runTest(dispatcher) { + val api = Api(tailscale, linkedIdentity) + val registry = OneServer(ServerEntry(id = "a", url = "https://silo.tailnet.ts.net")) + val identityApi = object : ServerIdentityApi { + override suspend fun probeIdentity(serverUrl: String) = ServerIdentityProbe.Identity("srv-1") + override suspend fun connections(scope: AuthScopeSnapshot): ServerConnections? = null + } + val vm = SignInSettingsViewModel( + ExternalSignInRepository(api), SignedIn(), registry, ServerIdentityRepository(registry, identityApi), + NativeSignInCoordinator(InMemoryPendingNativeSignInStore(), NoBrowser, backgroundScope, InMemoryAccountChoiceStore()), + ) + advanceUntilIdle() + api.linkGate = CompletableDeferred() + vm.onConnectNetwork(tailscale) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + val lateReload = CompletableDeferred() + api.identityGates += lateReload + vm.onDismissNetworkPrompt() + advanceUntilIdle() + + api.linkGate = null + vm.onConnectNetwork(tailscale) + vm.onConfirmNetwork("local-pw") + advanceUntilIdle() + assertEquals(listOf(linkedIdentity), vm.uiState.value.identities) + + lateReload.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(linkedIdentity), vm.uiState.value.identities, "the older read is dropped") + assertNull(vm.uiState.value.network, "connected: not offered again") + vm.viewModelScope.cancel() + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt index 0aa09318b0..d3dc335d7f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt @@ -221,7 +221,11 @@ fun Modifier.auroraPanel(cornerRadius: Dp = 20.dp): Modifier { ) } -/** Warm cream pill with a gold focus glow — the Aurora primary action. */ +/** + * Warm cream pill with a gold focus glow — the Aurora primary action. + * [supportingText], when given, is a smaller second line under [label] + * ("Continue as Alice" / "via Tailscale"); both read as one control. + */ @Composable fun AuroraPrimaryButton( label: String, @@ -233,6 +237,7 @@ fun AuroraPrimaryButton( filledAtRest: Boolean = true, neutralFocusFill: Boolean = false, enabled: Boolean = true, + supportingText: String? = null, ) { var isFocused by remember { mutableStateOf(false) } val shape = RoundedCornerShape(14.dp) @@ -285,7 +290,8 @@ fun AuroraPrimaryButton( enabled = enabled, onClick = onClick, ) - .padding(horizontal = 30.dp, vertical = 18.dp), + // A second line trades vertical padding, so both pills stand about as tall. + .padding(horizontal = 30.dp, vertical = if (supportingText != null) 10.dp else 18.dp), contentAlignment = Alignment.Center, ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { @@ -297,12 +303,22 @@ fun AuroraPrimaryButton( modifier = Modifier.size(24.dp), ) } - Text( - text = label, - color = contentColor.copy(alpha = enabledAlpha), - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - ) + Column { + Text( + text = label, + color = contentColor.copy(alpha = enabledAlpha), + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + ) + if (supportingText != null) { + Text( + text = supportingText, + color = contentColor.copy(alpha = enabledAlpha * 0.72f), + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + ) + } + } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt index 4d67ae425e..916338f861 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt @@ -86,6 +86,7 @@ import org.siloserver.silo.common.pairing.PairingReceiver import org.siloserver.silo.common.pairing.PairingReceiverStatus import org.siloserver.silo.common.pairing.TvPairingAdvertiser import org.siloserver.silo.model.auth.DeviceCodeFormat +import org.siloserver.silo.model.auth.SignInProvider import org.siloserver.silo.tv.R import org.siloserver.silo.tv.ui.components.AuroraEyebrow import org.siloserver.silo.tv.ui.components.AuroraGhostButton @@ -111,7 +112,9 @@ import org.siloserver.silo.tv.ui.theme.Spacing * `/activate` and the code, or open Silo on a nearby phone — with the * password one click away. One copy deck with Apple TV (server spec "TV * device sign-in UX"). The code renews itself while the screen is visible; - * there is no countdown. + * there is no countdown. When the TV reached the server through a network + * provider such as Tailscale, "Continue as " leads and takes focus: + * one press signs in, and every other way in stays. * * TOP-anchored so the username/password fields stay above the on-screen IME: * on Android TV the soft keyboard eats the lower half of the viewport. @@ -141,6 +144,7 @@ fun TvLoginScreen( val createAccountFocus = remember { FocusRequester() } val backToPhoneFocus = remember { FocusRequester() } val formChangeServerFocus = remember { FocusRequester() } + val networkFocus = remember { FocusRequester() } val formScrollState = rememberTvImeAwareFormScrollState() // Phone-first IA (mirrors tvOS TVLoginView): the device code leads, and the @@ -219,13 +223,26 @@ fun TvLoginScreen( BackHandler(enabled = passwordFormVisible && !device.passwordOnly && !isActivePairing, onBack = backToPhone) BackHandler(enabled = isActivePairing, onBack = pairingReceiver::cancelActiveSession) - // Focus moves to the state's action when the code needs the person - // ("Try again" / "Show a new code"), to "Change server" when only that - // helps, otherwise to the one local action, "Sign in with a password". - // "Can't reach" and "Too many requests" keep retrying by themselves, so - // their "Try again" doesn't take focus (Apple TV parity). + // A server without device sign-in opens on the password form, so + // "Continue as …" shows there too, above the fields (Apple TV parity). + // It has its own focus key there, so focus is claimed again when the + // button moves from the code screen into the form. + val networkInForm = device.passwordOnly && state.networkProvider != null + val networkKey = if (passwordFormVisible) "formNetwork" else "network" + // "Continue as …" is the one-press way in, so it takes focus whenever the + // server offers it, unless it was refused and the code needs the person: + // then that recovery action leads again. + val networkLeads = state.networkProvider != null && + (state.networkError == null || !device.status.actionTakesFocus()) + + // After "Continue as …", focus moves to the state's action when the code + // needs the person ("Try again" / "Show a new code"), to "Change server" + // when only that helps, otherwise to the one local action, "Sign in with a + // password". "Can't reach" and "Too many requests" keep retrying by + // themselves, so their "Try again" doesn't take focus (Apple TV parity). val focusTarget = when { - passwordFormVisible -> "username" + passwordFormVisible -> if (networkInForm && networkLeads) networkKey else "username" + networkLeads -> networkKey device.status.actionTakesFocus() -> "action" device.status == TvSignInStatus.UpdateRequired -> "changeServer" device.status == TvSignInStatus.Unreachable || device.status == TvSignInStatus.TooManyRequests -> null @@ -252,6 +269,7 @@ fun TvLoginScreen( } val target = when (focusTarget) { "username" -> usernameFocus + "network", "formNetwork" -> networkFocus "action" -> actionFocus "changeServer" -> changeServerFocus else -> usePasswordFocus @@ -271,6 +289,16 @@ fun TvLoginScreen( onChangeServer() } val serverName = state.serverName ?: state.serverHost.orEmpty() + val network = state.networkProvider?.let { provider -> + NetworkSignInAction( + provider = provider, + busy = state.networkBusy, + error = state.networkError?.message(providerName = null, externalName = provider.displayName), + focusRequester = networkFocus, + focusTracking = trackFocus(networkKey), + onClick = viewModel::onNetworkSignInClick, + ) + } Box( modifier = Modifier @@ -366,6 +394,7 @@ fun TvLoginScreen( onCreateAccount = onCreateAccount, onBackToPhone = backToPhone, onChangeServer = changeServer, + network = network.takeIf { networkInForm }, modifier = Modifier .align(Alignment.CenterHorizontally) .width(520.dp), @@ -383,9 +412,12 @@ fun TvLoginScreen( changeServerFocus = changeServerFocus, usePasswordTracking = trackFocus("usePassword"), changeServerTracking = trackFocus("changeServer"), - onUsePassword = { showPasswordForm = true }, + // The form would hide "Signing in…" while "Continue as …" + // waits, and its Sign in would do nothing until then. + onUsePassword = { if (!state.networkBusy) showPasswordForm = true }, onChangeServer = changeServer, showPasswordAction = state.passwordAvailable, + network = network, modifier = Modifier.weight(1f), ) DeviceCodePanel( @@ -444,8 +476,23 @@ private fun StatusBanner(text: String) { } /** - * Left column: title, the three ways in, the nearby-phone hint, and the two - * local actions. Mirrors tvOS `TVLoginView.heroColumn`. + * "Continue as " through a network provider, for [SignInHero] and, on + * a server without device sign-in, [CredentialFormCard]. [error] is why the + * last press was refused. + */ +private class NetworkSignInAction( + val provider: SignInProvider, + val busy: Boolean, + val error: String?, + val focusRequester: FocusRequester, + val focusTracking: Modifier, + val onClick: () -> Unit, +) + +/** + * Left column: title, "Continue as " when a network provider offers + * it, the three ways in, the nearby-phone hint, and the two local actions. + * Mirrors tvOS `TVLoginView.heroColumn`. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -460,6 +507,7 @@ private fun SignInHero( onChangeServer: () -> Unit, modifier: Modifier = Modifier, showPasswordAction: Boolean = true, + network: NetworkSignInAction? = null, ) { Column( verticalArrangement = Arrangement.spacedBy(Spacing.sm), @@ -471,6 +519,13 @@ private fun SignInHero( style = TvLoginTextStyles.Hero, color = MaterialTheme.colorScheme.onBackground, ) + if (network != null) { + NetworkSignInButton( + action = network, + // The steps between hold nothing focusable: name the way down. + below = if (showPasswordAction) usePasswordFocus else changeServerFocus, + ) + } AuroraStepRow(number = 1, text = stringResource(R.string.tv_signin_step_scan)) AuroraStepRow(number = 2, text = stringResource(R.string.tv_signin_step_url, activateText)) AuroraStepRow(number = 3, text = stringResource(R.string.tv_signin_step_approve)) @@ -490,7 +545,10 @@ private fun SignInHero( modifier = Modifier .then(usePasswordTracking) .focusRequester(usePasswordFocus) - .focusProperties { right = changeServerFocus }, + .focusProperties { + right = changeServerFocus + if (network != null) up = network.focusRequester + }, ) } AuroraGhostButton( @@ -502,12 +560,49 @@ private fun SignInHero( modifier = Modifier .then(changeServerTracking) .focusRequester(changeServerFocus) - .focusProperties { if (showPasswordAction) left = usePasswordFocus }, + .focusProperties { + if (showPasswordAction) left = usePasswordFocus + if (network != null) up = network.focusRequester + }, ) } } } +/** + * "Continue as " with "via " under it, or "Continue with + * " when the provider named nobody: the screen's primary action + * when offered, with its refusal under it. It stays focusable while signing + * in so focus doesn't jump; the view model ignores a second press. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun NetworkSignInButton(action: NetworkSignInAction, below: FocusRequester) { + val providerName = action.provider.displayName + val owner = action.provider.networkIdentity?.label + AuroraPrimaryButton( + label = when { + action.busy -> stringResource(R.string.tv_signin_form_submitting) + owner != null -> stringResource(R.string.tv_signin_network_continue_as, owner) + else -> stringResource(R.string.tv_signin_network_continue_with, providerName) + }, + supportingText = owner?.let { stringResource(R.string.tv_signin_network_via, providerName) }, + onClick = action.onClick, + focusRequester = action.focusRequester, + modifier = Modifier + .then(action.focusTracking) + .focusProperties { down = below }, + ) + action.error?.let { error -> + Text( + text = error, + style = TvLoginTextStyles.Error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } +} + /** * Right panel: the QR (at least a third of the screen height, dark on white, * 4-module quiet zone), the code in large monospace grouped 4+4, the typed-URL @@ -673,37 +768,40 @@ private fun BrandHeader() { /** * [providerName] is the server's browser sign-in provider: an account that * signs in with it has no password here, so a refused password points to the - * phone. [directoryName] names the directory (LDAP) the form also reaches. + * phone. [externalName] names the provider the refused attempt went through: + * the directory (LDAP) the form also reaches, or the network provider behind + * "Continue as …". */ @Composable -private fun TvLoginError.message(providerName: String?, directoryName: String?): String = when (this) { - TvLoginError.UsernameRequired -> stringResource(R.string.tv_signin_error_username_required) - TvLoginError.PasswordRequired -> stringResource(R.string.tv_signin_error_password_required) - TvLoginError.InvalidCredentials -> if (providerName != null) { - stringResource(R.string.tv_signin_error_invalid_credentials_provider, providerName) - } else { - stringResource(R.string.tv_signin_error_invalid_credentials) +private fun TvLoginError.message(providerName: String?, externalName: String?): String { + val external = externalName ?: stringResource(R.string.tv_signin_provider_fallback) + return when (this) { + TvLoginError.UsernameRequired -> stringResource(R.string.tv_signin_error_username_required) + TvLoginError.PasswordRequired -> stringResource(R.string.tv_signin_error_password_required) + TvLoginError.InvalidCredentials -> if (providerName != null) { + stringResource(R.string.tv_signin_error_invalid_credentials_provider, providerName) + } else { + stringResource(R.string.tv_signin_error_invalid_credentials) + } + TvLoginError.EmailInUse -> stringResource(R.string.tv_signin_error_email_in_use, external) + TvLoginError.IdentityLinkedElsewhere -> stringResource(R.string.tv_signin_error_identity_linked_elsewhere, external) + TvLoginError.RateLimited -> stringResource(R.string.tv_signin_error_rate_limited) + TvLoginError.AccountRequired -> stringResource(R.string.tv_signin_error_account_required) + TvLoginError.AccountDisabled -> stringResource(R.string.tv_signin_error_account_disabled) + TvLoginError.Network -> stringResource(R.string.tv_signin_error_network) + TvLoginError.LocalLoginDisabled -> stringResource(R.string.tv_signin_error_local_login_disabled) + TvLoginError.NotPermitted -> stringResource(R.string.tv_signin_error_not_permitted) + TvLoginError.PasswordExpired -> stringResource(R.string.tv_signin_error_password_expired) + TvLoginError.ProviderUnavailable -> stringResource(R.string.tv_signin_error_provider_unavailable) + TvLoginError.IdentityChanged -> stringResource(R.string.tv_signin_error_identity_changed) + TvLoginError.SaveFailed -> stringResource(R.string.tv_signin_error_save_failed) + TvLoginError.CleanupIncomplete -> stringResource(R.string.tv_signin_error_cleanup_incomplete) + TvLoginError.NetworkIdentityRequired -> stringResource(R.string.tv_signin_error_network_identity_required, external) + TvLoginError.NetworkNotPermitted -> stringResource(R.string.tv_signin_error_network_not_permitted, external) + TvLoginError.NetworkEmailInUse -> stringResource(R.string.tv_signin_error_network_email_in_use, external) + TvLoginError.NetworkProviderGone -> stringResource(R.string.tv_signin_error_network_provider_gone, external) + is TvLoginError.Server -> message ?: stringResource(R.string.tv_signin_error_login_failed) } - TvLoginError.EmailInUse -> stringResource( - R.string.tv_signin_error_email_in_use, - directoryName ?: stringResource(R.string.tv_signin_provider_fallback), - ) - TvLoginError.IdentityLinkedElsewhere -> stringResource( - R.string.tv_signin_error_identity_linked_elsewhere, - directoryName ?: stringResource(R.string.tv_signin_provider_fallback), - ) - TvLoginError.RateLimited -> stringResource(R.string.tv_signin_error_rate_limited) - TvLoginError.AccountRequired -> stringResource(R.string.tv_signin_error_account_required) - TvLoginError.AccountDisabled -> stringResource(R.string.tv_signin_error_account_disabled) - TvLoginError.Network -> stringResource(R.string.tv_signin_error_network) - TvLoginError.LocalLoginDisabled -> stringResource(R.string.tv_signin_error_local_login_disabled) - TvLoginError.NotPermitted -> stringResource(R.string.tv_signin_error_not_permitted) - TvLoginError.PasswordExpired -> stringResource(R.string.tv_signin_error_password_expired) - TvLoginError.ProviderUnavailable -> stringResource(R.string.tv_signin_error_provider_unavailable) - TvLoginError.IdentityChanged -> stringResource(R.string.tv_signin_error_identity_changed) - TvLoginError.SaveFailed -> stringResource(R.string.tv_signin_error_save_failed) - TvLoginError.CleanupIncomplete -> stringResource(R.string.tv_signin_error_cleanup_incomplete) - is TvLoginError.Server -> message ?: stringResource(R.string.tv_signin_error_login_failed) } @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @@ -726,6 +824,7 @@ private fun CredentialFormCard( onBackToPhone: () -> Unit, onChangeServer: () -> Unit, modifier: Modifier = Modifier, + network: NetworkSignInAction? = null, ) { var passwordVisible by remember { mutableStateOf(false) } // Height budget, not taste: this card plus the screen chrome above it has @@ -743,13 +842,25 @@ private fun CredentialFormCard( style = TvLoginTextStyles.Title, color = MaterialTheme.colorScheme.onBackground, ) - if (passwordOnly) { + if (passwordOnly && network == null) { Text( text = stringResource(R.string.tv_signin_password_only), style = TvLoginTextStyles.Body, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + val firstSecondary = when { + signupEnabled -> createAccountFocus + !passwordOnly -> backToPhoneFocus + else -> changeServerFocus + } + // While either sign-in waits, the fields and Sign in are disabled and + // can't take focus: "Continue as …" (which keeps focus while it waits) + // and the secondary actions reach each other directly. + val aboveSecondary = if (state.signingIn && network != null) network.focusRequester else signInFocus + if (network != null) { + NetworkSignInButton(action = network, below = if (state.signingIn) firstSecondary else usernameFocus) + } Column( verticalArrangement = Arrangement.spacedBy(Spacing.xs), @@ -769,7 +880,7 @@ private fun CredentialFormCard( imeAction = ImeAction.Next, showKeyboardOnFocus = false, ), - enabled = !state.isLoading, + enabled = !state.signingIn, textStyle = TvLoginTextStyles.Field, modifier = Modifier .fillMaxWidth() @@ -777,7 +888,8 @@ private fun CredentialFormCard( .semantics { contentType = ContentType.Username } .then(usernameFocusTracking) .tvShowImeOnSelect() - .focusRequester(usernameFocus), + .focusRequester(usernameFocus) + .focusProperties { if (network != null) up = network.focusRequester }, colors = tvOutlinedTextFieldColors(), ) } @@ -803,7 +915,7 @@ private fun CredentialFormCard( trailingIcon = { IconButton( onClick = { passwordVisible = !passwordVisible }, - enabled = !state.isLoading, + enabled = !state.signingIn, ) { Icon( imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, @@ -820,12 +932,12 @@ private fun CredentialFormCard( ), keyboardActions = KeyboardActions( onDone = { - if (canSubmitTvCredentialLogin(state.username, state.password, state.isLoading)) { + if (canSubmitTvCredentialLogin(state.username, state.password, state.signingIn)) { onLoginClick() } }, ), - enabled = !state.isLoading, + enabled = !state.signingIn, textStyle = TvLoginTextStyles.Field, modifier = Modifier .fillMaxWidth() @@ -856,11 +968,6 @@ private fun CredentialFormCard( ) } - val firstSecondary = when { - signupEnabled -> createAccountFocus - !passwordOnly -> backToPhoneFocus - else -> changeServerFocus - } AuroraPrimaryButton( label = stringResource(if (state.isLoading) R.string.tv_signin_form_submitting else R.string.tv_signin_form_submit), icon = Icons.AutoMirrored.Filled.Login, @@ -869,7 +976,7 @@ private fun CredentialFormCard( focusHalo = false, filledAtRest = false, neutralFocusFill = true, - enabled = !state.isLoading, + enabled = !state.signingIn, modifier = Modifier // Explicit chain: the label Texts are not focusable, so there // is no default search to fall back on. @@ -894,7 +1001,7 @@ private fun CredentialFormCard( modifier = Modifier .focusRequester(createAccountFocus) .focusProperties { - up = signInFocus + up = aboveSecondary right = if (passwordOnly) changeServerFocus else backToPhoneFocus } .weight(1f), @@ -911,7 +1018,7 @@ private fun CredentialFormCard( modifier = Modifier .focusRequester(backToPhoneFocus) .focusProperties { - up = signInFocus + up = aboveSecondary if (signupEnabled) left = createAccountFocus right = changeServerFocus } @@ -927,7 +1034,7 @@ private fun CredentialFormCard( modifier = Modifier .focusRequester(changeServerFocus) .focusProperties { - up = signInFocus + up = aboveSecondary left = when { !passwordOnly -> backToPhoneFocus signupEnabled -> createAccountFocus diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt index a909211653..89818be01c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModel.kt @@ -10,6 +10,9 @@ import org.siloserver.silo.pairing.PairingFailureCode import org.siloserver.silo.model.auth.DeviceCodeFormat import org.siloserver.silo.model.auth.DeviceLoginPollResponse import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.NetworkSignInFailure +import org.siloserver.silo.model.auth.SignInProvider import org.siloserver.silo.network.AccountSessionChangedException import org.siloserver.silo.network.AccountSessionExpectation import org.siloserver.silo.network.ApiResult @@ -58,7 +61,28 @@ internal fun tvLoginError(result: ApiResult.Error): TvLoginError = when { } } -/** Why the password form can't proceed; the screen maps each to `strings.xml`. */ +/** The error for a refused network identity sign-in ("Continue as …"). */ +internal fun tvNetworkSignInError(result: ApiResult.Error): TvLoginError = when { + // AuthRepository's code for a sign-in whose account or server changed underneath it. + result.error == "identity_changed" -> TvLoginError.IdentityChanged + else -> when (NetworkSignInFailure.of(result.code, result.error)) { + NetworkSignInFailure.NetworkIdentityRequired -> TvLoginError.NetworkIdentityRequired + NetworkSignInFailure.NotPermitted -> TvLoginError.NetworkNotPermitted + NetworkSignInFailure.EmailInUse -> TvLoginError.NetworkEmailInUse + NetworkSignInFailure.NotFound -> TvLoginError.NetworkProviderGone + NetworkSignInFailure.AccountRequired -> TvLoginError.AccountRequired + NetworkSignInFailure.AccountDisabled -> TvLoginError.AccountDisabled + NetworkSignInFailure.IdentityLinkedElsewhere -> TvLoginError.IdentityLinkedElsewhere + NetworkSignInFailure.ProviderUnavailable -> TvLoginError.ProviderUnavailable + NetworkSignInFailure.RateLimited -> TvLoginError.RateLimited + NetworkSignInFailure.Other -> TvLoginError.Server(result.message.takeIf { it.isNotBlank() }) + } +} + +/** + * Why a sign-in on this screen (the password form, or "Continue as …") can't + * proceed; the screen maps each to `strings.xml`. + */ sealed interface TvLoginError { data object UsernameRequired : TvLoginError data object PasswordRequired : TvLoginError @@ -91,6 +115,18 @@ sealed interface TvLoginError { data object SaveFailed : TvLoginError data object CleanupIncomplete : TvLoginError + /** A network sign-in that didn't come through the provider's network (`network_identity_required`). */ + data object NetworkIdentityRequired : TvLoginError + + /** The network provider refuses this TV (`not_permitted`): a tagged device, or one its policy leaves out. */ + data object NetworkNotPermitted : TvLoginError + + /** A first network sign-in found another account with the same email: connect from that account instead. */ + data object NetworkEmailInUse : TvLoginError + + /** The network provider is no longer enabled on the server (404). */ + data object NetworkProviderGone : TvLoginError + /** The server's own explanation, or null for a generic failure. */ data class Server(val message: String?) : TvLoginError } @@ -123,6 +159,16 @@ data class TvLoginUiState( val providerName: String? = null, /** The directory (LDAP) provider the password form also reaches, when enabled. */ val directoryName: String? = null, + /** + * The network provider (such as Tailscale) the server listed for this + * TV, which it does only when the TV reached it through that provider's + * network: "Continue as " signs in with no code and no password. + */ + val networkProvider: SignInProvider? = null, + /** "Continue as …" is waiting for the server. */ + val networkBusy: Boolean = false, + /** Why the last "Continue as …" was refused; shown with that button. */ + val networkError: TvLoginError? = null, ) { /** * A password was refused because password sign-in is off, and the form @@ -130,6 +176,13 @@ data class TvLoginUiState( * other place the refusal could show. */ val passwordTurnedOff: Boolean get() = error == TvLoginError.LocalLoginDisabled && !passwordAvailable + + /** + * A password or "Continue as …" sign-in is waiting for the server. Neither + * button takes another press until it answers, so the password form's + * fields and Sign in are disabled with it. + */ + val signingIn: Boolean get() = isLoading || networkBusy } /** What the device-code panel shows. */ @@ -172,7 +225,9 @@ data class TvDeviceSignInUi( /** * Two parallel sign-in flows share this ViewModel: * - * 1. **Credential** — [AuthRepository.loginForTokens] then an atomic + * 1. **Credential** — [AuthRepository.loginForTokens] (or, for "Continue as + * " through a network provider such as Tailscale, the network + * identity sign-in, which answers the same token pair) then an atomic * [TokenManager.replaceAccountSession]. * 2. **Device code (QR)** — a [DeviceSignInMachine] shows a code, renews it * while the screen is visible, pauses after about an hour, and names @@ -254,7 +309,7 @@ class TvLoginViewModel( /** [onStop] ran since the last [onStart]: the next start re-reads the sign-in options. */ private var lastDeviceCode: String? = null - /** A device approval that arrived while a password save held the sign-in. */ + /** A device approval that arrived while a password (or network) save held the sign-in. */ private var heldApproval: Pair? = null /** The code on screen now, for a nearby phone; null while none is live. */ @@ -269,7 +324,7 @@ class TvLoginViewModel( fun onPasswordChanged(v: String) = _uiState.update { it.copy(password = v, error = null) } fun onLoginClick() { - if (_uiState.value.isLoading) return + if (_uiState.value.signingIn) return val s = _uiState.value if (s.username.isBlank()) { _uiState.update { it.copy(error = TvLoginError.UsernameRequired) } @@ -281,17 +336,61 @@ class TvLoginViewModel( } _uiState.update { it.copy(isLoading = true, error = null) } + signInWithTokens(TokenSignIn.Password) { expected -> authRepository.loginForTokens(s.username, s.password, expected) } + } + + /** + * "Continue as ": sign in as whoever the server's network provider + * (such as Tailscale) says owns this TV, with no code and no password. + * The session is saved exactly like a password sign-in's, and the code on + * screen is withdrawn once it is. + */ + fun onNetworkSignInClick() { + val s = _uiState.value + val discovery = externalSignIn ?: return + val path = s.networkProvider?.networkSignInPath ?: return + if (s.signingIn) return + _uiState.update { it.copy(networkBusy = true, networkError = null) } + signInWithTokens(TokenSignIn.Network) { expected -> + authRepository.tokensFor(expected) { serverUrl -> discovery.signInWithNetworkIdentity(serverUrl, path) } + } + } + + /** Which button started a [signInWithTokens]: its busy state and errors show there. */ + private enum class TokenSignIn { Password, Network } + + /** + * [attempt]'s button is free again, with [error], when given, shown where + * it was pressed. A null [attempt] is the device code's own handoff: its + * approval cancels whichever sign-in was under way, so both buttons are + * free again, and its error uses the form's slot. + */ + private fun TvLoginUiState.settled(attempt: TokenSignIn?, error: TvLoginError? = null): TvLoginUiState = when (attempt) { + TokenSignIn.Password -> copy(isLoading = false, error = error ?: this.error) + TokenSignIn.Network -> copy(networkBusy = false, networkError = error ?: networkError) + null -> copy(isLoading = false, networkBusy = false, error = error ?: this.error) + } + + /** + * A sign-in whose [fetch] answers `login`'s token pair (a password, or the + * network identity): it races the device code for the sign-in, and its + * session is committed atomically, or not at all. + */ + private fun signInWithTokens( + attempt: TokenSignIn, + fetch: suspend (AccountSessionExpectation) -> ApiResult, + ) { credentialLoginJob?.cancel() credentialLoginJob = viewModelScope.launch { val expected = captureExpectation() if (expected == null) { - _uiState.update { it.copy(isLoading = false, error = TvLoginError.IdentityChanged) } + _uiState.update { it.settled(attempt, TvLoginError.IdentityChanged) } return@launch } - when (val result = authRepository.loginForTokens(s.username, s.password, expected)) { + when (val result = fetch(expected)) { is ApiResult.Success -> { if (!tryCompleteAuth()) { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.settled(attempt) } return@launch } // Polling carries on until the password session commits: if @@ -306,13 +405,14 @@ class TvLoginViewModel( } catch (cancelled: CancellationException) { throw cancelled } catch (_: AccountSessionChangedException) { - handleIdentityChanged() + handleIdentityChanged(attempt) resumeDeviceSignIn() return@launch } catch (_: Throwable) { handleSessionPersistenceFailure( accessToken = result.data.accessToken, refreshToken = result.data.refreshToken, + attempt = attempt, ) if (!authCompleted) { // This screen's own save moved the generation without @@ -321,7 +421,7 @@ class TvLoginViewModel( if (machineExpectation?.serverUrl == expected.serverUrl) machineExpectation = captureExpectation() resumeDeviceSignIn() } else { - passwordWon() + tokenSignInWon() } return@launch } @@ -329,30 +429,33 @@ class TvLoginViewModel( // server's v2 contract verdict the same way every other sign-in does. authRepository.onSessionCommitted() if (tokenManager.captureAccountSessionExpectation()?.generation != expected.generation + 1) { - handleIdentityChanged() + handleIdentityChanged(attempt) resumeDeviceSignIn() return@launch } - passwordWon() - _uiState.update { it.copy(isLoading = false, loginSuccess = true) } + tokenSignInWon() + _uiState.update { it.settled(attempt).copy(loginSuccess = true) } } is ApiResult.Error -> { if (authCompleted) { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.settled(attempt) } return@launch } - val error = tvLoginError(result) - _uiState.update { it.copy(isLoading = false, error = error) } + val error = when (attempt) { + TokenSignIn.Password -> tvLoginError(result) + TokenSignIn.Network -> tvNetworkSignInError(result) + } + _uiState.update { it.settled(attempt, error) } // The server refused passwords: re-read what it offers, so // the form goes away once password sign-in is off. - if (error == TvLoginError.LocalLoginDisabled) refreshSignInOptions() + if (attempt == TokenSignIn.Password && error == TvLoginError.LocalLoginDisabled) refreshSignInOptions() } is ApiResult.NetworkError -> { if (authCompleted) { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.settled(attempt) } return@launch } - _uiState.update { it.copy(isLoading = false, error = TvLoginError.Network) } + _uiState.update { it.settled(attempt, TvLoginError.Network) } } } } @@ -572,6 +675,8 @@ class TvLoginViewModel( _deviceSignIn.value = TvDeviceSignInUi(status = TvSignInStatus.CouldntFinish) return } + // A password or network sign-in under way never settles now: this + // handoff frees its button when it ends (see settled). credentialLoginJob?.cancel() try { tokenManager.replaceAccountSession( @@ -607,20 +712,22 @@ class TvLoginViewModel( ) // Let "Signed in as …" register before the profile picker replaces it. delay(SIGNED_IN_DWELL_MS) - _uiState.update { it.copy(isLoading = false, loginSuccess = true) } + _uiState.update { it.settled(null).copy(loginSuccess = true) } } - private fun handleIdentityChanged() { + /** [attempt] null: the device code's approval, see [settled]. */ + private fun handleIdentityChanged(attempt: TokenSignIn? = null) { // Only release this screen's attempt. Credentials now belong to the new identity. authCompleted = false _uiState.update { - it.copy(isLoading = false, loginSuccess = false, error = TvLoginError.IdentityChanged) + it.settled(attempt, TvLoginError.IdentityChanged).copy(loginSuccess = false) } } private suspend fun handleSessionPersistenceFailure( accessToken: String, refreshToken: String, + attempt: TokenSignIn? = null, ) { val committed = runCatching { tokenManager.getAccessToken() == accessToken && @@ -629,16 +736,16 @@ class TvLoginViewModel( if (committed) authRepository.onSessionCommitted() authCompleted = committed _uiState.update { - it.copy( - isLoading = false, - loginSuccess = committed, - error = if (committed) TvLoginError.CleanupIncomplete else TvLoginError.SaveFailed, - ) + it.settled(attempt, if (committed) TvLoginError.CleanupIncomplete else TvLoginError.SaveFailed) + .copy(loginSuccess = committed) } } - /** The password session committed: the code on screen must not be approvable any more. */ - private fun passwordWon() { + /** + * The password or network session committed: the code on screen must not + * be approvable any more. + */ + private fun tokenSignInWon() { heldApproval = null abandonDeviceCode() } @@ -763,6 +870,9 @@ class TvLoginViewModel( }, providerName = options.oauthProviders.firstOrNull()?.displayName, directoryName = options.directoryProvider?.displayName, + networkProvider = options.networkProvider, + // A refusal belongs to its button; it goes once the button does. + networkError = it.networkError.takeIf { options.networkProvider != null }, ) } } diff --git a/androidTvApp/src/androidMain/res/values/strings.xml b/androidTvApp/src/androidMain/res/values/strings.xml index dbe678c4fb..c6e04608c0 100644 --- a/androidTvApp/src/androidMain/res/values/strings.xml +++ b/androidTvApp/src/androidMain/res/values/strings.xml @@ -105,6 +105,18 @@ Unable to save this session. Try again. Signed in, but local diagnostics cleanup did not finish. + + Continue as %1$s + Continue with %1$s + via %1$s + Open this server at its %1$s address to sign in this way. + %1$s doesn\'t allow this device to sign in to this server. + + An account with your email already exists. Sign in to it with Silo on your phone or on the web, connect %1$s in Sign-in settings, then try again. + %1$s is no longer available on this server. + diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt new file mode 100644 index 0000000000..6f01e1ea6f --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt @@ -0,0 +1,225 @@ +package org.siloserver.silo.tv.ui.screens.auth + +import android.app.Application +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertAll +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondBadRequest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.siloserver.silo.common.pairing.DeviceLoginRepositoryPort +import org.siloserver.silo.common.pairing.PairingAuthPort +import org.siloserver.silo.common.pairing.PairingDeviceIdentity +import org.siloserver.silo.common.pairing.PairingReceiver +import org.siloserver.silo.common.pairing.TvPairingAdvertiser +import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.NetworkIdentity +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.model.auth.SignInProviders +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.AccountSessionExpectation +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.DeviceLoginApi +import org.siloserver.silo.network.api.ExternalSignInApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.DeviceLoginRepository +import org.siloserver.silo.repository.ExternalSignInRepository +import org.siloserver.silo.tv.ui.theme.SiloTvTheme + +/** + * "Continue as …" on a server without device sign-in, where the screen skips + * the code. With passwords on it opens on the password form, which offers the + * button above the fields and focuses it; with network identity alone it + * stays on the code screen, which offers it too. + */ +@RunWith(RobolectricTestRunner::class) +// The reference TV surface: 1920x1080 at 320dpi. +@Config(sdk = [35], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class TvLoginScreenNetworkSignInTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun thePasswordFormOffersContinueAsAndFocusesIt() { + show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + composeRule.onNodeWithText("USERNAME").assertIsDisplayed() + composeRule.onNodeWithText(CONTINUE_AS).assertIsDisplayed().assertIsFocused() + } + + @Test + fun aServerWithOnlyNetworkSignInStillOffersContinueAs() { + show(SignInProviders(listOf(TAILSCALE), passwordLogin = false)) + composeRule.onNodeWithText("USERNAME").assertDoesNotExist() + composeRule.onNodeWithText(CONTINUE_AS).assertIsDisplayed().assertIsFocused() + } + + /** The providers can answer before the device check: focus follows the button into the form. */ + @Test + fun focusFollowsContinueAsIntoTheForm() { + val deviceCheck = CompletableDeferred() + show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true), deviceCheck) + composeRule.onNodeWithText("USERNAME").assertDoesNotExist() + composeRule.onNodeWithText(CONTINUE_AS).assertIsFocused() + + deviceCheck.complete(Unit) + composeRule.waitUntil(5_000) { composeRule.onAllNodesWithText("USERNAME").fetchSemanticsNodes().isNotEmpty() } + composeRule.waitForIdle() + composeRule.onNodeWithText(CONTINUE_AS).assertIsFocused() + } + + /** + * While "Continue as …" waits, the password form takes nothing either: its + * fields and Sign in are disabled, focus stays on the waiting button, and + * the D-pad steps past the disabled controls to the secondary actions. + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun continueAsInProgressDisablesThePasswordForm() { + show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(2).assertAll(isEnabled()) + composeRule.onNodeWithText(SIGN_IN).assertIsEnabled() + + composeRule.onNodeWithText(CONTINUE_AS).performSemanticsAction(SemanticsActions.OnClick) + composeRule.waitForIdle() + composeRule.onNodeWithText(SIGNING_IN).assertIsFocused() + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(2).assertAll(isNotEnabled()) + composeRule.onNodeWithText(SIGN_IN).assertIsNotEnabled() + + composeRule.onNodeWithText(SIGNING_IN).performKeyInput { pressKey(Key.DirectionDown) } + composeRule.waitForIdle() + composeRule.onNodeWithText(CHANGE_SERVER).assertIsFocused() + composeRule.onNodeWithText(CHANGE_SERVER).performKeyInput { pressKey(Key.DirectionUp) } + composeRule.waitForIdle() + composeRule.onNodeWithText(SIGNING_IN).assertIsFocused() + } + + private fun show(providers: SignInProviders, deviceCheck: CompletableDeferred = CompletableDeferred(Unit)) { + // D-pad, not touch: the screen claims focus only for remote users. + InstrumentationRegistry.getInstrumentation().setInTouchMode(false) + val deviceLogin = DeviceLoginRepository(NoDeviceSignIn(deviceCheck)) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun captureAccountSessionExpectation() = AccountSessionExpectation(0, null, SERVER.url) + } + val viewModel = TvLoginViewModel( + authRepository = AuthRepository(AuthApi(HttpClient(MockEngine { respondBadRequest() }), ApiV2Gate.Unrestricted), tokens), + tokenManager = tokens, + deviceLogin = deviceLogin, + serverRegistry = OneServer, + externalSignIn = ExternalSignInRepository(Providers(providers)), + ) + val receiver = PairingReceiver(NoPairing, DeviceLoginRepositoryPort(deviceLogin), { PairingDeviceIdentity("TV", "tv-1") }) + composeRule.setContent { + SiloTvTheme { + TvLoginScreen( + onLoginSuccess = {}, + viewModel = viewModel, + pairingReceiver = receiver, + pairingAdvertiser = TvPairingAdvertiser(ApplicationProvider.getApplicationContext(), receiver), + ) + } + } + composeRule.waitUntil(5_000) { composeRule.onAllNodesWithText(CONTINUE_AS).fetchSemanticsNodes().isNotEmpty() } + composeRule.waitForIdle() + } + + /** A server without device sign-in, which says so once [answered] completes. */ + private class NoDeviceSignIn(private val answered: CompletableDeferred) : DeviceLoginApi { + override suspend fun deviceLoginCapabilityAt(serverUrl: String): ApiResult { + answered.await() + return ApiResult.Success(DeviceLoginCapabilityResponse(deviceLoginAvailable = false)) + } + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("no device sign-in") + override suspend fun pollDeviceLogin(deviceCode: String) = error("no device sign-in") + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unused") + } + + private class Providers(private val providers: SignInProviders) : ExternalSignInApi { + override suspend fun listProviders(serverUrl: String) = ApiResult.Success(providers) + override suspend fun oauthCapabilities(serverUrl: String) = ApiResult.Success(OAuthHandshakeCapabilities.None) + override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot) = error("unused") + override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String) = error("unused") + // Waits like a server that hasn't answered yet. + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult = + awaitCancellation() + override suspend fun listIdentities(scope: AuthScopeSnapshot) = error("unused") + override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String) = error("unused") + override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String) = error("unused") + override suspend fun completeLink(scope: AuthScopeSnapshot, code: String, codeVerifier: String) = error("unused") + override suspend fun linkWithCredentials( + scope: AuthScopeSnapshot, installationId: String, password: String, username: String, directoryPassword: String, + ) = error("unused") + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String) = error("unused") + } + + private object NoPairing : PairingAuthPort { + override suspend fun persistApprovedSession( + serverUrl: String, serverName: String?, accessToken: String, refreshToken: String, expiresIn: Long, + expectedIdentity: AccountSessionExpectation?, verifiedServerId: String?, + ) = error("unused") + } + + private object OneServer : ServerRegistry { + override val entries = MutableStateFlow(listOf(SERVER)) + override val activeServerId = MutableStateFlow(SERVER.id) + override val activeEntry = MutableStateFlow(SERVER) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = SERVER.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + private companion object { + const val CONTINUE_AS = "Continue as Alice Example" + const val SIGNING_IN = "Signing in…" + const val SIGN_IN = "Sign in" + const val CHANGE_SERVER = "Change server" + val TEXT_FIELD = SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText) + val SERVER = ServerEntry(id = "entry-1", url = "https://silo.test", fetchedName = "Silo") + val LOCAL = SignInProvider("local", "Silo account", SignInProvider.Mode.Credentials, true, null, null, null) + val TAILSCALE = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt index e6cf809c1a..89a3ecbe5a 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginViewModelDeviceSignInTest.kt @@ -32,6 +32,8 @@ import org.siloserver.silo.model.auth.DeviceLoginDecisionResponse import org.siloserver.silo.model.auth.DeviceLoginLookupResponse import org.siloserver.silo.model.auth.DeviceLoginPollResponse import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.NetworkIdentity import org.siloserver.silo.model.auth.User import org.siloserver.silo.network.AccountSessionChangedException import org.siloserver.silo.network.AccountSessionExpectation @@ -72,7 +74,8 @@ import kotlin.test.assertTrue /** * The TV sign-in panel's device-code behavior: #421 (no silent "Loading" * hang), #422 (no false "Signed in!"), lifecycle-aware polling (#420) and - * server-side cancel of an abandoned code. + * server-side cancel of an abandoned code; and the other ways in beside it + * (the password form, "Continue as …" through a network provider). */ @OptIn(ExperimentalCoroutinesApi::class) class TvLoginViewModelDeviceSignInTest { @@ -536,6 +539,156 @@ class TvLoginViewModelDeviceSignInTest { assertEquals(TvLoginError.LocalLoginDisabled, vm.uiState.value.error) } + // --- Network identity ("Continue as …", e.g. Tailscale) --- + + /** + * Reached through Tailscale, the server lists it with the TV's owner: one + * press signs in with no code and no password, on the saved base, and the + * code on screen is withdrawn once the session is saved. + */ + @Test + fun continueAsSignsInWithoutACodeAndWithdrawsTheCode() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkAnswer = ApiResult.Success( + LoginResponse("net-access", "net-refresh", 3600, User(id = "1", username = "alice", email = "a@example.test", role = "user")), + ) + val tokens = FakeTokens() + val api = FakeDeviceApi() + val vm = viewModel(tokens, api, registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + assertEquals(TAILSCALE, vm.uiState.value.networkProvider) + assertTrue(vm.uiState.value.passwordAvailable, "the password form stays available") + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status, "and so does the code") + + vm.onNetworkSignInClick() + assertTrue(vm.uiState.value.networkBusy) + vm.onNetworkSignInClick() + advanceUntilIdle() + assertEquals(listOf("https://silo.test" to "/api/v2/auth/network/7/sign-in"), signIn.networkCalls, "one sign-in, on the saved base") + assertTrue(vm.uiState.value.loginSuccess) + assertFalse(vm.uiState.value.networkBusy) + assertNull(vm.uiState.value.networkError) + assertEquals("net-access", tokens.accessToken) + assertEquals(1, tokens.installAttempts) + assertEquals(listOf("dev-1"), api.canceled, "the code on screen can't be approved any more") + } + + @Test + fun aRefusedContinueAsSaysWhyAndKeepsTheCode() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkAnswer = ApiResult.Error(403, "network_identity_required", "raw detail") + val tokens = FakeTokens() + val api = FakeDeviceApi() + val vm = viewModel(tokens, api, registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onNetworkSignInClick() + runCurrent() + assertEquals(TvLoginError.NetworkIdentityRequired, vm.uiState.value.networkError) + assertNull(vm.uiState.value.error, "the password form's error slot is untouched") + assertFalse(vm.uiState.value.networkBusy, "the button can be pressed again") + assertFalse(vm.uiState.value.loginSuccess) + assertNull(tokens.accessToken) + assertEquals(TvSignInStatus.Waiting, vm.deviceSignIn.value.status) + assertTrue(api.canceled.isEmpty(), "the code still works") + + signIn.networkAnswer = ApiResult.Error(403, "not_permitted", "") + vm.onNetworkSignInClick() + runCurrent() + assertEquals(TvLoginError.NetworkNotPermitted, vm.uiState.value.networkError) + + // A phone can still approve the code on screen. + api.approveAfterPolls = api.polls + 1 + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("qr-access", tokens.accessToken) + } + + /** + * A phone approves the code while "Continue as …" waits for the server: + * the approval takes the sign-in and cancels the network attempt. When + * that approval can't be saved, the button is free again, not left busy. + */ + @Test + fun anApprovalThatCancelsContinueAsAndFailsFreesItsButton() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkDelayMs = 60_000L + val tokens = FakeTokens(failInstall = true) + val api = FakeDeviceApi() + api.approveWhen = { signIn.networkCalls.isNotEmpty() } + val vm = viewModel(tokens, api, registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onNetworkSignInClick() + runCurrent() + assertTrue(vm.uiState.value.networkBusy) + + // The next poll is approved; saving it fails. + advanceTimeBy(5_001) + assertEquals(TvSignInStatus.CouldntFinish, vm.deviceSignIn.value.status) + assertEquals(TvLoginError.SaveFailed, vm.uiState.value.error) + assertFalse(vm.uiState.value.networkBusy, "the canceled network attempt doesn't hold its button") + assertFalse(vm.uiState.value.isLoading) + + tokens.failInstall = false + signIn.networkDelayMs = 0L + signIn.networkAnswer = ApiResult.Success( + LoginResponse("net-access", "net-refresh", 3600, User(id = "1", username = "alice", email = "a@example.test", role = "user")), + ) + vm.onNetworkSignInClick() + advanceUntilIdle() + assertTrue(vm.uiState.value.loginSuccess) + assertEquals("net-access", tokens.accessToken) + } + + /** Off the provider's network the server stops listing it: the button and its refusal go. */ + @Test + fun continueAsGoesWhenTheServerNoLongerListsIt() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) + signIn.networkAnswer = ApiResult.Error(403, "network_identity_required", "") + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + vm.onNetworkSignInClick() + runCurrent() + assertEquals(TvLoginError.NetworkIdentityRequired, vm.uiState.value.networkError) + + signIn.providers = SignInProviders(listOf(LOCAL), passwordLogin = true) + vm.onStop() + vm.onStart() + runCurrent() + assertNull(vm.uiState.value.networkProvider) + assertNull(vm.uiState.value.networkError) + vm.onNetworkSignInClick() + runCurrent() + assertEquals(1, signIn.networkCalls.size, "nothing to press") + } + + @Test + fun aNetworkProviderWithoutItsSignInPathOffersNothing() = runTest(dispatcher) { + val signIn = FakeSignInApi(SignInProviders(listOf(LOCAL, TAILSCALE.copy(networkSignInPath = null)), passwordLogin = true)) + val vm = viewModel(FakeTokens(), FakeDeviceApi(), registry = FakeRegistry(), externalSignIn = signIn) + runCurrent() + assertNull(vm.uiState.value.networkProvider) + vm.onNetworkSignInClick() + runCurrent() + assertTrue(signIn.networkCalls.isEmpty()) + } + + @Test + fun networkRefusalsMapToTheirErrors() { + fun error(status: Int, code: String) = tvNetworkSignInError(ApiResult.Error(status, code, "raw")) + assertEquals(TvLoginError.NetworkIdentityRequired, error(403, "network_identity_required")) + assertEquals(TvLoginError.NetworkNotPermitted, error(403, "not_permitted")) + assertEquals(TvLoginError.NetworkEmailInUse, error(409, "email_in_use")) + assertEquals(TvLoginError.NetworkProviderGone, error(404, "not_found")) + // The rest read as the other sign-ins' refusals. + assertEquals(TvLoginError.AccountRequired, error(403, "account_required")) + assertEquals(TvLoginError.AccountDisabled, error(403, "permission_denied")) + assertEquals(TvLoginError.IdentityLinkedElsewhere, error(409, "identity_linked_elsewhere")) + assertEquals(TvLoginError.ProviderUnavailable, error(503, "provider_unavailable")) + assertEquals(TvLoginError.RateLimited, error(429, "")) + assertEquals(TvLoginError.IdentityChanged, error(0, "identity_changed")) + assertEquals(TvLoginError.Server("raw"), error(500, "internal")) + } + private fun TestScope.viewModel( tokens: FakeTokens, api: FakeDeviceApi, @@ -679,6 +832,13 @@ class TvLoginViewModelDeviceSignInTest { /** Provider discovery; [providers] null = a server that can't list them. */ private class FakeSignInApi(var providers: SignInProviders?) : ExternalSignInApi { val asked = mutableListOf() + + /** What "Continue as …" answers, and where each one was sent (saved base, path). */ + var networkAnswer: ApiResult = ApiResult.Error(404, "not_found", "") + val networkCalls = mutableListOf>() + + /** How long "Continue as …" waits for its answer. */ + var networkDelayMs = 0L override suspend fun listProviders(serverUrl: String): ApiResult { asked += serverUrl return providers?.let { ApiResult.Success(it) } ?: ApiResult.Error(404, "not_found", "") @@ -694,6 +854,13 @@ class TvLoginViewModelDeviceSignInTest { override suspend fun linkWithCredentials( scope: AuthScopeSnapshot, installationId: String, password: String, username: String, directoryPassword: String, ) = error("unused") + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult { + networkCalls += serverUrl to signInPath + if (networkDelayMs > 0) delay(networkDelayMs) + return networkAnswer + } + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String) = + error("TVs have no Sign-in settings") } private class FakeTokens( @@ -756,6 +923,11 @@ class TvLoginViewModelDeviceSignInTest { ) val LDAP = SignInProvider("plugin:6:ldap", "Directory", SignInProvider.Mode.Credentials, false, null, "6", null) val LOCAL = SignInProvider("local", "Silo account", SignInProvider.Mode.Credentials, true, null, null, null) + val TAILSCALE = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice Example", "alice@example.test"), + ) val LOGIN_JSON = """ {"access_token":"credential-access","refresh_token":"credential-refresh","expires_in":3600, "user":{"id":"1","username":"jim","email":"jim@example.com","role":"user","download_allowed":true}} diff --git a/scripts/sync-apiv2-fixtures.sh b/scripts/sync-apiv2-fixtures.sh index 1de1ceac71..eee51286a2 100755 --- a/scripts/sync-apiv2-fixtures.sh +++ b/scripts/sync-apiv2-fixtures.sh @@ -52,6 +52,9 @@ SELECTED=( complete_account_identity_link_invalid_grant delete_account_identity_last_sign_in_method refresh_session_provider_unavailable + sign_in_with_network_identity_ok + sign_in_with_network_identity_off_overlay + link_account_identity_with_network_ok ) [ -d "$SRC" ] || { echo "no fixtures at $SRC" >&2; exit 1; } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt index 6c78b65d7c..3e72df0e5d 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt @@ -21,8 +21,32 @@ data class SignInProvider( * listed it with. */ val nativeStartPath: String?, + /** + * `signInWithNetworkIdentity` relative to the server base + * (`/api/v2/auth/network/{id}/sign-in`); network providers only. Apps + * POST `{}` to it on their own saved base URL, like [nativeStartPath]. + */ + val networkSignInPath: String? = null, + /** Who the network provider says owns this device; network providers only. It authorizes nothing. */ + val networkIdentity: NetworkIdentity? = null, +) { + /** + * How the provider signs people in: [Network] providers (such as the + * Tailscale plugin) are listed only to a request that came through their + * own network, and sign in the owner of the device with no password and + * no browser. + */ + enum class Mode { Credentials, OAuth, Network, Unknown } +} + +/** A network provider's `network_identity`: the person who owns the device that asked. */ +data class NetworkIdentity( + val displayName: String, + val username: String, ) { - enum class Mode { Credentials, OAuth, Unknown } + /** The name for "Continue as": the display name, else the username; null when the provider gave neither. */ + val label: String? + get() = displayName.trim().ifEmpty { username.trim() }.ifEmpty { null } } /** `listAuthProviders`: the providers plus whether any of them takes a password. */ @@ -56,6 +80,12 @@ data class ExternalSignInCapabilities( * Directory linking needs no OAuth handshake, so this document reports it. */ val credentialsLinking: Boolean = false, + /** + * `signInWithNetworkIdentity` and `linkAccountIdentityWithNetwork` are + * served. Whether this device may use them is answered by provider + * discovery, which lists a network provider only through its own network. + */ + val networkSignIn: Boolean = false, ) /** @@ -68,6 +98,9 @@ data class ExternalSignInCapabilities( * provider takes a password (the local one, or a directory such as LDAP, * which the server reaches without a provider field) and hidden only when * the server says no provider does. + * - [networkProvider]: "Continue as " above everything else. The + * server lists a network provider only when this request came through that + * provider's network; it never changes what else is offered. */ data class SignInOptions( val oauthProviders: List, @@ -76,6 +109,8 @@ data class SignInOptions( val directoryProvider: SignInProvider?, /** A provider sign-in may ask the provider to offer another account (`prompt=select_account`). */ val selectAccount: Boolean = false, + /** A network provider (such as Tailscale) that signs in this device's owner, with its sign-in path. */ + val networkProvider: SignInProvider? = null, ) { companion object { /** A server that predates provider discovery: the password form alone. */ @@ -100,6 +135,9 @@ data class SignInOptions( showPasswordForm = providers.passwordLogin || credentials.isNotEmpty(), directoryProvider = credentials.firstOrNull { it.id != LOCAL_PROVIDER_ID }, selectAccount = oauth.isNotEmpty() && handshake.selectAccount, + networkProvider = providers.providers.firstOrNull { + it.mode == SignInProvider.Mode.Network && !it.networkSignInPath.isNullOrBlank() + }, ) } @@ -200,3 +238,56 @@ enum class PasswordLoginFailure { } } } + +/** + * Why a network identity sign-in (`signInWithNetworkIdentity`) was refused, + * from the problem code first and the status second (silo-server + * `docs/auth-api.md`, "External sign-in"). Phone and TV map each to their own copy. + */ +enum class NetworkSignInFailure { + /** + * The request didn't come through the provider's network + * (`network_identity_required`): the app uses another address for the + * server, or the device left that network. + */ + NetworkIdentityRequired, + + /** The provider refuses this device (`not_permitted`): a tagged device, or one its policy leaves out. */ + NotPermitted, + + /** No account matches and the server doesn't create one (`account_required`). */ + AccountRequired, + + /** The account is disabled (`permission_denied`). */ + AccountDisabled, + + /** A first sign-in found another account with the same email (409): link from that account instead. */ + EmailInUse, + + /** The network identity is linked to another account (409). */ + IdentityLinkedElsewhere, + + /** Not an enabled network provider any more (404). */ + NotFound, + ProviderUnavailable, + + /** Too many attempts (429). */ + RateLimited, + Other; + + companion object { + fun of(code: Int, problem: String): NetworkSignInFailure = when { + problem == "network_identity_required" -> NetworkIdentityRequired + problem == "not_permitted" -> NotPermitted + problem == "account_required" -> AccountRequired + problem == "permission_denied" -> AccountDisabled + problem == "email_in_use" -> EmailInUse + problem == "identity_linked_elsewhere" -> IdentityLinkedElsewhere + problem == "provider_unavailable" -> ProviderUnavailable + problem == "rate_limited" || code == 429 -> RateLimited + problem == "not_found" || code == 404 -> NotFound + code == 503 -> ProviderUnavailable + else -> Other + } + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt index 565610ff96..3b04b8bf92 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt @@ -14,11 +14,13 @@ import io.ktor.http.contentType import io.ktor.http.encodeURLPathPart import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject import org.siloserver.silo.model.auth.AccountIdentities import org.siloserver.silo.model.auth.AccountIdentity import org.siloserver.silo.model.auth.AccountIdentityLinkTicket import org.siloserver.silo.model.auth.ExternalSignInCapabilities import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.NetworkIdentity import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities import org.siloserver.silo.model.auth.SignInProvider import org.siloserver.silo.model.auth.SignInProviders @@ -35,8 +37,9 @@ import org.siloserver.silo.network.singleAttempt import org.siloserver.silo.network.skipSiloAuth /** - * External sign-in (OIDC, LDAP) operations the apps use: provider discovery, - * the native OAuth handoff's completion, and the account's Sign-in section. + * External sign-in (OIDC, LDAP, network identity) operations the apps use: + * provider discovery, the native OAuth handoff's completion, the network + * identity sign-in, and the account's Sign-in section. * Contract: silo-server `docs/auth-api.md` ("External sign-in", "OAuth sign-in * flows"). Nothing here retries; the completions are single use. */ @@ -56,6 +59,16 @@ interface ExternalSignInApi { /** `completeOAuthLogin` for a native code: the token pair and the account. */ suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String): ApiResult + /** + * `signInWithNetworkIdentity`: POST `{}` to [signInPath] on the saved base + * [serverUrl], unauthenticated. [signInPath] is a provider's + * [SignInProvider.networkSignInPath], which discovery already reduced to + * the base-relative route, so nothing is posted to another origin. + * Answers `login`'s token pair and account. Never retried: each attempt + * spends the server's login rate-limit budget. + */ + suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult + /** `listAccountIdentities` for [scope]'s account. */ suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult @@ -84,6 +97,18 @@ interface ExternalSignInApi { username: String, directoryPassword: String, ): ApiResult + + /** + * `linkAccountIdentityWithNetwork`: link [scope]'s account to the person + * who owns this device at network provider [installationId], confirming + * the account's local [password]. The server allows it only through that + * provider's network. + */ + suspend fun linkWithNetwork( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult } class DefaultExternalSignInApi( @@ -122,6 +147,17 @@ class DefaultExternalSignInApi( }.requireAuthStatus(200) }.map { it.domain() } + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult = + safeApiV2Call(apiV2Gate) { + client.post("${serverUrl.trimEnd('/')}$signInPath") { + skipSiloAuth(); singleAttempt() + // The server refuses a sign-in without a JSON body (415), which + // keeps a cross-site form post from signing a device in. + contentType(ContentType.Application.Json) + setBody(JsonObject(emptyMap())) + }.requireAuthStatus(200) + }.map { it.domain() } + override suspend fun listIdentities(scope: AuthScopeSnapshot): ApiResult = safeApiV2Call(apiV2Gate.forServer(scope.serverId)) { client.get("/api/v2/account/identities") { authScope(scope) } @@ -182,6 +218,20 @@ class DefaultExternalSignInApi( } } + override suspend fun linkWithNetwork( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult = locatedCall(scope, expected = 201, decode = { body -> + SiloJson.decodeFromString(AccountIdentityV2.serializer(), body).domain() + }) { + client.post("/api/v2/account/identities/link-network") { + authScope(scope); freshSiloAuth(); singleAttempt() + contentType(ContentType.Application.Json) + setBody(LinkNetworkRequestV2(installationId = installationId, password = password)) + } + } + /** * One v2 exchange whose 422s differ only by the member the problem names * (see [locatedError]); everything else is as [safeApiV2Call]. @@ -243,6 +293,8 @@ internal data class AuthProviderV2( @SerialName("icon_url") val iconUrl: String? = null, @SerialName("installation_id") val installationId: String? = null, @SerialName("native_start_path") val nativeStartPath: String? = null, + @SerialName("network_sign_in_path") val networkSignInPath: String? = null, + @SerialName("network_identity") val networkIdentity: AuthProviderNetworkIdentityV2? = null, ) { fun domain(serverUrl: String) = SignInProvider( id = id, @@ -250,16 +302,25 @@ internal data class AuthProviderV2( mode = when (mode) { "credentials" -> SignInProvider.Mode.Credentials "oauth" -> SignInProvider.Mode.OAuth + "network" -> SignInProvider.Mode.Network else -> SignInProvider.Mode.Unknown }, isDefault = default, iconUrl = iconUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { absoluteUrl(it, serverUrl) }, installationId = installationId?.takeIf { it.isNotBlank() }, - // Only the path is kept: the app opens it on its own saved server base. + // Only the paths are kept: the app uses them on its own saved server base. nativeStartPath = nativeStartPath(nativeStartPath), + networkSignInPath = networkSignInPath(networkSignInPath), + networkIdentity = networkIdentity?.let { NetworkIdentity(displayName = it.displayName, username = it.username) }, ) } +@Serializable +internal data class AuthProviderNetworkIdentityV2( + @SerialName("display_name") val displayName: String = "", + val username: String = "", +) + /** * The native start relative to the server base: `/api/v2/auth/oauth/{id}/native/start` * and any query, taken from `native_start_path`. An oauth provider without it @@ -268,17 +329,29 @@ internal data class AuthProviderV2( * saved base URL and never open another origin. * Null for anything that isn't that route. */ -internal fun nativeStartPath(path: String?): String? { +internal fun nativeStartPath(path: String?): String? = baseRelativeRoute(path, NATIVE_START_PREFIX, NATIVE_START_ROUTE) + +/** + * `signInWithNetworkIdentity` relative to the server base + * (`/api/v2/auth/network/{id}/sign-in`), taken from `network_sign_in_path` + * the way [nativeStartPath] takes the native start: a reverse proxy's path + * prefix is dropped, and anything that isn't that route is null. + */ +internal fun networkSignInPath(path: String?): String? = baseRelativeRoute(path, NETWORK_SIGN_IN_PREFIX, NETWORK_SIGN_IN_ROUTE) + +private fun baseRelativeRoute(path: String?, prefix: String, route: Regex): String? { val raw = path?.trim() ?.takeIf { it.startsWith("/") && !it.startsWith("//") } ?.substringBefore('#') ?: return null - val start = raw.substringBefore('?').indexOf(NATIVE_START_PREFIX) + val start = raw.substringBefore('?').indexOf(prefix) if (start < 0) return null - return raw.substring(start).takeIf { NATIVE_START_ROUTE.matches(it) } + return raw.substring(start).takeIf { route.matches(it) } } private const val NATIVE_START_PREFIX = "/api/v2/auth/oauth/" private val NATIVE_START_ROUTE = Regex("^/api/v2/auth/oauth/[A-Za-z0-9._~%-]+/native/start(\\?[^#\\s]*)?$") +private const val NETWORK_SIGN_IN_PREFIX = "/api/v2/auth/network/" +private val NETWORK_SIGN_IN_ROUTE = Regex("^/api/v2/auth/network/[A-Za-z0-9._~%-]+/sign-in$") /** A site-relative icon path (`/api/v2/plugin-content/...`) resolves against the server that listed it. */ internal fun absoluteUrl(url: String, serverUrl: String): String? = when { @@ -323,12 +396,14 @@ internal data class ExternalSignInCapabilitiesV2( val available: Boolean = false, val identities: Boolean = false, @SerialName("credentials_linking") val credentialsLinking: Boolean = false, + @SerialName("network_sign_in") val networkSignIn: Boolean = false, ) { fun domain(): ExternalSignInCapabilities { val usable = available && (state == null || state == "available") return ExternalSignInCapabilities( identities = usable && identities, credentialsLinking = usable && credentialsLinking, + networkSignIn = usable && networkSignIn, ) } } @@ -397,3 +472,11 @@ internal data class LinkCredentialsRequestV2( ) { override fun toString(): String = "LinkCredentialsRequestV2(installationId=$installationId, )" } + +@Serializable +internal data class LinkNetworkRequestV2( + @SerialName("installation_id") val installationId: String, + val password: String, +) { + override fun toString(): String = "LinkNetworkRequestV2(installationId=$installationId, password=)" +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt index f492bef3e4..c58850c6ff 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/AuthRepository.kt @@ -135,6 +135,16 @@ class AuthRepository( authApi.login(LoginRequest(username, password), it.serverUrl) } + /** + * Signs in with another operation that answers `login`'s token pair, such + * as the network identity sign-in (silo-server `docs/auth-api.md`, + * "External sign-in"). [exchange] runs against the active server's saved + * base URL it is given, and the session lands exactly like a password + * sign-in's. + */ + suspend fun signInWith(exchange: suspend (serverUrl: String) -> ApiResult): ApiResult = + authenticate { exchange(it.serverUrl) } + /** * Signs in with the one-time code a native OAuth flow returned to the app * (silo-server `docs/auth-api.md`, "OAuth sign-in flows"). The flow must @@ -151,9 +161,21 @@ class AuthRepository( if ((expected.serverId ?: serverRegistry?.activeServerId?.value) != serverId) staleSession() else redeem(expected.serverUrl) } - suspend fun loginForTokens(username: String, password: String, expected: AccountSessionExpectation? = null): ApiResult { + suspend fun loginForTokens(username: String, password: String, expected: AccountSessionExpectation? = null): ApiResult = + tokensFor(expected) { serverUrl -> authApi.login(LoginRequest(username, password), serverUrl) } + + /** + * [loginForTokens] for another operation that answers `login`'s token + * pair: the tokens, not yet saved, from [exchange] run against the saved + * base URL of the sign-in context [expected] (the current one when null), + * and only while that context still holds. + */ + suspend fun tokensFor( + expected: AccountSessionExpectation? = null, + exchange: suspend (serverUrl: String) -> ApiResult, + ): ApiResult { val captured = expected ?: tokenManager.captureAccountSessionExpectation() ?: return staleSession() - val result = authApi.login(LoginRequest(username, password), captured.serverUrl) + val result = exchange(captured.serverUrl) return if (captured.isSameSession(tokenManager.captureAccountSessionExpectation())) result else staleSession() } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt index 13716b854b..0a96af724e 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ExternalSignInRepository.kt @@ -6,6 +6,7 @@ import org.siloserver.silo.model.auth.AccountIdentities import org.siloserver.silo.model.auth.AccountIdentity import org.siloserver.silo.model.auth.AccountIdentityLinkTicket import org.siloserver.silo.model.auth.ExternalSignInCapabilities +import org.siloserver.silo.model.auth.LoginResponse import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities import org.siloserver.silo.model.auth.SignInOptions import org.siloserver.silo.model.auth.SignInProviders @@ -14,9 +15,10 @@ import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.api.ExternalSignInApi /** - * External sign-in (OIDC, LDAP) for the apps: what a sign-in screen offers, - * and the account's Sign-in section. The native OAuth handoff itself (browser, - * PKCE, the app redirect) belongs to the phone app; TVs never run it. + * External sign-in (OIDC, LDAP, network identity) for the apps: what a + * sign-in screen offers, and the account's Sign-in section. The native OAuth + * handoff itself (browser, PKCE, the app redirect) belongs to the phone app; + * TVs never run it. The network identity sign-in needs neither and runs on both. */ class ExternalSignInRepository( private val api: ExternalSignInApi, @@ -75,4 +77,14 @@ class ExternalSignInRepository( username: String, directoryPassword: String, ): ApiResult = api.linkWithCredentials(scope, installationId, password, username, directoryPassword) + + /** The token pair for this device's owner at a network provider; see [ExternalSignInApi.signInWithNetworkIdentity]. */ + suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String): ApiResult = + api.signInWithNetworkIdentity(serverUrl, signInPath) + + suspend fun linkWithNetwork( + scope: AuthScopeSnapshot, + installationId: String, + password: String, + ): ApiResult = api.linkWithNetwork(scope, installationId, password) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt index 9ade18c27f..2b682539e9 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/ExternalSignInApiTest.kt @@ -9,6 +9,7 @@ import io.ktor.client.request.HttpResponseData import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.content.TextContent import io.ktor.http.headersOf @@ -19,6 +20,9 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.siloserver.silo.model.auth.AccountIdentities import org.siloserver.silo.model.auth.AccountIdentity +import org.siloserver.silo.model.auth.LoginResponse +import org.siloserver.silo.model.auth.NetworkIdentity +import org.siloserver.silo.model.auth.NetworkSignInFailure import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities import org.siloserver.silo.model.auth.PasswordLoginFailure import org.siloserver.silo.model.auth.SignInOptions @@ -42,8 +46,10 @@ import kotlin.test.assertTrue /** * External sign-in on the wire: what the apps send to silo-server's - * `listAuthProviders`, `completeOAuthLogin`, the identity operations and - * `linkAccountIdentityWithCredentials`, and how each refusal comes back. + * `listAuthProviders`, `completeOAuthLogin`, `signInWithNetworkIdentity`, + * the identity operations and the two password-confirmed links + * (`linkAccountIdentityWithCredentials`, `linkAccountIdentityWithNetwork`), + * and how each refusal comes back. */ class ExternalSignInApiTest { private val scope = AuthScopeSnapshot("server-a", "p1", "https://silo.example.test", null, identityGeneration = 1) @@ -124,6 +130,121 @@ class ExternalSignInApiTest { } } + /** A network provider is listed only over its own network, with the owner of the asking device. */ + @Test + fun aNetworkProviderIsReadWithItsSignInPathAndTheDevicesOwner() = runTest { + val (client, api) = api { + json( + """{"items":[{"id":"local","display_name":"Silo account","mode":"credentials","default":true}, + {"id":"plugin:5:tailscale","display_name":"Tailscale","mode":"network","default":false, + "installation_id":"5","network_sign_in_path":"/api/v2/auth/network/5/sign-in", + "network_identity":{"display_name":"Alice Example","username":"alice@example.test"}}], + "password_login":true}""", + ) + } + try { + val listed = assertIs>(api.listProviders("https://silo.tailnet.ts.net")).data + val network = listed.providers[1] + assertEquals(SignInProvider.Mode.Network, network.mode) + assertEquals("Tailscale", network.displayName) + assertEquals("5", network.installationId) + assertEquals("/api/v2/auth/network/5/sign-in", network.networkSignInPath) + assertEquals(NetworkIdentity("Alice Example", "alice@example.test"), network.networkIdentity) + assertEquals("Alice Example", network.networkIdentity?.label) + assertFalse(network.isDefault) + assertNull(network.nativeStartPath) + + val options = SignInOptions.of(listed, OAuthHandshakeCapabilities.None) + assertEquals(network, options.networkProvider) + assertTrue(options.showPasswordForm, "the network provider leaves the password form alone") + assertNull(options.directoryProvider, "a network provider is not a directory") + assertTrue(options.oauthProviders.isEmpty()) + } finally { client.close() } + } + + @Test + fun theOwnersLabelFallsBackToTheUsernameAndThenToNothing() { + assertEquals("alice@example.test", NetworkIdentity("", "alice@example.test").label) + assertEquals("alice@example.test", NetworkIdentity(" ", "alice@example.test").label) + assertNull(NetworkIdentity("", "").label, "the button then names the provider instead") + } + + /** + * The sign-in posts `{}` as JSON (the server answers 415 without it) to + * the listed path on the app's own saved base, which may carry a reverse + * proxy's prefix, and lands the same token pair as `login`. + */ + @Test + fun networkSignInPostsAnEmptyJsonObjectBelowTheSavedBase() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("https://silo.tailnet.ts.net/silo/api/v2/auth/network/5/sign-in", request.url.toString()) + assertEquals(true, request.attributes.getOrNull(SkipSiloAuthAttributeKey), "no bearer: this is a sign-in") + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey), "never retried") + assertNull(request.headers[HttpHeaders.Authorization]) + assertEquals(ContentType.Application.Json, request.body.contentType?.withoutParameters()) + assertEquals("{}", (request.body as TextContent).text) + json( + """{"access_token":"acc","refresh_token":"ref","expires_in":3600, + "user":{"id":"1","username":"laura","email":"laura@example.test","role":"user"}}""", + ) + } + try { + val login = assertIs>( + api.signInWithNetworkIdentity("https://silo.tailnet.ts.net/silo/", "/api/v2/auth/network/5/sign-in"), + ).data + assertEquals("acc", login.accessToken) + assertEquals("ref", login.refreshToken) + assertEquals(3600L, login.expiresIn) + assertEquals("laura", login.user.username) + } finally { client.close() } + } + + /** Discovery keeps only the listed route, so the sign-in posts nowhere else. */ + @Test + fun networkSignInGoesNowhereButTheListedRoute() { + listOf( + "//evil.example.test/api/v2/auth/network/5/sign-in", + "https://evil.example.test/api/v2/auth/network/5/sign-in", + "/api/v2/auth/login", + "/api/v2/auth/network/5/sign-in/../../login", + "", + null, + ).forEach { path -> assertNull(networkSignInPath(path), path) } + assertEquals("/api/v2/auth/network/5/sign-in", networkSignInPath("/silo/api/v2/auth/network/5/sign-in")) + } + + @Test + fun networkSignInRefusalsKeepTheirProblemCodes() = runTest { + val cases = listOf( + "network_identity_required" to 403 to NetworkSignInFailure.NetworkIdentityRequired, + "not_permitted" to 403 to NetworkSignInFailure.NotPermitted, + "account_required" to 403 to NetworkSignInFailure.AccountRequired, + "permission_denied" to 403 to NetworkSignInFailure.AccountDisabled, + "email_in_use" to 409 to NetworkSignInFailure.EmailInUse, + "identity_linked_elsewhere" to 409 to NetworkSignInFailure.IdentityLinkedElsewhere, + "not_found" to 404 to NetworkSignInFailure.NotFound, + "provider_unavailable" to 503 to NetworkSignInFailure.ProviderUnavailable, + "rate_limited" to 429 to NetworkSignInFailure.RateLimited, + ) + for ((problem, failure) in cases) { + val (code, status) = problem + val (client, api) = api { problem(code, status) } + try { + val error = assertIs(api.signInWithNetworkIdentity("https://silo.example.test", "/api/v2/auth/network/5/sign-in")) + assertEquals(status, error.code, code) + assertEquals(code, error.error, code) + assertEquals(failure, NetworkSignInFailure.of(error.code, error.error), code) + } finally { client.close() } + } + // By status when the problem code is unknown. + assertEquals(NetworkSignInFailure.RateLimited, NetworkSignInFailure.of(429, "")) + assertEquals(NetworkSignInFailure.ProviderUnavailable, NetworkSignInFailure.of(503, "")) + assertEquals(NetworkSignInFailure.NotFound, NetworkSignInFailure.of(404, "")) + assertEquals(NetworkSignInFailure.Other, NetworkSignInFailure.of(403, "")) + assertEquals(NetworkSignInFailure.Other, NetworkSignInFailure.of(500, "internal")) + } + @Test fun linkTicketConfirmsThePasswordThroughTheAccountScope() = runTest { val (client, api) = api { request -> @@ -207,6 +328,67 @@ class ExternalSignInApiTest { } finally { client.close() } } + /** Network linking asks only for the account's own password: the device says who the person is. */ + @Test + fun linkWithNetworkSendsTheInstallationAndPasswordOnly() = runTest { + val (client, api) = api { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("/api/v2/account/identities/link-network", request.url.encodedPath) + assertEquals(scope, request.attributes.getOrNull(AuthScopeAttributeKey)) + assertEquals(true, request.attributes.getOrNull(FreshSiloAuthAttributeKey)) + assertEquals(true, request.attributes.getOrNull(SingleAttemptAttributeKey)) + val body = request.jsonBody() + assertEquals(setOf("installation_id", "password"), body.keys) + assertEquals("5", body["installation_id"]?.jsonPrimitive?.content) + if (body["password"]?.jsonPrimitive?.content != "local-pw") return@api problem("validation_failed", 422, "body.password") + respond( + """{"id":"8","installation_id":"5","provider_id":"plugin:5:tailscale","provider_name":"Tailscale", + "username":"alice@example.test","email":"alice@example.test","display_name":"Alice Example", + "linked_at":"2026-01-02T03:04:05.000Z","last_sign_in_at":null,"last_checked_at":"2026-01-03T16:07:08.000Z"}""", + HttpStatusCode.Created, + headersOf(HttpHeaders.ContentType to listOf("application/json"), HttpHeaders.Location to listOf("/api/v2/account/identities")), + ) + } + try { + val linked = assertIs>(api.linkWithNetwork(scope, "5", "local-pw")).data + assertEquals("Tailscale", linked.providerName) + assertEquals("alice@example.test", linked.accountLabel) + val wrong = assertIs(api.linkWithNetwork(scope, "5", "wrong")) + assertEquals(422, wrong.code) + assertEquals(DefaultExternalSignInApi.WRONG_PASSWORD, wrong.error) + } finally { client.close() } + } + + @Test + fun linkWithNetworkRefusalsAreDistinguishable() = runTest { + val cases = listOf( + Triple("network_identity_required", 403, null) to "network_identity_required", + Triple("validation_failed", 422, "body.password") to DefaultExternalSignInApi.WRONG_PASSWORD, + Triple("local_password_required", 409, null) to "local_password_required", + Triple("not_permitted", 403, null) to "not_permitted", + Triple("permission_denied", 403, null) to "permission_denied", + Triple("identity_linked_elsewhere", 409, null) to "identity_linked_elsewhere", + Triple("already_linked", 409, null) to "already_linked", + Triple("not_found", 404, null) to "not_found", + Triple("provider_unavailable", 503, null) to "provider_unavailable", + Triple("rate_limited", 429, null) to "rate_limited", + ) + for ((problem, expected) in cases) { + val (code, status, location) = problem + val (client, api) = api { problem(code, status, location) } + try { + val error = assertIs(api.linkWithNetwork(scope, "5", "pw")) + assertEquals(status, error.code, code) + assertEquals(expected, error.error, code) + } finally { client.close() } + } + // A success other than 201 is not an identity. + val (client, api) = api { json("{}") } + try { + assertEquals("invalid_response", assertIs(api.linkWithNetwork(scope, "5", "pw")).error) + } finally { client.close() } + } + @Test fun identitiesAreListedAndDisconnectedThroughTheAccountScope() = runTest { val calls = mutableListOf() @@ -239,6 +421,7 @@ class ExternalSignInApiTest { CompleteOAuthLoginV2(code = "secret-code", codeVerifier = "secret-verifier").toString(), LinkTicketRequestV2(installationId = "3", password = "secret-pw").toString(), LinkCredentialsRequestV2("6", "secret-pw", "secret-user", "secret-dir").toString(), + LinkNetworkRequestV2(installationId = "5", password = "secret-pw").toString(), ) strings.forEach { assertFalse(it.contains("secret"), it) } } @@ -304,6 +487,21 @@ class SignInOptionsTest { assertEquals(PasswordLoginFailure.RateLimited, PasswordLoginFailure.of(429, "")) } + @Test + fun aNetworkProviderNeedsItsSignInPathAndChangesNothingElse() { + val network = SignInProvider( + "plugin:7:tailscale", "Tailscale", SignInProvider.Mode.Network, false, null, "7", null, + networkSignInPath = "/api/v2/auth/network/7/sign-in", + networkIdentity = NetworkIdentity("Alice", "alice"), + ) + val withNetwork = SignInOptions.of(SignInProviders(listOf(oidc, network), passwordLogin = false), native) + assertEquals(network, withNetwork.networkProvider) + assertFalse(withNetwork.showPasswordForm, "a network provider never takes a password") + assertEquals(listOf(oidc), withNetwork.oauthProviders) + assertNull(SignInOptions.of(SignInProviders(listOf(network.copy(networkSignInPath = null)), true), native).networkProvider) + assertNull(SignInOptions.PasswordOnly.networkProvider) + } + @Test fun selectAccountNeedsAProviderButton() { val choosing = native.copy(selectAccount = true) @@ -341,6 +539,7 @@ class SignInOptionsTest { override suspend fun oauthCapabilities(serverUrl: String) = handshake override suspend fun externalSignInCapabilities(scope: AuthScopeSnapshot) = TODO() override suspend fun completeOAuthLogin(serverUrl: String, code: String, codeVerifier: String) = TODO() + override suspend fun signInWithNetworkIdentity(serverUrl: String, signInPath: String) = TODO() override suspend fun listIdentities(scope: AuthScopeSnapshot) = TODO() override suspend fun deleteIdentity(scope: AuthScopeSnapshot, identityId: String) = TODO() override suspend fun createLinkTicket(scope: AuthScopeSnapshot, installationId: String, password: String) = TODO() @@ -352,5 +551,6 @@ class SignInOptionsTest { username: String, directoryPassword: String, ) = TODO() + override suspend fun linkWithNetwork(scope: AuthScopeSnapshot, installationId: String, password: String) = TODO() } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt index 68a0d4d229..813057a080 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt @@ -20,14 +20,17 @@ import org.siloserver.silo.network.api.DevicePollV2 import org.siloserver.silo.network.api.ServerConnectionsV2 import org.siloserver.silo.network.api.ServerIdentityV2 import org.siloserver.silo.network.api.AccountIdentityCollectionV2 +import org.siloserver.silo.network.api.AccountIdentityV2 import org.siloserver.silo.network.api.AuthProviderCollectionV2 import org.siloserver.silo.network.api.DefaultExternalSignInApi import org.siloserver.silo.network.api.ExternalSignInCapabilitiesV2 import org.siloserver.silo.network.api.LinkTicketV2 import org.siloserver.silo.network.api.OAuthHandshakeCapabilitiesV2 import org.siloserver.silo.network.api.TokenPairV2 +import org.siloserver.silo.model.auth.OAuthHandshakeCapabilities import org.siloserver.silo.model.auth.SignInOptions import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.model.auth.SignInProviders import org.siloserver.silo.pairing.PairingEndpoint /** @@ -77,6 +80,8 @@ class ApiV2ContractTest { "completeOAuthLogin" -> ApiV2Fixtures.decode(body) "listAccountIdentities" -> ApiV2Fixtures.decode(body) "createAccountIdentityLinkTicket" -> ApiV2Fixtures.decode(body) + "signInWithNetworkIdentity" -> ApiV2Fixtures.decode(body) + "linkAccountIdentityWithNetwork" -> ApiV2Fixtures.decode(body) else -> error("unhandled success fixture ${entry.name} (${entry.operationId})") } } @@ -457,11 +462,90 @@ class ApiV2ContractTest { with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "credentials_linking" to null), ).domain() assertFalse(older.credentialsLinking, "absent credentials_linking means the operation isn't served") + assertTrue(capabilities.networkSignIn) + val beforeNetwork = ApiV2Fixtures.decode( + with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "network_sign_in" to null), + ).domain() + assertFalse(beforeNetwork.networkSignIn, "absent network_sign_in means the operations aren't served") + assertTrue(beforeNetwork.credentialsLinking) val unsupported = ApiV2Fixtures.decode( with(ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok"), "state" to JsonPrimitive("unsupported")), ).domain() assertFalse(unsupported.identities) assertFalse(unsupported.credentialsLinking) + assertFalse(unsupported.networkSignIn) + } + + // --- Network identity (signInWithNetworkIdentity, linkAccountIdentityWithNetwork) --- + + @Test + fun networkSignInFixtureCarriesTheTokenPairLoginAnswers() { + val login = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("sign_in_with_network_identity_ok").plusUnknown()).domain() + assertEquals("acc", login.accessToken) + assertEquals("ref", login.refreshToken) + assertEquals(3600L, login.expiresIn) + assertEquals("laura", login.user.username) + // The contract's request is the one the app sends: a POST of an empty JSON object. + val request = ApiV2Fixtures.index.fixtures.single { it.name == "sign_in_with_network_identity_ok" }.request + assertEquals("POST", request.method) + assertEquals("{}", request.body) + assertEquals("/api/v2/auth/network/5/sign-in", org.siloserver.silo.network.api.networkSignInPath(request.path)) + } + + @Test + fun networkSignInOffTheOverlayIsNetworkIdentityRequired() { + val problem = ApiV2Fixtures.decode(ApiV2Fixtures.bodyObject("sign_in_with_network_identity_off_overlay")) + assertEquals(403, problem.status) + assertEquals("network_identity_required", problem.code) + assertEquals( + org.siloserver.silo.model.auth.NetworkSignInFailure.NetworkIdentityRequired, + org.siloserver.silo.model.auth.NetworkSignInFailure.of(problem.status, problem.code), + ) + } + + @Test + fun networkLinkFixtureIsTheLinkedIdentity() { + val identity = ApiV2Fixtures.decode( + ApiV2Fixtures.bodyObject("link_account_identity_with_network_ok").plusUnknown(), + ).domain() + assertEquals("8", identity.id) + assertEquals("5", identity.installationId) + assertEquals("Tailscale", identity.providerName) + assertEquals("alice@example.test", identity.accountLabel) + assertNull(identity.lastSignInAt) + } + + /** + * A network provider as `listAuthProviders` lists it over its own network + * (the provider list fixture is read off it, so it shows none): apps get + * the sign-in route relative to the server base, like the native start. + */ + @Test + fun networkProviderSignInPathIsReducedToTheBaseRelativeRoute() { + fun provider(fields: String) = ApiV2Fixtures.decode( + ApiV2Fixtures.json.parseToJsonElement( + """{"items":[{"id":"plugin:5:tailscale","display_name":"Tailscale","mode":"network","default":false, + "installation_id":"5"$fields}],"password_login":true}""", + ).jsonObject, + ).domain("https://silo.tailnet.ts.net").providers.single() + val route = "/api/v2/auth/network/5/sign-in" + val listed = provider( + ""","network_sign_in_path":"$route","network_identity":{"display_name":"","username":"alice@example.test"}""", + ) + assertEquals(SignInProvider.Mode.Network, listed.mode) + assertEquals(route, listed.networkSignInPath) + assertEquals("alice@example.test", listed.networkIdentity?.label) + assertEquals(route, provider(""","network_sign_in_path":"/silo/api/v2/auth/network/5/sign-in"""").networkSignInPath) + assertNull(provider("").networkIdentity) + listOf( + "", + ""","network_sign_in_path":"/api/v2/auth/network/5/other"""", + ""","network_sign_in_path":"//evil.example.test/api/v2/auth/network/5/sign-in"""", + ""","network_sign_in_path":"https://public.example.test/api/v2/auth/network/5/sign-in"""", + ""","network_sign_in_path":"/api/v2/auth/oauth/5/native/start"""", + ).forEach { assertNull(provider(it).networkSignInPath, it) } + // Without its path the provider offers nothing. + assertNull(SignInOptions.of(SignInProviders(listOf(provider("")), true), OAuthHandshakeCapabilities.None).networkProvider) } @Test @@ -523,5 +607,6 @@ class ApiV2ContractTest { assertTrue(options.showPasswordForm) assertEquals(listOf("Example SSO"), options.oauthProviders.map { it.displayName }) assertNull(options.directoryProvider) + assertNull(options.networkProvider, "a listing that didn't come through a network provider offers none") } } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE index 69700c7695..7061a91fe4 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE +++ b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE @@ -1,7 +1,7 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/api/v2/fixtures -commit=9493206e837b3adfc16e4d404ae06342ca7baa64 -ref=t3code/oidc-sso-auth-support +commit=85e1690fbec9e0dd78151487ef6b0f8e7b00fc1a +ref=t3code/tsidp-oidc-feasibility snapshot=committed api_major=2 diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json index 439f441f48..752c4ca7bb 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json @@ -1,5 +1,5 @@ { - "revision": "39f58f9b45a058cfae314f94a03b66f3f100a02c7a4d45aac56c1c1a7e7600bb", + "revision": "cf5d430e08bbf554e6b2b0b1059724253fda155248f66d6f39edea8465e248c7", "state": "available", "available": true, "identities": true, @@ -8,5 +8,6 @@ "connection_test": true, "live_provider_changes": true, "provider_recheck": true, - "credentials_linking": true + "credentials_linking": true, + "network_sign_in": true } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json index 82a00b67c9..a562cca713 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "0536b7227b71a593baed0be1dc81a9603517c8948b429ab5d34a7fa3bf064c39", + "contract_digest": "aba661db5ba93bda7e22444f124496156beee44ebf2662199d3fd596eb218fa1", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/index.json b/shared/src/commonTest/resources/api/v2/fixtures/index.json index 7acf67dcd9..112099ec93 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/index.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/index.json @@ -281,6 +281,28 @@ "schema": "#/components/schemas/SystemInfo", "body_file": "get_system_info_ok.json" }, + { + "name": "link_account_identity_with_network_ok", + "operation_id": "linkAccountIdentityWithNetwork", + "scenario": "The account re-enters its password and links the Tailscale identity of the device it is using.", + "request": { + "method": "POST", + "path": "/api/v2/account/identities/link-network", + "headers": { + "Authorization": "Bearer tok-member" + }, + "body": "{\"installation_id\":\"5\",\"password\":\"right password\"}" + }, + "expected_status": 201, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json", + "Location": "/api/v2/account/identities" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AccountIdentity", + "body_file": "link_account_identity_with_network_ok.json" + }, { "name": "list_account_identities_ok", "operation_id": "listAccountIdentities", @@ -553,6 +575,42 @@ "schema": "#/components/schemas/ServerIdentity", "body_file": "server_identity_ok.json" }, + { + "name": "sign_in_with_network_identity_off_overlay", + "operation_id": "signInWithNetworkIdentity", + "scenario": "A request that did not come through the provider's network has no device owner to sign in.", + "request": { + "method": "POST", + "path": "/api/v2/auth/network/7/sign-in", + "body": "{}" + }, + "expected_status": 403, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "sign_in_with_network_identity_off_overlay.json" + }, + { + "name": "sign_in_with_network_identity_ok", + "operation_id": "signInWithNetworkIdentity", + "scenario": "A TV on the server's Tailscale network signs its owner in with no password: the body is an empty JSON object.", + "request": { + "method": "POST", + "path": "/api/v2/auth/network/5/sign-in", + "body": "{}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/TokenPair", + "body_file": "sign_in_with_network_identity_ok.json" + }, { "name": "start_device_login_ok", "operation_id": "startDeviceLogin", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/link_account_identity_with_network_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/link_account_identity_with_network_ok.json new file mode 100644 index 0000000000..d02a3c2b39 --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/link_account_identity_with_network_ok.json @@ -0,0 +1,12 @@ +{ + "id": "8", + "installation_id": "5", + "provider_id": "plugin:5:tailscale", + "provider_name": "Tailscale", + "username": "alice@example.test", + "email": "alice@example.test", + "display_name": "Alice Example", + "linked_at": "2026-01-02T03:04:05.000Z", + "last_sign_in_at": null, + "last_checked_at": "2026-01-03T16:07:08.000Z" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json new file mode 100644 index 0000000000..667eacbd0b --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/network_identity_required", + "title": "Network identity required", + "status": 403, + "detail": "Open this server through the provider's network address to sign in this way", + "instance": "urn:silo:request:0000000000000000000002ca" +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_ok.json new file mode 100644 index 0000000000..cde55f2cca --- /dev/null +++ b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_ok.json @@ -0,0 +1,16 @@ +{ + "access_token": "acc", + "refresh_token": "ref", + "expires_in": 3600, + "user": { + "id": "1", + "username": "laura", + "email": "laura@example.test", + "role": "user", + "permissions": [ + "marker_edit" + ], + "download_allowed": true, + "password_change_required": false + } +} From 32488e840fd52f7b8e702524ccd1c1179e9f7c55 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:40:37 -0400 Subject: [PATCH 16/87] fix(tv): play phone-launched titles with sequenced playback (#380) * fix(tv): play phone-launched titles with sequenced playback Sequenced playback required a saved login, so a title a phone launched on the TV under its temporary remote-playback profile failed to start with identity_unavailable. Attempts admitted under a temporary identity now run in memory only: their credentials die with the process, so they stay out of the durable journal and recovery, and only the identity that started an attempt may act for it. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): settle a phone's uncertain start before its next title The start fence compared pending attempts with the saved login's marker, so a start sent under a temporary remote-playback identity whose reply was lost was never settled: the next title started beside an orphaned server session. The fence now uses the current identity's marker, and a temporary identity settles its own uncertain attempts (replay the start, then stop) with its own scope before admitting another. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): drop an ended remote-playback identity's attempts A temporary attempt whose identity ended before its stop was confirmed never settled, so compaction never removed it: each interrupted handoff kept its session and history for the life of the TV process, and owns() kept answering for it. Saving now drops temporary attempts that don't belong to the current identity. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): forget an ended remote-playback identity's captured scope Dropping the attempt from the journal left its captured scope, adoption and subtitle headers (which hold the bearer header) in memory. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): don't restore the scope of an attempt pruned while saving Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): report an identity change when a phone's progress retry is pruned If the phone's remote-playback identity ended while a retried progress sample was in flight, saving the receipt dropped the attempt and the follow-up lookup threw, so the player saw playback_storage instead of identity_changed. Look the attempt up nullably and answer identity_changed. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/repository/SequencedPlayback.kt | 98 ++++++++++--- .../silo/repository/SequencedPlaybackTest.kt | 132 ++++++++++++++++++ 2 files changed, 214 insertions(+), 16 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt index ee83152384..56df3db3e5 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt @@ -59,8 +59,18 @@ class SequencedPlayback( private companion object { /** Settled attempts retained as tombstones. Bounds the journal's size. */ const val SETTLED_TOMBSTONE_LIMIT = 8 + + /** Login marker for attempts admitted under a temporary remote-playback identity. */ + const val TEMPORARY_LOGIN_PREFIX = "temporary:" } + /** + * Admitted under a phone's temporary remote-playback identity. Those + * credentials die with the process, so a restart could never replay the + * attempt: it stays in memory, out of the durable journal and recovery. + */ + private val PlaybackJournalEntry.isTemporary: Boolean get() = loginId.startsWith(TEMPORARY_LOGIN_PREFIX) + private val mutex = Mutex() private var entries: List? = null private val adopted = mutableSetOf() @@ -85,17 +95,32 @@ class SequencedPlayback( private fun publish() { liveAttempts = entries.orEmpty().filter { !it.terminal && it.stop == null }.map { it.attemptId }.toSet() _sessions.value = entries.orEmpty().mapNotNull { it.sessionId }.toSet() - _pending.value = entries.orEmpty().filter { it.needsRecovery() } + _pending.value = entries.orEmpty().filter { !it.isTemporary && it.needsRecovery() } .map { it.attemptId } } private suspend fun save(entry: PlaybackJournalEntry) { - val next = compactSettled(load().filterNot { it.attemptId == entry.attemptId } + entry) - store.write(next) - entries = next + val (temporary, durable) = (load().filterNot { it.attemptId == entry.attemptId } + entry).partition { it.isTemporary } + // Compacted apart, so titles a phone launched never crowd durable tombstones out. + val kept = compactSettled(durable) + if (!entry.isTemporary) store.write(kept) + // Only the identity that admitted a temporary attempt may act for it, so an ended + // identity's attempts can never settle; drop them rather than keep them for the + // life of the process, along with the scope and subtitle headers they captured. + val liveTemporary = tokens.snapshotCurrentScope()?.credentialGenerationId?.let { TEMPORARY_LOGIN_PREFIX + it } + val (current, ended) = temporary.partition { it.loginId == liveTemporary } + entries = kept + compactSettled(current) + ended.forEach { forget(it.attemptId) } if (entry.terminal || entry.stop != null) auxiliaryHeaders.remove(entry.attemptId) publish() } + private fun forget(attemptId: String) { + scopes.remove(attemptId) + adopted.remove(attemptId) + auxiliaryHeaders.remove(attemptId) + auxiliaryGenerations = auxiliaryGenerations - attemptId + } + /** * A settled attempt keeps a tombstone, not its history. * @@ -129,8 +154,15 @@ class SequencedPlayback( private fun failure(code: String, message: String) = ApiResult.Error(0, code, message) private fun authorityChanged() = failure("identity_changed", "Playback authority changed.") private suspend fun scope(entry: PlaybackJournalEntry): AuthScopeSnapshot? { - val live = authorities.snapshotDurableLoginAuthority() ?: return null val captured = scopes[entry.attemptId] ?: return null // Restart requires explicit recovery. + if (entry.isTemporary) { + // Only the temporary identity that admitted the attempt may act for it. + return tokens.snapshotCurrentScope()?.takeIf { + captured.isSameIdentityAs(it) && it.credentialGenerationId == captured.credentialGenerationId && + it.serverId == entry.serverId && it.serverUrl == entry.origin && it.profileId == entry.profileId + } + } + val live = authorities.snapshotDurableLoginAuthority() ?: return null return live.scope.takeIf { live.loginId == entry.loginId && it.serverId == entry.serverId && it.serverUrl == entry.origin && it.profileId == entry.profileId && @@ -172,13 +204,16 @@ class SequencedPlayback( if (!current.isSameIdentityAs(tokens.snapshotCurrentScope())) throw IdentityChanged() val live = authorities.snapshotDurableLoginAuthority() guard() - suspend fun unresolved() = load().any { it.needsRecovery() && it.loginId == live?.loginId && + // A temporary identity fences (and settles) only the attempts it admitted itself. + val temporaryLoginId = current.credentialGenerationId?.let { TEMPORARY_LOGIN_PREFIX + it } + val fenceLoginId = temporaryLoginId ?: live?.loginId + suspend fun unresolved() = load().any { it.needsRecovery() && it.loginId == fenceLoginId && it.serverId == current.serverId && it.profileId == current.profileId } guard() if (unresolved()) { // Settle the earlier attempt first (replay an uncertain start, then stop it) so a // crashed or rejected session never needs a manual recovery step before playing again. - val settled = try { recoverLocked() } + val settled = try { if (temporaryLoginId != null) recoverTemporaryLocked(current, temporaryLoginId) else recoverLocked() } catch (e: CancellationException) { throw e } catch (e: IdentityChanged) { throw e } catch (_: Exception) { failure("playback_storage", "Playback recovery storage is unavailable.") } @@ -194,9 +229,13 @@ class SequencedPlayback( if (!capability.allowed || capability.state != PlaybackCapabilityStateV2.AVAILABLE || capability.installationId.isNullOrBlank() || 3 !in capability.protocolVersions) return@withStableIdentity failure("playback_unavailable", "Sequenced playback is unavailable for this profile.") - if (live == null || !current.isSameIdentityAs(live.scope) || live.scope.credentialGenerationId != null || - current.profileId != request.profileId) - return@withStableIdentity failure("identity_unavailable", "Sequenced playback needs a saved login and matching profile.") + val loginId = when { + current.profileId != request.profileId -> null + // A phone launched this title on the TV under its own profile. + current.credentialGenerationId != null -> TEMPORARY_LOGIN_PREFIX + current.credentialGenerationId + live != null && current.isSameIdentityAs(live.scope) && live.scope.credentialGenerationId == null -> live.loginId + else -> null + } ?: return@withStableIdentity failure("identity_unavailable", "Sequenced playback needs a saved login and matching profile.") val account = when (val result = api.account(current)) { is ApiResult.Success -> result.data is ApiResult.Error -> return@withStableIdentity result @@ -207,10 +246,11 @@ class SequencedPlayback( if (load().any { it.attemptId == request.playbackAttemptId }) return@withStableIdentity failure("attempt_exists", "This playback attempt is already recorded. Use recovery.") guard() - val entry = PlaybackJournalEntry(current.serverId, current.serverUrl, live.loginId, account.id, + val entry = PlaybackJournalEntry(current.serverId, current.serverUrl, loginId, account.id, request.profileId, capability.installationId, request.playbackAttemptId, request.v2Body(capability.installationId)) save(entry) - scopes[entry.attemptId] = current + // Unless save() just pruned it: its temporary identity ended in between. + if (load().any { it.attemptId == entry.attemptId }) scopes[entry.attemptId] = current // A saved attempt is uncertainty, even if its owner changes before send. guard() sendStart(entry, current, expectedMetadataOwner = expectedMetadataOwner) @@ -273,9 +313,12 @@ class SequencedPlayback( require(plan.sessionId == sessionId) val ephemeral = ProxyAuxiliaryRequestHeaders(plan.stream.url, sessionId, references, headers) { // Lock-free: a playback request in flight under the mutex must not stall subtitle loads. - val live = authorities.snapshotDurableLoginAuthority() - live?.loginId == entry.loginId && live.scope == captured && - auxiliaryGenerations[entry.attemptId] == generation && entry.attemptId in liveAttempts + val current = if (entry.isTemporary) { + tokens.snapshotCurrentScope() + } else { + authorities.snapshotDurableLoginAuthority()?.takeIf { it.loginId == entry.loginId }?.scope + } + current == captured && auxiliaryGenerations[entry.attemptId] == generation && entry.attemptId in liveAttempts } auxiliaryHeaders[entry.attemptId] = ephemeral return decision.copy(playbackPlan = plan.copy(stream = plan.stream.copy(auxiliaryRequestHeaders = ephemeral))) @@ -376,7 +419,8 @@ class SequencedPlayback( if (entry.progress != null) { val retry = sendProgress(entry, captured) if (retry !is ApiResult.Success) return@withLock retry - entry = load().first { it.attemptId == entry.attemptId } + // Gone when the retry's save() pruned it: its temporary identity ended in flight. + entry = load().find { it.attemptId == entry.attemptId } ?: return@withLock authorityChanged() } if (entry.sequence == Long.MAX_VALUE) return@withLock failure("sequence_exhausted", "Playback sample sequence exhausted.") val sample = PlaybackProgressV2(entry.installationId, entry.sequence + 1, position, paused) @@ -444,6 +488,28 @@ class SequencedPlayback( /** Explicit recovery revalidates installation, canonical account, saved login and profile. Never autoplay. */ suspend fun recover(): ApiResult = mutex.withLock { recoverLocked() } + /** + * [recoverLocked] for a temporary remote-playback identity, which has no saved login to + * recover under: settles the attempts this identity admitted (replay an uncertain start, + * then stop), using its own scope. + */ + private suspend fun recoverTemporaryLocked(current: AuthScopeSnapshot, loginId: String): ApiResult { + for (entry in load().filter { it.needsRecovery() && it.loginId == loginId && + it.serverId == current.serverId && it.profileId == current.profileId }) { + if (scope(entry) == null) continue + if (entry.sessionId == null) { + when (val startResult = sendStart(entry, current, adoptForPlayer = false)) { + is ApiResult.Success -> Unit + is ApiResult.Error -> return startResult + is ApiResult.NetworkError -> return startResult + } + } + val result = stopEntry(load().first { it.attemptId == entry.attemptId }) + if (result !is ApiResult.Success) return result + } + return ApiResult.Success(Unit) + } + private suspend fun recoverLocked(): ApiResult { val live = authorities.snapshotDurableLoginAuthority() ?: return failure("identity_unavailable", "Sign in to recover playback.") diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt index 49d4451986..52c76075bb 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt @@ -473,4 +473,136 @@ class SequencedPlaybackTest { assertFailsWith { decodePlaybackDecisionV2(body) } } + /** A title a phone launches on the TV plays under process-only remote-playback credentials. */ + private fun remotePlaybackIdentity() = Identity().apply { + temporary = true + scope = scope.copy(credentialGenerationId = "remote-1") + } + + @Test fun remotePlaybackIdentityPlaysWithoutTheDurableJournal() = runTest { + val identity = remotePlaybackIdentity() + val store = Store().apply { fail = true } // Any journal write would fail the call. + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> reply(adoptedDecision, HttpStatusCode.Created) + "/api/v2/playback/session-1/progress" -> { + val sample = SiloJson.decodeFromString(req.body.toByteArray().decodeToString()) + reply("""{"outcome":"applied","accepted":{"sequence":${sample.sequence},"position":${sample.position},"is_paused":${sample.isPaused}}}""") + } + "/api/v2/playback/session-1" -> reply("""{"outcome":"stopped","stop_id":"$stopId"}""") + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } + assertIs>(runtime.start(request())) + assertIs>(runtime.progress("session-1", 12.0, false)) + assertIs>(runtime.stop("session-1")) + assertTrue(store.entries.isEmpty()) + assertTrue(runtime.pending.value.isEmpty()) + } finally { c.close() } + } + + @Test fun uncertainRemotePlaybackStartIsSettledBeforeTheNextOne() = runTest { + val identity = remotePlaybackIdentity() + val starts = mutableListOf(); val deletes = mutableListOf() + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { + val attempt = SiloJson.parseToJsonElement(req.body.toByteArray().decodeToString()) + .jsonObject["playback_attempt_id"]!!.jsonPrimitive.content + starts += attempt + // The first start's reply is lost; its replay and the next start succeed. + if (starts.size == 1) throw IllegalStateException("lost reply") + reply(adoptedDecision.replace("session-1", "session-$attempt"), HttpStatusCode.Created) + } + "/api/v2/playback/session-attempt-1" -> { + deletes += "attempt-1" + reply("""{"outcome":"stopped","stop_id":"$stopId"}""") + } + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, Store()) { stopId } + assertIs(runtime.start(request())) + // The phone sends another title: the lost start is replayed and stopped first. + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(listOf("attempt-1", "attempt-1", "attempt-2"), starts) + assertEquals(listOf("attempt-1"), deletes) + } finally { c.close() } + } + + @Test fun endedRemotePlaybackAttemptsAreDroppedOnTheNextSave() = runTest { + val identity = remotePlaybackIdentity() + var starts = 0 + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { starts++; reply(adoptedDecision.replace("session-1", "session-$starts"), HttpStatusCode.Created) } + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, Store()) { stopId } + assertIs>(runtime.start(request())) + assertTrue(runtime.owns("session-1")) + // The TV restores its own login before the phone's title stopped. + identity.temporary = false + identity.scope = identity.scope.copy(credentialGenerationId = null, identityGeneration = 2) + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertFalse(runtime.owns("session-1")) + assertTrue(runtime.owns("session-2")) + } finally { c.close() } + } + + @Test fun remotePlaybackIdentityEndingDuringAProgressRetryIsAnIdentityChange() = runTest { + val identity = remotePlaybackIdentity() + var progressCalls = 0 + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> reply(adoptedDecision, HttpStatusCode.Created) + "/api/v2/playback/session-1/progress" -> { + progressCalls++ + // The first sample's reply is lost, so the next call retries it first. + if (progressCalls == 1) throw IllegalStateException("lost reply") + // The TV restores its own login while the retry is in flight. + identity.temporary = false + identity.scope = identity.scope.copy(credentialGenerationId = null, identityGeneration = 2) + reply("""{"outcome":"applied","accepted":{"sequence":1,"position":12.0,"is_paused":false}}""") + } + else -> error("Unexpected request ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, Store()) { stopId } + assertIs>(runtime.start(request())) + assertIs(runtime.progress("session-1", 12.0, false)) + assertEquals("identity_changed", assertIs(runtime.progress("session-1", 13.0, false)).error) + assertEquals(2, progressCalls) + } finally { c.close() } + } + + @Test fun endedRemotePlaybackIdentityCannotActForItsAttempt() = runTest { + val identity = remotePlaybackIdentity() + val store = Store() + val c = client { req -> when (req.url.encodedPath) { + "/api/v2/playback/capabilities" -> reply(caps()) + "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> reply(adoptedDecision, HttpStatusCode.Created) + else -> error("Must not act for an ended identity: ${req.url}") + } } + try { + val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } + assertIs>(runtime.start(request())) + // The TV restores its own saved login. + identity.temporary = false + identity.scope = identity.scope.copy(credentialGenerationId = null, identityGeneration = 2) + assertEquals("identity_changed", assertIs(runtime.progress("session-1", 12.0, false)).error) + assertEquals("identity_changed", assertIs(runtime.stop("session-1")).error) + // Nothing to recover later: the credentials are gone for good. + assertTrue(store.entries.isEmpty()) + assertTrue(runtime.pending.value.isEmpty()) + } finally { c.close() } + } + } From 844ff74b1dbec66def8e29381b04e084ddb06c85 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:13:23 -0400 Subject: [PATCH 17/87] feat(onboarding): redesign the first-run flow on Android phone and TV (#459) * feat(onboarding): redesign the first-run flow on Android phone and TV Port of the Apple first-run redesign (Silo-Server/silo-apple#586): one brand-light backdrop behind server setup, sign-in, profiles and PIN on both apps, tinted to the server's accent and to the selected person. - Phone: address field with Advanced options and Recent servers, a "Sign in" screen with the server chip and branded subtitle, round profile avatars sized to the household, full-screen PIN keypad. - TV: phone-first setup with three picture steps, a separate address screen, sign-in with the code card on the right, restyled pairing, picker and PIN. - A saved server that still has a session goes straight to profiles. - After a sign-in, a one-profile household with no PIN skips the picker. - Branding (subtitle, accent, mark) is read from the public branding endpoint; asset URLs are accepted only on the server's own origin. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): keep the server address fixed while it is probed Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/common/ui/marquee/MarqueeBackdrop.kt | 348 +++++ .../common/ui/marquee/MarqueeProfileAvatar.kt | 189 +++ .../silo/common/ui/marquee/MarqueeScene.kt | 79 ++ .../common/ui/marquee/MarqueeServerMark.kt | 74 ++ .../silo/common/ui/marquee/MarqueeShake.kt | 36 + .../silo/common/ui/marquee/ServerBranding.kt | 115 ++ .../silo/common/ui/marquee/SignInHandoff.kt | 16 + .../silo/common/ui/marquee/SignInServer.kt | 51 + .../common/ui/marquee/ServerBrandingTest.kt | 46 + .../silo/android/SiloApplication.kt | 3 +- .../silo/android/di/AndroidModule.kt | 5 +- .../ui/components/aurora/AuroraChrome.kt | 203 +-- .../ui/components/marquee/MarqueeControls.kt | 332 +++++ .../ui/components/marquee/MarqueeFields.kt | 191 +++ .../components/marquee/MarqueeServerChip.kt | 108 ++ .../ui/components/marquee/MarqueeStage.kt | 116 ++ .../android/ui/navigation/AppNavigation.kt | 41 + .../ui/screens/auth/InviteClaimScreen.kt | 305 ++--- .../android/ui/screens/auth/LoginScreen.kt | 507 +++++--- .../ui/screens/auth/ServerSetupScreen.kt | 436 +++++-- .../ui/screens/auth/ServerSetupViewModel.kt | 23 +- .../android/ui/screens/auth/SetupScreen.kt | 139 +- .../android/ui/screens/auth/SignInLabels.kt | 25 + .../ui/screens/auth/SignInProviderButton.kt | 153 --- .../android/ui/screens/auth/SignupScreen.kt | 153 +-- .../ui/screens/profiles/PINEntryDialog.kt | 270 ---- .../ui/screens/profiles/PINEntryOverlay.kt | 284 ++++ .../screens/profiles/ProfilePickerLayout.kt | 66 + .../profiles/ProfileSelectionScreen.kt | 727 ++++++----- .../profiles/ProfileSelectionViewModel.kt | 31 +- .../profiles/ProfilePickerLayoutTest.kt | 64 + .../siloserver/silo/tv/SiloTvApplication.kt | 3 +- .../siloserver/silo/tv/di/AndroidTvModule.kt | 5 +- .../silo/tv/ui/components/TvAuroraChrome.kt | 393 ------ .../silo/tv/ui/components/TvPinEntryDialog.kt | 200 +-- .../ui/components/marquee/TvActiveServer.kt | 26 + .../tv/ui/components/marquee/TvMarquee.kt | 425 ++++++ .../ui/components/marquee/TvMarqueeField.kt | 161 +++ .../components/marquee/TvMarqueeSetupSteps.kt | 157 +++ .../silo/tv/ui/navigation/TvAppNavigation.kt | 21 + .../silo/tv/ui/screens/auth/TvLoginScreen.kt | 1153 +++++++---------- .../silo/tv/ui/screens/auth/TvPairingPanel.kt | 518 ++++---- .../tv/ui/screens/auth/TvServerSetupScreen.kt | 811 +++--------- .../ui/screens/auth/TvServerSetupViewModel.kt | 14 +- .../silo/tv/ui/screens/auth/TvSetupScreen.kt | 341 ++--- .../silo/tv/ui/screens/auth/TvSignupScreen.kt | 342 ++--- .../profiles/TvProfileSelectionScreen.kt | 624 ++++----- .../profiles/TvProfileSelectionViewModel.kt | 26 +- .../src/androidMain/res/values/strings.xml | 4 +- .../components/TvControlWiringCallSiteTest.kt | 6 +- .../auth/TvLoginScreenNetworkSignInTest.kt | 8 +- .../ui/theme/TvTypographyReadabilityTest.kt | 4 + .../silo/network/api/BrandingApi.kt | 18 + 53 files changed, 5763 insertions(+), 4633 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeBackdrop.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeProfileAvatar.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeScene.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeServerMark.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeShake.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/ServerBranding.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInHandoff.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInServer.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/marquee/ServerBrandingTest.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeControls.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeFields.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeStage.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInLabels.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryDialog.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayout.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayoutTest.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvActiveServer.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarquee.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeField.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeBackdrop.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeBackdrop.kt new file mode 100644 index 0000000000..ee17137804 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeBackdrop.kt @@ -0,0 +1,348 @@ +package org.siloserver.silo.common.ui.marquee + +import android.app.ActivityManager +import android.content.Context +import android.provider.Settings +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +/** Silo's mark colors and the first-run ink, matching the Apple apps. */ +object MarqueeColors { + val BrandBlue = Color(0xFF0034FB) + val BrandRed = Color(0xFFF50B4F) + val BrandOrange = Color(0xFFFD7403) + val Ink = Color(0xFFEDEDED) + val InkSecondary = Ink.copy(alpha = 0.62f) + val InkTertiary = Ink.copy(alpha = 0.40f) + val Error = Color(0xFFFF6961) + val Live = Color(0xFF30D158) + val Warning = Color(0xFFF4C869) + val Hairline = Color.White.copy(alpha = 0.14f) + val Separator = Color.White.copy(alpha = 0.08f) + val GlassFill = Color.White.copy(alpha = 0.08f) +} + +/** True when the system asks for no animation (animator duration scale 0). */ +@Composable +fun rememberReduceMotion(): Boolean { + val context = LocalContext.current + return remember(context) { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } +} + +/** + * Brand light plus the server or profile tint. Place it once behind the + * first-run navigation and keep screens transparent over it. + * + * @param mirrored TV copy sits on the left under the darkest scrim, so the + * light pools on the right, behind the card. + */ +@Composable +fun MarqueeBackdrop(modifier: Modifier = Modifier, mirrored: Boolean = false) { + val context = LocalContext.current + val reduceMotion = rememberReduceMotion() + val drifts = remember(context, reduceMotion) { !reduceMotion && !isLowRam(context) } + + // Moving forward reads as the light moving, not a crossfade. + val stage = remember { Animatable(MarqueeScene.stage) } + LaunchedEffect(MarqueeScene.stage) { + stage.animateTo(MarqueeScene.stage, tween(if (reduceMotion) 150 else 1400)) + } + val tint by animateColorAsState( + targetValue = MarqueeScene.personalTint ?: MarqueeScene.accent ?: Color.Transparent, + animationSpec = tween(600), + label = "marqueeTint", + ) + + var phaseSeconds by remember { mutableFloatStateOf(0f) } + if (drifts) { + LaunchedEffect(Unit) { + val start = withFrameMillis { it } + var last = 0L + while (true) { + withFrameMillis { now -> + // About 30 frames a second is plenty for light this slow. + if (now - last >= 33) { + last = now + phaseSeconds = (now - start) / 1000f + } + } + } + } + } + + val light = remember { BrandLight() } + val grain = remember { ShaderBrush(ImageShader(grainTile(), TileMode.Repeated, TileMode.Repeated)) } + + Box(modifier.fillMaxSize()) { + Canvas(Modifier.fillMaxSize()) { + drawRect(Color.Black) + val image = light.render(stage.value, phaseSeconds) + if (mirrored) { + scale(scaleX = -1f, scaleY = 1f) { + drawImage(image, dstSize = IntSize(size.width.toInt(), size.height.toInt()), filterQuality = FilterQuality.Low) + } + } else { + drawImage(image, dstOffset = IntOffset.Zero, dstSize = IntSize(size.width.toInt(), size.height.toInt()), filterQuality = FilterQuality.Low) + } + if (tint.alpha > 0f) { + drawRect( + Brush.radialGradient( + colors = listOf(tint.copy(alpha = tint.alpha * 0.32f), tint.copy(alpha = 0f)), + center = Offset(size.width * 0.5f, size.height * 0.16f), + radius = max(size.width, size.height) * 0.7f, + ), + ) + } + // Fine noise hides banding in near-black ramps and gives the light + // a filmic texture. + drawRect(grain, alpha = 0.045f) + } + } +} + +private fun isLowRam(context: Context): Boolean = + (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.isLowRamDevice == true + +/** + * The mark's three colors as slow pools of light over black: one cool pool + * while choosing a server, all three once it answers, and a warm room that + * reaches lower behind the profiles. A 3x3 color mesh (as on Apple) drawn + * into a small bitmap and scaled up with filtering. + */ +private class BrandLight { + private val bitmap = android.graphics.Bitmap.createBitmap(W, H, android.graphics.Bitmap.Config.ARGB_8888) + private val image: ImageBitmap = bitmap.asImageBitmap() + private val pixels = IntArray(W * H) + + /** The nine mesh colors for this frame, as r, g, b in 0..1. */ + private val mesh = FloatArray(27) + + fun render(stage: Float, phase: Float): ImageBitmap { + val a = sin(phase / 13f) + val b = cos(phase / 19f) + val middle = staged(floatArrayOf(0.42f, 0.5f, 0.6f), stage) + val centerX = staged(floatArrayOf(0.3f, 0.55f, 0.5f), stage) + // Nine Color lerps a frame; the per-pixel blend below is plain floats. + for (i in 0 until 9) { + val color = stagedColor(i, stage) + mesh[i * 3] = color.red + mesh[i * 3 + 1] = color.green + mesh[i * 3 + 2] = color.blue + } + + // Mesh control points that move: the middle row and the middle column. + val topX = 0.5f + 0.12f * a + val bottomX = 0.5f - 0.1f * b + val leftY = middle + 0.06f * b + val rightY = middle - 0.06f * a + val cX = centerX + 0.1f * b + val cY = middle - 0.08f * a + + for (py in 0 until H) { + val y = (py + 0.5f) / H + for (px in 0 until W) { + val x = (px + 0.5f) / W + // Where the middle row and column cross this pixel. + val rowY = if (x < cX) lerp(leftY, cY, x / cX) else lerp(cY, rightY, (x - cX) / (1f - cX)) + val colX = if (y < rowY) lerp(topX, cX, y / rowY) else lerp(cX, bottomX, (y - rowY) / (1f - rowY)) + val u = if (x < colX) 0.5f * x / colX else 0.5f + 0.5f * (x - colX) / (1f - colX) + val v = if (y < rowY) 0.5f * y / rowY else 0.5f + 0.5f * (y - rowY) / (1f - rowY) + pixels[py * W + px] = sample(u.coerceIn(0f, 1f), v.coerceIn(0f, 1f)) + } + } + bitmap.setPixels(pixels, 0, W, 0, 0, W, H) + bitmap.prepareToDraw() + return image + } + + /** Smoothed bilinear blend of the 3x3 mesh at (u, v) in 0..1, as opaque ARGB. */ + private fun sample(u: Float, v: Float): Int { + val gx = (u * 2f).coerceAtMost(1.9999f) + val gy = (v * 2f).coerceAtMost(1.9999f) + val cx = gx.toInt() + val cy = gy.toInt() + val fx = smooth(gx - cx) + val fy = smooth(gy - cy) + val topLeft = (cy * 3 + cx) * 3 + val bottomLeft = topLeft + 9 + var argb = 0xFF shl 24 + for (channel in 0 until 3) { + val top = lerp(mesh[topLeft + channel], mesh[topLeft + 3 + channel], fx) + val bottom = lerp(mesh[bottomLeft + channel], mesh[bottomLeft + 3 + channel], fx) + val value = (lerp(top, bottom, fy) * 255f + 0.5f).toInt().coerceIn(0, 255) + argb = argb or (value shl (16 - channel * 8)) + } + return argb + } + + private fun smooth(t: Float) = t * t * (3f - 2f * t) + + private fun stagedColor(index: Int, stage: Float): Color { + val s = stage.coerceIn(0f, 2f) + val i = s.toInt().coerceAtMost(1) + return lerp(PALETTES[i][index], PALETTES[i + 1][index], s - i) + } + + private fun staged(values: FloatArray, stage: Float): Float { + val s = stage.coerceIn(0f, 2f) + val i = s.toInt().coerceAtMost(1) + return lerp(values[i], values[i + 1], s - i) + } + + private fun lerp(a: Float, b: Float, t: Float) = a + (b - a) * t + + companion object { + const val W = 48 + const val H = 32 + + private fun Color.dimmed(amount: Float) = lerp(this, Color.Black, amount) + private val blue = MarqueeColors.BrandBlue + private val red = MarqueeColors.BrandRed + private val orange = MarqueeColors.BrandOrange + private val black = Color.Black + + /** Row-major 3x3 mesh colors per stage. */ + val PALETTES = arrayOf( + arrayOf( + blue.dimmed(0.46f), blue.dimmed(0.7f), red.dimmed(0.8f), + blue.dimmed(0.8f), blue.dimmed(0.86f), red.dimmed(0.92f), + black, black, black, + ), + arrayOf( + blue.dimmed(0.5f), red.dimmed(0.58f), orange.dimmed(0.58f), + blue.dimmed(0.76f), red.dimmed(0.74f), orange.dimmed(0.82f), + black, black, black, + ), + arrayOf( + red.dimmed(0.56f), orange.dimmed(0.52f), blue.dimmed(0.56f), + orange.dimmed(0.7f), red.dimmed(0.66f), blue.dimmed(0.72f), + blue.dimmed(0.9f), red.dimmed(0.9f), orange.dimmed(0.92f), + ), + ) + } +} + +/** 96x96 premultiplied white noise, tiled over the light. */ +private fun grainTile(): ImageBitmap { + val side = 96 + val pixels = IntArray(side * side) + var state = 0x9E3779B9.toInt() + for (i in pixels.indices) { + state = state * 1_664_525 + 1_013_904_223 + val value = (state ushr 24) and 0xFF + pixels[i] = (value shl 24) or 0x00FFFFFF + } + return android.graphics.Bitmap.createBitmap(pixels, side, side, android.graphics.Bitmap.Config.ARGB_8888).asImageBitmap() +} + +/** Darkening each screen lays over the backdrop so text always sits on at least 86% black. */ +enum class MarqueeScrimStyle { + /** Phone: content anchored to the bottom. */ + Bottom, + + /** Same, darker higher up, for screens with more controls. */ + BottomDeep, + + /** Profiles and PIN: lighter, ambient. */ + Ambient, + + /** TV: copy on the left, the card on the right. */ + Leading, +} + +/** + * @param frostStart where an extra soft darkening band begins behind the + * controls, as a fraction of the height; null for none. + */ +@Composable +fun MarqueeScrim(style: MarqueeScrimStyle, modifier: Modifier = Modifier, frostStart: Float? = null) { + Canvas(modifier.fillMaxSize()) { + when (style) { + MarqueeScrimStyle.Bottom -> drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.62f), + 0.15f to Color.Black.copy(alpha = 0.08f), + 0.34f to Color.Black.copy(alpha = 0.25f), + 0.58f to Color.Black.copy(alpha = 0.86f), + 0.8f to Color.Black, + ), + ) + MarqueeScrimStyle.BottomDeep -> drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.62f), + 0.14f to Color.Black.copy(alpha = 0.2f), + 0.32f to Color.Black.copy(alpha = 0.55f), + 0.5f to Color.Black.copy(alpha = 0.92f), + 0.7f to Color.Black, + ), + ) + MarqueeScrimStyle.Ambient -> drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.35f), + 0.25f to Color.Black.copy(alpha = 0.2f), + 0.55f to Color.Black.copy(alpha = 0.75f), + 0.78f to Color.Black, + ), + ) + MarqueeScrimStyle.Leading -> { + drawRect( + Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.8f), + 0.3f to Color.Black.copy(alpha = 0.55f), + 0.58f to Color.Black.copy(alpha = 0.2f), + 0.8f to Color.Black.copy(alpha = 0.1f), + 1f to Color.Black.copy(alpha = 0.3f), + ), + ) + drawRect( + Brush.verticalGradient( + 0.65f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.5f), + ), + ) + } + } + if (frostStart != null) { + val end = (frostStart + 130.dpToFraction(size.height, density)).coerceAtMost(1f) + drawRect( + Brush.verticalGradient( + frostStart to Color.Transparent, + end to Color.Black.copy(alpha = 0.35f), + ), + ) + } + } +} + +private fun Int.dpToFraction(height: Float, density: Float): Float = if (height <= 0f) 0f else this * density / height diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeProfileAvatar.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeProfileAvatar.kt new file mode 100644 index 0000000000..da34823418 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeProfileAvatar.kt @@ -0,0 +1,189 @@ +package org.siloserver.silo.common.ui.marquee + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +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.compose.ui.draw.drawBehind +import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.common.ui.components.avatarRef +import org.siloserver.silo.common.ui.components.isEmojiAvatar +import org.siloserver.silo.common.ui.components.profileAvatarDisplayText +import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage +import org.siloserver.silo.model.profile.Profile + +/** + * Warm tile colors, one per profile id (DJB2), matching the Apple apps' + * `ProfileTilePalette` so a profile has the same color everywhere. + */ +object ProfileTilePalette { + private val colors = listOf( + Color(red = 0.850f, green = 0.460f, blue = 0.380f), // coral + Color(red = 0.400f, green = 0.560f, blue = 0.720f), // slate blue + Color(red = 0.690f, green = 0.540f, blue = 0.400f), // warm tan + Color(red = 0.460f, green = 0.620f, blue = 0.520f), // sage + Color(red = 0.780f, green = 0.480f, blue = 0.520f), // dusty rose + Color(red = 0.560f, green = 0.480f, blue = 0.720f), // lavender + Color(red = 0.360f, green = 0.580f, blue = 0.620f), // teal + Color(red = 0.780f, green = 0.640f, blue = 0.380f), // amber + ) + + fun tint(profileId: String): Color { + var h = 5381UL + for (byte in profileId.encodeToByteArray()) { + h = ((h shl 5) + h) + byte.toUByte().toULong() + } + return colors[(h % colors.size.toULong()).toInt()] + } +} + +/** + * One person's round avatar, like the cast rows on detail pages: their + * image, emoji or initial on their tint, with lock and KIDS badges riding on + * the circle. Badges and text scale with [size]. + * + * @param baseSize the size the badge metrics are designed for (92 phone, 110 TV). + */ +@Composable +fun MarqueeProfileAvatar( + profile: Profile, + size: Dp, + modifier: Modifier = Modifier, + baseSize: Dp = 92.dp, + showsBadges: Boolean = true, +) { + val scale = size / baseSize + val tint = ProfileTilePalette.tint(profile.id) + val avatar = profile.avatarRef() + val image = rememberProfileAvatarImage(avatar) + Box(modifier.size(size)) { + Box( + Modifier + .size(size) + .clip(CircleShape) + .drawBehind { + drawRect( + Brush.radialGradient( + colors = listOf(lerp(tint, Color.White, 0.35f), tint, lerp(tint, Color.Black, 0.45f)), + center = Offset(this.size.width * 0.3f, this.size.height * 0.25f), + radius = this.size.width * 0.8f, + ), + ) + } + .border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape), + contentAlignment = Alignment.Center, + ) { + when { + image != null -> ThumbhashImage( + url = image.url, + thumbhash = null, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + transparent = true, + cacheKey = image.cacheKey, + onError = image.onLoadFailed, + ) + isEmojiAvatar(avatar) -> BasicText( + text = avatar.avatar.orEmpty().trim(), + style = TextStyle(fontSize = (46f * scale).sp), + ) + else -> BasicText( + text = profileAvatarDisplayText(avatar = avatar, name = profile.name), + style = TextStyle( + color = Color.White.copy(alpha = 0.95f), + fontSize = (38f * scale).sp, + fontWeight = FontWeight.Bold, + ), + ) + } + } + if (showsBadges && profile.hasPin) { + val lock = 30.dp * scale + Box( + Modifier + .align(Alignment.BottomEnd) + .offset(x = lock * 0.08f, y = lock * 0.08f) + .size(lock) + .clip(CircleShape) + .background(Color(0xFF1C1C1E)) + .border(lock * 0.07f, Color.Black, CircleShape), + contentAlignment = Alignment.Center, + ) { + LockGlyph(lock * 0.42f) + } + } + if (showsBadges && profile.isChild) { + val font = 11f * scale + Box( + Modifier + .align(Alignment.TopEnd) + .offset(x = (font * 0.5f).dp, y = (-font * 0.2f).dp) + .height((font * 2f).dp) + .clip(CircleShape) + .background(Color.White) + .padding(horizontal = (font * 0.75f).dp), + contentAlignment = Alignment.Center, + ) { + BasicText( + "KIDS", + style = TextStyle(color = Color.Black, fontSize = font.sp, fontWeight = FontWeight.Black, letterSpacing = 0.4.sp), + ) + } + } + } +} + +/** A small padlock drawn with shapes, so this module needs no icon set. */ +@Composable +private fun LockGlyph(size: Dp) { + val ink = MarqueeColors.Ink.copy(alpha = 0.75f) + Box( + Modifier + .size(size) + .drawBehind { + val w = this.size.width + val h = this.size.height + val stroke = w * 0.16f + // Shackle. + drawArc( + color = ink, + startAngle = 180f, + sweepAngle = 180f, + useCenter = false, + topLeft = Offset(w * 0.24f, h * 0.04f), + size = androidx.compose.ui.geometry.Size(w * 0.52f, h * 0.6f), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke), + ) + drawRect(ink, topLeft = Offset(w * 0.24f, h * 0.34f), size = androidx.compose.ui.geometry.Size(stroke, h * 0.12f)) + drawRect(ink, topLeft = Offset(w * 0.76f - stroke, h * 0.34f), size = androidx.compose.ui.geometry.Size(stroke, h * 0.12f)) + // Body. + drawRoundRect( + color = ink, + topLeft = Offset(w * 0.1f, h * 0.44f), + size = androidx.compose.ui.geometry.Size(w * 0.8f, h * 0.56f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(w * 0.12f), + ) + }, + ) +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeScene.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeScene.kt new file mode 100644 index 0000000000..1374e3d059 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeScene.kt @@ -0,0 +1,79 @@ +package org.siloserver.silo.common.ui.marquee + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import org.koin.dsl.module + +/** + * Where the first-run flow is, shared by every first-run screen. One + * [MarqueeBackdrop] sits behind the whole flow and survives screen changes; + * screens describe the flow through this object and the backdrop moves + * between those states instead of cutting. + */ +object MarqueeScene { + enum class Focus { + /** Server and sign-in screens. */ + Account, + + /** Profile selection and PIN. */ + Profiles, + } + + /** False until a server is confirmed. */ + var serverConfirmed by mutableStateOf(false) + private set + + /** The confirmed server's accent, clamped to a dark, low-saturation tint. */ + var accent by mutableStateOf(null) + private set + + var focus by mutableStateOf(Focus.Account) + + /** Overrides [accent] on screens about one person (PIN, a pressed profile). */ + var personalTint by mutableStateOf(null) + + /** 0 choosing a server, 1 signing in, 2 choosing a profile. */ + val stage: Float + get() = when { + !serverConfirmed -> 0f + focus == Focus.Profiles -> 2f + else -> 1f + } + + /** Applies a confirmed server's branding. Called from the cache first, then after the server answers. */ + fun showServer(branding: ServerBranding?) { + serverConfirmed = true + accent = branding?.accentColor?.let(::clampedAccent) + } + + /** Back to the pre-connect state (sign out, change server). */ + fun showGeneric() { + serverConfirmed = false + accent = null + focus = Focus.Account + personalTint = null + } + + /** + * Admin-chosen accents can be anything; keep the tint dark enough that + * form text over it stays legible. + */ + fun clampedAccent(hex: String): Color? { + val trimmed = hex.trim().removePrefix("#") + if (!Regex("^[0-9A-Fa-f]{6}$").matches(trimmed)) return null + val rgb = trimmed.toInt(16) + val hsv = FloatArray(3) + android.graphics.Color.RGBToHSV((rgb shr 16) and 0xFF, (rgb shr 8) and 0xFF, rgb and 0xFF, hsv) + hsv[1] = hsv[1].coerceAtMost(0.7f) + hsv[2] = hsv[2].coerceIn(0.45f, 0.8f) + return Color(android.graphics.Color.HSVToColor(hsv)) + } +} + +/** Branding cache and loader for the first-run screens on phone and TV. */ +val marqueeModule = module { + single { ServerBrandingCache(get()) } + single { ServerBrandingLoader(get(), get()) } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeServerMark.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeServerMark.kt new file mode 100644 index 0000000000..026f3db543 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeServerMark.kt @@ -0,0 +1,74 @@ +package org.siloserver.silo.common.ui.marquee + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +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 coil3.compose.AsyncImage + +/** + * The server's mark: its branded `mark_url` image when there is one, else its + * initial on a warm tile, else a plain dark tile. + */ +@Composable +fun MarqueeServerMark(name: String?, imageUrl: String?, size: Dp, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(size * 0.25f) + var imageFailed by remember(imageUrl) { mutableStateOf(imageUrl == null) } + Box( + modifier = modifier + .size(size) + .clip(shape) + .border(1.dp, Color.White.copy(alpha = 0.18f), shape), + contentAlignment = Alignment.Center, + ) { + val initial = name?.trim()?.firstOrNull()?.uppercase() + if (initial != null) { + Box( + Modifier + .size(size) + .background(Brush.linearGradient(listOf(Color(0xFFEAA65C), Color(0xFFA8521D), Color(0xFF5E290D)))), + contentAlignment = Alignment.Center, + ) { + BasicText( + text = initial, + style = TextStyle( + color = Color(0xFFFFF6EA), + fontSize = (size.value * 0.54f).sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Serif, + ), + ) + } + } else { + Box(Modifier.size(size).background(Brush.verticalGradient(listOf(Color(0xFF2B2D33), Color(0xFF15171C))))) + } + if (!imageFailed) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + onError = { imageFailed = true }, + modifier = Modifier.size(size), + ) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeShake.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeShake.kt new file mode 100644 index 0000000000..4de899dc07 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/MarqueeShake.kt @@ -0,0 +1,36 @@ +package org.siloserver.silo.common.ui.marquee + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.unit.IntOffset + +/** + * A short sideways shake each time [trigger] changes (a rejected password or + * PIN). The value it starts with doesn't shake, so returning to a screen + * doesn't replay an old failure. Under reduced motion the error's color + * carries it alone. + */ +fun Modifier.marqueeShake(trigger: Int): Modifier = composed { + val reduceMotion = rememberReduceMotion() + val offset = remember { Animatable(0f) } + val initial = remember { trigger } + LaunchedEffect(trigger) { + if (trigger == initial || reduceMotion) return@LaunchedEffect + offset.animateTo( + 0f, + keyframes { + durationMillis = 370 + -8f at 70 + 8f at 140 + -6f at 210 + 4f at 280 + }, + ) + } + offset { IntOffset((offset.value * density).toInt(), 0) } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/ServerBranding.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/ServerBranding.kt new file mode 100644 index 0000000000..3e00e606f4 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/ServerBranding.kt @@ -0,0 +1,115 @@ +package org.siloserver.silo.common.ui.marquee + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.siloserver.silo.network.AndroidServerRegistry +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.api.BrandingApi +import org.siloserver.silo.network.api.BrandingStatus +import java.net.URI + +/** + * A server's public branding (`GET /api/v2/theme/branding`) with its asset + * paths resolved against the server. Read before sign-in, so the first-run + * screens can name the server and tint the backdrop to its accent. + */ +@Serializable +data class ServerBranding( + val serverName: String? = null, + val loginSubtitle: String? = null, + val accentColor: String? = null, + val markUrl: String? = null, + val wordmarkUrl: String? = null, +) { + companion object { + fun from(document: BrandingStatus, serverUrl: String) = ServerBranding( + serverName = document.serverName.usable(), + loginSubtitle = document.loginSubtitle.usable(), + accentColor = document.accentColor.usable(), + markUrl = resolveAsset(document.markUrl, serverUrl), + wordmarkUrl = resolveAsset(document.wordmarkUrl, serverUrl), + ) + + /** + * Server asset paths are site-relative. Only same-origin http(s) URLs + * are accepted, so branding can't point the app at another host. + */ + fun resolveAsset(value: String?, serverUrl: String): String? { + val raw = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val base = runCatching { URI(serverUrl.trimEnd('/') + "/") }.getOrNull() ?: return null + val resolved = runCatching { base.resolve(raw.removePrefix("/")) }.getOrNull() ?: return null + val origin = origin(resolved) ?: return null + return if (origin == origin(base)) resolved.toString() else null + } + + /** Scheme, host and port with the default port filled in, so `https://h` and `https://h:443` match. */ + private fun origin(uri: URI): String? { + val scheme = uri.scheme?.lowercase() ?: return null + val host = uri.host?.lowercase() ?: return null + val port = uri.port.takeIf { it > 0 } ?: when (scheme) { + "https" -> 443 + "http" -> 80 + else -> return null + } + return "$scheme://$host:$port" + } + + /** `host` or `host:port`, for a server's address line. */ + fun hostLabel(serverUrl: String): String { + val uri = runCatching { URI(serverUrl) }.getOrNull() ?: return serverUrl + val host = uri.host ?: return serverUrl + return if (uri.port > 0) "$host:${uri.port}" else host + } + + private fun String?.usable(): String? = this?.trim()?.takeIf { it.isNotEmpty() } + } +} + +/** + * The last branding each server showed, so returning to sign-in or the + * profile picker shows that server's name and accent at once instead of + * flashing the generic look first. + */ +class ServerBrandingCache(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val serializer = MapSerializer(String.serializer(), ServerBranding.serializer()) + private val json = Json { ignoreUnknownKeys = true } + + fun branding(serverUrl: String): ServerBranding? = readAll()[key(serverUrl)] + + fun store(serverUrl: String, branding: ServerBranding) { + val all = readAll().toMutableMap() + all[key(serverUrl)] = branding + prefs.edit().putString(KEY, json.encodeToString(serializer, all)).apply() + } + + private fun readAll(): Map { + val raw = prefs.getString(KEY, null) ?: return emptyMap() + return runCatching { json.decodeFromString(serializer, raw) }.getOrDefault(emptyMap()) + } + + private fun key(serverUrl: String) = AndroidServerRegistry.normalizeUrl(serverUrl) + + private companion object { + const val PREFS = "marquee_server_branding" + const val KEY = "branding.v1" + } +} + +/** + * Reads a server's branding and saves it. A failure is no branding: the + * screens fall back to the plain brand light and the saved name. + */ +class ServerBrandingLoader( + private val api: BrandingApi, + val cache: ServerBrandingCache, +) { + suspend fun load(serverUrl: String): ServerBranding? { + if (serverUrl.isBlank()) return null + val document = (api.getBranding(serverUrl) as? ApiResult.Success)?.data ?: return null + return ServerBranding.from(document, serverUrl).also { cache.store(serverUrl, it) } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInHandoff.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInHandoff.kt new file mode 100644 index 0000000000..7d76dbeb5a --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInHandoff.kt @@ -0,0 +1,16 @@ +package org.siloserver.silo.common.ui.marquee + +/** + * What a finished sign-in tells the profile picker that follows it. + */ +object SignInHandoff { + /** + * Set by a completed sign-in, including setup from a nearby phone: a + * household with exactly one profile and no PIN goes straight to Home + * instead of a one-person picker. The picker clears it on first read. + */ + @Volatile + var skipsSingleProfilePicker: Boolean = false + + fun consumeSkipsSingleProfilePicker(): Boolean = skipsSingleProfilePicker.also { skipsSingleProfilePicker = false } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInServer.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInServer.kt new file mode 100644 index 0000000000..93021df1c0 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/marquee/SignInServer.kt @@ -0,0 +1,51 @@ +package org.siloserver.silo.common.ui.marquee + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * The server a first-run screen is about: its name, address and branding. + * Branding shows from the cache at once and refreshes from the server. + */ +@Stable +class SignInServer(val serverUrl: String, initial: ServerBranding?, private val savedName: String?) { + var branding by mutableStateOf(initial) + internal set + + /** Branded name, then the name saved at connect, then the host. */ + val name: String + get() = branding?.serverName ?: savedName?.trim()?.takeIf { it.isNotEmpty() } ?: hostLabel + + val hostLabel: String get() = ServerBranding.hostLabel(serverUrl) + + val isSecure: Boolean get() = serverUrl.startsWith("https://", ignoreCase = true) +} + +/** + * Tints the backdrop for [serverUrl] from the saved branding, then reads + * fresh branding. With [tintsBackdrop] false the screen only reads the name + * (the profile picker tints for a person instead of the server). + */ +@Composable +fun rememberSignInServer( + serverUrl: String, + savedName: String?, + loader: ServerBrandingLoader, + tintsBackdrop: Boolean = true, +): SignInServer { + val server = remember(serverUrl) { SignInServer(serverUrl, loader.cache.branding(serverUrl), savedName) } + LaunchedEffect(serverUrl) { + if (serverUrl.isBlank()) return@LaunchedEffect + if (tintsBackdrop) MarqueeScene.showServer(server.branding) + // Cancelled with the screen, so a late answer can't retint the next one. + val fresh = loader.load(serverUrl) ?: return@LaunchedEffect + server.branding = fresh + if (tintsBackdrop) MarqueeScene.showServer(fresh) + } + return server +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/marquee/ServerBrandingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/marquee/ServerBrandingTest.kt new file mode 100644 index 0000000000..1296291d60 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/marquee/ServerBrandingTest.kt @@ -0,0 +1,46 @@ +package org.siloserver.silo.common.ui.marquee + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Branding asset URLs are read before sign-in, so only the server's own origin is accepted. */ +class ServerBrandingTest { + @Test + fun resolvesSiteRelativePathsAgainstTheServer() { + assertEquals( + "https://media.example.com/branding/mark.png", + ServerBranding.resolveAsset("/branding/mark.png", "https://media.example.com"), + ) + assertEquals( + "https://media.example.com/silo/branding/mark.png", + ServerBranding.resolveAsset("branding/mark.png", "https://media.example.com/silo"), + ) + } + + @Test + fun acceptsTheSameOriginWithTheDefaultPortSpelledOut() { + assertEquals( + "https://media.example.com:443/mark.png", + ServerBranding.resolveAsset("https://media.example.com:443/mark.png", "https://media.example.com"), + ) + } + + @Test + fun rejectsOtherHostsSchemesAndPorts() { + val base = "https://media.example.com" + assertNull(ServerBranding.resolveAsset("https://evil.example.net/mark.png", base)) + assertNull(ServerBranding.resolveAsset("http://media.example.com/mark.png", base)) + assertNull(ServerBranding.resolveAsset("https://media.example.com:8443/mark.png", base)) + // A protocol-relative path stays on the server, as a path. + assertEquals("https://media.example.com/evil.example.net/mark.png", ServerBranding.resolveAsset("//evil.example.net/mark.png", base)) + assertNull(ServerBranding.resolveAsset("javascript:alert(1)", base)) + assertNull(ServerBranding.resolveAsset(" ", base)) + } + + @Test + fun clampsAccentsToALegibleTint() { + assertNull(MarqueeScene.clampedAccent("not a color")) + assertNull(MarqueeScene.clampedAccent("#12345")) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt index 7e8db31403..fda064d955 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt @@ -14,6 +14,7 @@ import org.siloserver.silo.android.downloads.AppWorkerFactory import org.siloserver.silo.android.notifications.NotificationsForegroundStarter import org.siloserver.silo.android.push.AndroidPushRegistrationStarter import org.siloserver.silo.common.di.playerInfraModule +import org.siloserver.silo.common.ui.marquee.marqueeModule import org.siloserver.silo.common.di.playerModule import org.siloserver.silo.common.diagnostics.DiagnosticsCoordinator import org.siloserver.silo.common.diagnostics.DiagnosticsStartup @@ -40,7 +41,7 @@ class SiloApplication : Application(), Configuration.Provider, SingletonImageLoa DiagnosticsStartup.installCrashCapture(this) val koinApp = startKoin { androidContext(this@SiloApplication) - modules(sharedModules() + playerModule + playerInfraModule + androidModule + diagnosticsModule) + modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + androidModule + diagnosticsModule) } DiagnosticsStartup.startCoordinator { koinApp.koin.get() } koinApp.koin.get().start( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index f303fe3a08..8334d141ff 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -506,7 +506,10 @@ val androidModule = module { viewModel { DiagnosticsViewModel(get()) } viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { org.siloserver.silo.android.ui.screens.pairing.CompanionPairingViewModel(get(), get(), get()) } - viewModel { ServerSetupViewModel(get(), get()) } + viewModel { + val tokens = get() + ServerSetupViewModel(get(), get(), hasSession = { !tokens.getAccessToken().isNullOrBlank() }) + } viewModel { LoginViewModel(get(), get(), get(), get(), get(), get()) } viewModel { org.siloserver.silo.android.ui.screens.settings.SignInSettingsViewModel(get(), get(), get(), get(), get()) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt index 8cf175962c..679795d65a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/aurora/AuroraChrome.kt @@ -1,131 +1,54 @@ package org.siloserver.silo.android.ui.components.aurora -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import org.siloserver.silo.android.ui.components.siloKeyboardActions import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily 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.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** - * The Aurora first-run chrome — palette, eyebrow, glass panel, cream primary - * button, ghost button, text field, segment, field/error labels — ported from - * silo-apple's `AuroraStyle.swift` + `AuroraTextField.swift`, scaled for the - * phone (iOS points ≈ Android dp 1:1). A warm, cinematic skin (gold accent, - * plum-night) layered over the onboarding screens. + * The Aurora chrome — palette, glass panel, cream primary button and ghost + * button — used by the onboarding tour, ported from silo-apple's Aurora + * styles and scaled for the phone (iOS points ≈ Android dp 1:1). The other + * first-run screens use the Marquee components instead. */ // MARK: palette val AuroraInk = Color(0xFFF3EFE9) val AuroraAccent = Color(0xFFF3D3A0) val AuroraInkSecondary = AuroraInk.copy(alpha = 0.62f) -val AuroraInkTertiary = AuroraInk.copy(alpha = 0.40f) private val AuroraGlassTint = Color(0xFF171019) private val AuroraCreamTop = Color(0xFFFDF7EC) private val AuroraCreamBottom = Color(0xFFF1E3CD) private val AuroraCreamInk = Color(0xFF20160A) -private val AuroraError = Color(0xFFEF5350) // MARK: shared control metrics (iOS AuroraControl) internal val AuroraControlHeight = 52.dp internal val AuroraControlCorner = 14.dp -private val AuroraActiveFill = Color(0xFFF4EEE2) -private val AuroraActiveInk = Color(0xFF1A1206) -private val AuroraActivePlaceholder = Color(0xFF4A4035) - -/** Gold-hairline + mono-caps step label, e.g. "STEP 01 — CONNECT". */ -@Composable -fun AuroraEyebrow(text: String, modifier: Modifier = Modifier, centered: Boolean = false) { - Row( - modifier = if (centered) modifier.fillMaxWidth() else modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start, - ) { - Box( - modifier = Modifier - .width(46.dp) - .height(1.dp) - .background( - Brush.horizontalGradient(listOf(AuroraAccent, AuroraAccent.copy(alpha = 0f))), - ), - ) - Spacer(Modifier.width(16.dp)) - Text( - text = text.uppercase(), - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - letterSpacing = 3.5.sp, - color = AuroraAccent, - ) - } -} - -/** Mono-caps field label (e.g. "SERVER ADDRESS"). */ -@Composable -fun AuroraFieldLabel(text: String, modifier: Modifier = Modifier) { - Text( - modifier = modifier, - text = text.uppercase(), - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp, - letterSpacing = 1.6.sp, - color = AuroraInkTertiary, - ) -} - -/** Inline error row. */ -@Composable -fun AuroraErrorLabel(text: String, modifier: Modifier = Modifier) { - Text( - modifier = modifier.fillMaxWidth(), - text = text, - fontSize = 13.sp, - color = AuroraError, - ) -} /** * Liquid-glass panel chrome (translucent plum tint + gradient hairline + top @@ -236,121 +159,3 @@ fun AuroraGhostButton( } } } - -/** - * Editable Aurora field — controlled input with the Aurora chrome. The - * placeholder is a controlled overlay so we own its contrast on the plum - * background. When focused it flips to the warm cream fill + gold ring used - * across the flow. - */ -@Composable -fun AuroraTextField( - label: String, - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - placeholder: String = "", - keyboardType: KeyboardType = KeyboardType.Text, - imeAction: ImeAction = ImeAction.Next, - onImeAction: (() -> Unit)? = null, - visualTransformation: VisualTransformation = VisualTransformation.None, - trailing: (@Composable () -> Unit)? = null, - enabled: Boolean = true, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(AuroraControlCorner) - // A disabled field's text dims like a disabled sign-in button's label. - val inkAlpha = if (enabled) 1f else 0.6f - - Column(modifier = modifier.fillMaxWidth()) { - AuroraFieldLabel(label) - Spacer(Modifier.height(8.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .shadow( - elevation = if (isFocused) 16.dp else 0.dp, - shape = shape, - clip = false, - ambientColor = AuroraAccent, - spotColor = AuroraAccent, - ) - .clip(shape) - .background(if (isFocused) AuroraActiveFill else Color.White.copy(alpha = 0.06f)) - .border( - width = if (isFocused) 2.dp else 1.5.dp, - color = if (isFocused) AuroraAccent else Color.White.copy(alpha = 0.16f), - shape = shape, - ) - .heightIn(min = AuroraControlHeight) - .padding(horizontal = 16.dp), - contentAlignment = Alignment.CenterStart, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { - if (value.isEmpty()) { - Text( - text = placeholder, - fontSize = 17.sp, - color = (if (isFocused) AuroraActivePlaceholder else AuroraInkTertiary).let { it.copy(alpha = it.alpha * inkAlpha) }, - ) - } - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), - enabled = enabled, - singleLine = true, - textStyle = LocalTextStyle.current.merge( - TextStyle( - color = (if (isFocused) AuroraActiveInk else AuroraInk).let { it.copy(alpha = it.alpha * inkAlpha) }, - fontSize = 17.sp, - ), - ), - cursorBrush = SolidColor(if (isFocused) AuroraActiveInk else AuroraAccent), - visualTransformation = visualTransformation, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction), - keyboardActions = siloKeyboardActions(onImeAction), - interactionSource = interactionSource, - ) - } - if (trailing != null) { - Spacer(Modifier.width(10.dp)) - trailing() - } - } - } - } -} - -/** Equal-width, single-line segmented option chip (Aurora). */ -@Composable -fun AuroraSegment( - title: String, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val shape = RoundedCornerShape(AuroraControlCorner) - val fill = if (isSelected) AuroraAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f) - val stroke = if (isSelected) AuroraAccent.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.14f) - val textColor = if (isSelected) AuroraInk else AuroraInkSecondary - Box( - modifier = modifier - .height(AuroraControlHeight) - .clip(shape) - .background(fill) - .border(1.5.dp, stroke, shape) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Text( - text = title, - color = textColor, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - maxLines = 1, - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeControls.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeControls.kt new file mode 100644 index 0000000000..b3c29e2e60 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeControls.kt @@ -0,0 +1,332 @@ +package org.siloserver.silo.android.ui.components.marquee + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +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.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.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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 coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.svg.SvgDecoder +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.model.auth.SignInProvider + +/** First-run sizes on the phone, matching the Apple apps' points. */ +object MarqueeMetrics { + val ButtonHeight = 52.dp + val ButtonFont = 17.sp + val ButtonPadding = 24.dp + val FieldHeight = 52.dp + val FieldFont = 17.sp + val FieldCorner = 14.dp + val HeroFont = 34.sp + val LeadFont = 16.sp +} + +/** + * The detail page's grammar: one white pill for the primary action, glass + * pills for alternatives, plain text for the way out. + */ +enum class MarqueeButtonKind { Primary, Glass, Plain } + +/** A light tap the moment a first-run control is pressed, so it feels immediate. */ +fun Modifier.marqueePressHaptic( + interactionSource: MutableInteractionSource, + feedback: Int = HapticFeedbackConstants.KEYBOARD_TAP, +): Modifier = composed { + val view = LocalView.current + val pressed by interactionSource.collectIsPressedAsState() + LaunchedEffect(pressed) { if (pressed) view.performHapticFeedback(feedback) } + this +} + +@Composable +fun MarqueeButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: MarqueeButtonKind = MarqueeButtonKind.Primary, + fullWidth: Boolean = true, + isLoading: Boolean = false, + enabled: Boolean = true, + /** Smaller utility actions (change server, sign out). */ + compact: Boolean = false, + icon: ImageVector? = null, +) { + MarqueeButton(onClick, modifier, kind, fullWidth, isLoading, enabled, compact) { ink -> + if (icon != null) Icon(icon, contentDescription = null, tint = ink, modifier = Modifier.size(if (compact) 16.dp else 18.dp)) + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +fun MarqueeButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: MarqueeButtonKind = MarqueeButtonKind.Primary, + fullWidth: Boolean = true, + isLoading: Boolean = false, + enabled: Boolean = true, + compact: Boolean = false, + label: @Composable RowScope.(ink: Color) -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val ink = when (kind) { + MarqueeButtonKind.Primary -> Color.Black + MarqueeButtonKind.Glass -> MarqueeColors.Ink + MarqueeButtonKind.Plain -> MarqueeColors.InkSecondary + } + val height = when { + compact -> MarqueeMetrics.ButtonHeight * 0.75f + kind == MarqueeButtonKind.Plain -> MarqueeMetrics.ButtonHeight - 8.dp + else -> MarqueeMetrics.ButtonHeight + } + val fontSize = if (compact) MarqueeMetrics.ButtonFont * 0.85f else MarqueeMetrics.ButtonFont + Row( + modifier = modifier + .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier) + .height(height) + .clip(CircleShape) + .then( + when (kind) { + MarqueeButtonKind.Primary -> Modifier.background(MarqueeColors.Ink) + MarqueeButtonKind.Glass -> Modifier + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + MarqueeButtonKind.Plain -> Modifier + }, + ) + .clickable( + enabled = enabled, + role = Role.Button, + interactionSource = interaction, + indication = null, + onClick = onClick, + ) + .marqueePressHaptic( + interaction, + if (kind == MarqueeButtonKind.Primary) HapticFeedbackConstants.VIRTUAL_KEY else HapticFeedbackConstants.KEYBOARD_TAP, + ) + .alpha(if (!enabled) 0.45f else if (pressed) 0.7f else 1f) + .padding(horizontal = if (compact) MarqueeMetrics.ButtonPadding * 0.7f else MarqueeMetrics.ButtonPadding), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) { + CircularProgressIndicator(color = ink, strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + } + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.material3.LocalContentColor provides ink, + androidx.compose.material3.LocalTextStyle provides androidx.compose.ui.text.TextStyle( + color = ink, + fontSize = fontSize, + fontWeight = if (kind == MarqueeButtonKind.Plain) FontWeight.Medium else FontWeight.SemiBold, + ), + ) { + label(ink) + } + } +} + +/** Round glass icon button (back, close), as on the detail pages. */ +@Composable +fun MarqueeIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onClick) + .marqueePressHaptic(interaction) + .alpha(if (!enabled) 0.45f else if (pressed) 0.7f else 1f) + .semantics { this.contentDescription = contentDescription }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = MarqueeColors.Ink, modifier = Modifier.size(20.dp)) + } +} + +/** Big title with an optional lead line. */ +@Composable +fun MarqueeHeadline( + title: String, + modifier: Modifier = Modifier, + lead: String? = null, + centered: Boolean = false, +) { + val align = if (centered) TextAlign.Center else TextAlign.Start + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = if (centered) Alignment.CenterHorizontally else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = title, + modifier = Modifier.semantics { heading() }, + color = MarqueeColors.Ink, + fontSize = MarqueeMetrics.HeroFont, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.85).sp, + lineHeight = 38.sp, + textAlign = align, + ) + if (lead != null) { + Text( + text = lead, + color = MarqueeColors.InkSecondary, + fontSize = MarqueeMetrics.LeadFont, + lineHeight = 22.sp, + textAlign = align, + ) + } + } +} + +/** Inline error under a control. Errors never become banners. */ +@Composable +fun MarqueeErrorText(text: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clearAndSetSemantics { contentDescription = "Error: $text" }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + Icons.Outlined.ErrorOutline, + contentDescription = null, + tint = MarqueeColors.Error, + modifier = Modifier.size(16.dp).offset(y = 1.dp), + ) + Text(text, color = MarqueeColors.Error, fontSize = 14.sp, lineHeight = 19.sp) + } +} + +/** A thin line with a word in the middle ("or"). */ +@Composable +fun MarqueeLabeledDivider(text: String, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).height(1.dp).background(MarqueeColors.Separator)) + Text(text, Modifier.padding(horizontal = 12.dp), color = MarqueeColors.InkTertiary, fontSize = 13.sp) + Box(Modifier.weight(1f).height(1.dp).background(MarqueeColors.Separator)) + } +} + +/** + * A provider's icon, or its initial on a warm gradient when it has none or + * the icon can't be drawn. + */ +@Composable +fun MarqueeProviderMark(provider: SignInProvider, size: Dp = 22.dp) { + val iconUrl = provider.iconUrl + var failed by remember(iconUrl) { mutableStateOf(iconUrl == null) } + val shape = RoundedCornerShape(size * 0.27f) + Box(Modifier.size(size).clip(shape), contentAlignment = Alignment.Center) { + if (failed) { + Box( + Modifier + .size(size) + .background(Brush.linearGradient(listOf(Color(0xFFFD4B2D), Color(0xFFA21D5C)))), + contentAlignment = Alignment.Center, + ) { + Text( + text = provider.displayName.firstOrNull()?.lowercase() ?: "s", + color = Color.White, + fontSize = (size.value * 0.55f).sp, + fontWeight = FontWeight.ExtraBold, + ) + } + } else { + val context = LocalContext.current + AsyncImage( + model = remember(iconUrl) { + ImageRequest.Builder(context) + .data(iconUrl) + .apply { + // The app's image loader has no SVG decoder; plugin + // icons (the OIDC plugin's sso.svg) often are SVG. + if (iconUrl.orEmpty().substringBefore('?').endsWith(".svg", ignoreCase = true)) { + decoderFactory(SvgDecoder.Factory()) + } + } + .build() + }, + contentDescription = null, + onError = { failed = true }, + modifier = Modifier.size(size), + ) + } + } +} + +/** + * An error buzz each time [trigger] changes to a failure (non-null, non-zero). + * The value it starts with doesn't buzz, so returning to a screen doesn't + * replay an old failure. + */ +@Composable +fun MarqueeErrorHaptic(trigger: Any?) { + val view = LocalView.current + val initial = remember { trigger } + LaunchedEffect(trigger) { + if (trigger != initial && trigger != null && trigger != 0) { + view.performHapticFeedback( + if (android.os.Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.REJECT else HapticFeedbackConstants.LONG_PRESS, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeFields.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeFields.kt new file mode 100644 index 0000000000..441618d1fc --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeFields.kt @@ -0,0 +1,191 @@ +package org.siloserver.silo.android.ui.components.marquee + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.Row +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +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.unit.dp +import org.siloserver.silo.android.ui.components.siloKeyboardActions +import org.siloserver.silo.common.ui.marquee.MarqueeColors + +private val LocalMarqueeFieldGrouped = compositionLocalOf { false } + +/** + * Frosted text field with a leading icon. Inside [MarqueeFieldGroup] it drops + * its own background so the group draws one rounded block with separators. + */ +@Composable +fun MarqueeTextField( + value: String, + onValueChange: (String) -> Unit, + icon: ImageVector, + placeholder: String, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Next, + onImeAction: (() -> Unit)? = null, + isSecure: Boolean = false, + showsClearButton: Boolean = false, + isError: Boolean = false, + enabled: Boolean = true, + focusRequester: FocusRequester? = null, +) { + val grouped = LocalMarqueeFieldGrouped.current + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + var reveal by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(MarqueeMetrics.FieldCorner) + val textStyle = TextStyle(color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont) + + Row( + modifier = modifier + .fillMaxWidth() + .height(MarqueeMetrics.FieldHeight) + .then( + if (grouped) { + if (isError) Modifier.background(MarqueeColors.Error.copy(alpha = 0.08f)) else Modifier + } else { + Modifier + .clip(shape) + .background( + when { + isError -> MarqueeColors.Error.copy(alpha = 0.08f) + focused -> Color.White.copy(alpha = 0.10f) + else -> Color.White.copy(alpha = 0.07f) + }, + ) + .border( + 1.dp, + when { + isError -> MarqueeColors.Error.copy(alpha = 0.65f) + focused -> Color.White.copy(alpha = 0.45f) + else -> Color.White.copy(alpha = 0.12f) + }, + shape, + ) + }, + ) + .alpha(if (enabled) 1f else 0.5f) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(icon, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.width(20.dp).size(20.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text(placeholder, style = textStyle.copy(color = MarqueeColors.InkTertiary), maxLines = 1) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .semantics { contentDescription = placeholder }, + enabled = enabled, + singleLine = true, + textStyle = textStyle, + cursorBrush = SolidColor(Color(0xFF0A84FF)), + visualTransformation = if (isSecure && !reveal) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions( + keyboardType = if (isSecure) KeyboardType.Password else keyboardType, + imeAction = imeAction, + autoCorrectEnabled = false, + ), + keyboardActions = siloKeyboardActions(onImeAction), + interactionSource = interaction, + ) + } + if (isSecure) { + Icon( + imageVector = if (reveal) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (reveal) "Hide password" else "Show password", + tint = MarqueeColors.InkTertiary, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { reveal = !reveal } + .padding(4.dp) + .size(20.dp), + ) + } else if (showsClearButton && value.isNotEmpty() && enabled) { + Icon( + imageVector = Icons.Filled.Cancel, + contentDescription = "Clear", + tint = Color.White.copy(alpha = 0.3f), + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { onValueChange("") } + .padding(4.dp) + .size(18.dp), + ) + } + } +} + +/** Username and password as one block, like grouped fields on iOS. */ +@Composable +fun MarqueeFieldGroup( + modifier: Modifier = Modifier, + isError: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(MarqueeMetrics.FieldCorner) + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, if (isError) MarqueeColors.Error.copy(alpha = 0.65f) else Color.White.copy(alpha = 0.12f), shape), + ) { + CompositionLocalProvider(LocalMarqueeFieldGrouped provides true) { content() } + } +} + +/** The hairline between rows of a [MarqueeFieldGroup] or another grouped list. */ +@Composable +fun MarqueeSeparator(modifier: Modifier = Modifier) { + Box(modifier.fillMaxWidth().height(1.dp).background(MarqueeColors.Separator)) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt new file mode 100644 index 0000000000..a9d0dd9c05 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt @@ -0,0 +1,108 @@ +package org.siloserver.silo.android.ui.components.marquee + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeServerMark + +/** + * Compact server capsule for the top bar. Tapping it opens a menu headed by + * the server's address with one action, [actionLabel]. + */ +@Composable +fun MarqueeServerChip( + name: String, + hostLabel: String, + markUrl: String?, + actionLabel: String, + onAction: () -> Unit, + enabled: Boolean = true, +) { + var expanded by remember { mutableStateOf(false) } + val interaction = remember { MutableInteractionSource() } + Box { + Row( + modifier = Modifier + .height(36.dp) + .widthIn(max = 220.dp) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null) { + expanded = true + } + .marqueePressHaptic(interaction) + .alpha(if (enabled) 1f else 0.45f) + .semantics { contentDescription = "$name, server. Double-tap to change." } + .padding(start = 6.dp, end = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeServerMark(name = name, imageUrl = markUrl, size = 24.dp) + Text( + name, + color = MarqueeColors.Ink, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(16.dp)) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = Color(0xFF1C1C1E), + ) { + Text( + hostLabel, + color = MarqueeColors.InkTertiary, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + DropdownMenuItem( + text = { Text(actionLabel, color = MarqueeColors.Ink) }, + leadingIcon = { Icon(Icons.Outlined.Dns, contentDescription = null, tint = MarqueeColors.Ink) }, + onClick = { + expanded = false + onAction() + }, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeStage.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeStage.kt new file mode 100644 index 0000000000..31f898dcb9 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeStage.kt @@ -0,0 +1,116 @@ +package org.siloserver.silo.android.ui.components.marquee + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.siloserver.silo.android.R +import org.siloserver.silo.common.ui.marquee.MarqueeScrim +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle + +/** + * A first-run screen on the phone: transparent over the shared backdrop, a + * scrim for legibility, a top bar, and content anchored to the bottom like a + * streaming app's welcome screen. The column scrolls, so large text and the + * keyboard push the title up instead of clipping the controls. + * + * @param onBack what system Back does; null leaves Back to the navigation + * host. Pass null while a sign-in is in flight so leaving can't race it. + */ +@Composable +fun MarqueeStage( + modifier: Modifier = Modifier, + scrim: MarqueeScrimStyle = MarqueeScrimStyle.Bottom, + frostStart: Float? = 0.42f, + maxWidth: Dp = 440.dp, + onBack: (() -> Unit)? = null, + blockBack: Boolean = false, + topBar: @Composable RowScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + BackHandler(enabled = onBack != null || blockBack) { if (!blockBack) onBack?.invoke() } + Box(modifier.fillMaxSize()) { + MarqueeScrim(scrim, frostStart = frostStart) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars), + ) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(start = 24.dp, end = 24.dp, top = 6.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + content = topBar, + ) + BoxWithConstraints( + Modifier + .weight(1f) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime)), + ) { + val viewport = maxHeight + Column( + Modifier + .fillMaxSize() + // Keep the primary action in view when the room changes. + .verticalScroll(rememberScrollState(), reverseScrolling = true), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + Modifier + .heightIn(min = viewport) + .widthIn(max = maxWidth) + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 20.dp), + verticalArrangement = Arrangement.Bottom, + content = content, + ) + } + } + } + } +} + +/** The Silo wordmark for a first-run top bar. */ +@Composable +fun MarqueeWordmark(width: Dp = 92.dp) { + Image( + painter = painterResource(R.drawable.silo_wordmark), + contentDescription = "Silo", + contentScale = ContentScale.Fit, + modifier = Modifier.width(width), + ) +} + +/** Pushes the trailing top-bar item to the end. */ +@Composable +fun RowScope.MarqueeTopBarSpacer() = Spacer(Modifier.weight(1f)) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 16f620f029..81991465e6 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.android.ui.navigation import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExitTransition import androidx.compose.animation.SharedTransitionLayout @@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelStoreOwner import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController +import org.siloserver.silo.common.ui.marquee.MarqueeBackdrop import androidx.lifecycle.DEFAULT_ARGS_KEY import androidx.lifecycle.viewmodel.MutableCreationExtras import androidx.core.os.bundleOf @@ -140,6 +142,17 @@ internal fun currentPlayerTargetOrNull( return registration.target() } +/** First-run routes, drawn transparent over the shared [MarqueeBackdrop]. */ +private val MarqueeRoutes = setOf( + Route.ServerSetup.route, + Route.ServerSetupPrefilled.ROUTE, + Route.Login.route, + Route.Setup.route, + Route.Signup.route, + Route.InviteClaim.ROUTE, + Route.ProfileSelection.route, +) + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun AppNavigation( @@ -330,6 +343,15 @@ fun AppNavigation( LocalHeroSourceHandoff provides heroSourceHandoff, ) { Box(modifier = Modifier.fillMaxSize()) { + // The first-run screens draw over one shared brand-light backdrop that + // lives outside the destinations, so it keeps moving while they fade. + AnimatedVisibility( + visible = currentEntry?.destination?.route in MarqueeRoutes, + enter = fadeIn(tween(PageFadeDurationMs)), + exit = fadeOut(tween(PageFadeDurationMs)), + ) { + MarqueeBackdrop() + } NavHost( navController = navController, startDestination = startDestination, @@ -359,6 +381,11 @@ fun AppNavigation( popUpTo(Route.ServerSetup.route) { inclusive = true } } }, + onNavigateToProfiles = { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(Route.ServerSetup.route) { inclusive = true } + } + }, ) } composable( @@ -377,6 +404,11 @@ fun AppNavigation( popUpTo(Route.ServerSetupPrefilled.ROUTE) { inclusive = true } } }, + onNavigateToProfiles = { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(Route.ServerSetupPrefilled.ROUTE) { inclusive = true } + } + }, ) } composable(Route.Login.route) { @@ -751,6 +783,15 @@ fun AppNavigation( onNavigateToEditProfile = { profileId -> navController.navigate(Route.EditProfile(profileId).route) }, + onChangeServer = { navController.navigate(Route.ServerList.route) }, + onSignOut = { + navScope.launch { + signOutTeardown.signOut() + navController.navigate(Route.Login.route) { + popUpTo(0) { inclusive = true } + } + } + }, ) } composable(Route.CreateProfile.route) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt index 766cada92f..cebffcd008 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt @@ -1,52 +1,49 @@ package org.siloserver.silo.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.Box +import androidx.compose.foundation.layout.Row 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.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock 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.material3.TextButton 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.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.koin.compose.viewmodel.koinViewModel -import org.siloserver.silo.android.ui.components.aurora.AuroraErrorLabel -import org.siloserver.silo.android.ui.components.aurora.AuroraEyebrow -import org.siloserver.silo.android.ui.components.aurora.AuroraGhostButton -import org.siloserver.silo.android.ui.components.aurora.AuroraPrimaryButton -import org.siloserver.silo.android.ui.components.aurora.AuroraScreen -import org.siloserver.silo.android.ui.components.aurora.AuroraScrim -import org.siloserver.silo.android.ui.components.aurora.AuroraTextField -import org.siloserver.silo.android.ui.components.aurora.AuroraVariant -import org.siloserver.silo.android.ui.components.aurora.auroraGlass +import org.siloserver.silo.android.ui.components.marquee.MarqueeButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeButtonKind +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorHaptic +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorText +import org.siloserver.silo.android.ui.components.marquee.MarqueeFieldGroup +import org.siloserver.silo.android.ui.components.marquee.MarqueeHeadline +import org.siloserver.silo.android.ui.components.marquee.MarqueeMetrics +import org.siloserver.silo.android.ui.components.marquee.MarqueeSeparator +import org.siloserver.silo.android.ui.components.marquee.MarqueeStage +import org.siloserver.silo.android.ui.components.marquee.MarqueeTextField +import org.siloserver.silo.android.ui.components.marquee.MarqueeWordmark +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.common.ui.marquee.ServerBranding /** * 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. + * this screen asks for a password and nothing else. Laid out like the other + * first-run screens. */ @Composable fun InviteClaimScreen( @@ -57,7 +54,6 @@ fun InviteClaimScreen( viewModel: InviteClaimViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } LaunchedEffect(serverUrl, token) { viewModel.load(serverUrl, token) @@ -68,228 +64,139 @@ fun InviteClaimScreen( LaunchedEffect(state.claimSuccess) { if (state.claimSuccess) onClaimComplete() } + MarqueeErrorHaptic(state.error) state.pendingCleartextOrigin?.let { origin -> AlertDialog( onDismissRequest = viewModel::onCancelCleartext, - title = { Text("Use unencrypted HTTP?") }, + title = { Text("Connect without encryption?") }, 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, + Text( + "Your new password will be sent unencrypted to ${ServerBranding.hostLabel(origin)}. " + + "Only do this on a network you trust.", ) }, + confirmButton = { TextButton(onClick = viewModel::onConfirmCleartext) { Text("Connect") } }, + dismissButton = { TextButton(onClick = viewModel::onCancelCleartext) { Text("Cancel") } }, ) } - AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { - SiloLogo() - Spacer(Modifier.height(30.dp)) - + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + topBar = { MarqueeWordmark() }, + ) { when { state.signInRequiredUsername != null || state.acceptanceUncertain -> { - Text(if (state.signInRequiredUsername != null) "Account created" else "Check sign-in", - color = Color(0xFFF3EFE9), fontSize = 30.sp) - Spacer(Modifier.height(16.dp)) - Text(if (state.signInRequiredUsername != null) - "Sign in to $serverUrl as ${state.signInRequiredUsername} using the password you chose. Use Change server on the sign-in screen if needed." - else state.error.orEmpty(), color = Color.White.copy(alpha = 0.8f)) - Spacer(Modifier.height(24.dp)) - AuroraPrimaryButton(label = "Go to sign in", onClick = onNavigateToLogin, - modifier = Modifier.fillMaxWidth()) + MarqueeHeadline( + title = if (state.signInRequiredUsername != null) "Account created" else "Check sign-in", + lead = if (state.signInRequiredUsername != null) { + "Sign in to $serverUrl as ${state.signInRequiredUsername} using the password you chose. " + + "Use Change server on the sign-in screen if needed." + } else { + state.error.orEmpty() + }, + ) + MarqueeButton("Go to sign in", onClick = onNavigateToLogin, modifier = Modifier.padding(top = 24.dp)) } state.acceptanceUnavailable || state.invitation?.acceptanceAvailable == false -> { - Text("Invitation acceptance is unavailable on this server.", color = Color(0xFFF3EFE9)) - Spacer(Modifier.height(24.dp)) - AuroraGhostButton(label = "Back to sign in", onClick = onNavigateToLogin, fillMaxWidth = true) + MarqueeHeadline(title = "Can't accept here", lead = "Invitation acceptance is unavailable on this server.") + MarqueeButton( + "Back to sign in", + onClick = onNavigateToLogin, + kind = MarqueeButtonKind.Glass, + modifier = Modifier.padding(top = 24.dp), + ) } state.isLoadingInvitation -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(60.dp)) - CircularProgressIndicator(color = Color(0xFFF3EFE9)) + Box(Modifier.fillMaxWidth().height(120.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(28.dp)) } } state.lookupFailed -> { - AuroraEyebrow(text = "Invitation", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Couldn't check the invitation", - 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. " + + MarqueeHeadline( + title = "Couldn't check\nthe invitation", + lead = "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", + MarqueeButton("Try again", onClick = viewModel::onRetryLookup, modifier = Modifier.padding(top = 24.dp)) + MarqueeButton( + "Back to sign in", onClick = onNavigateToLogin, - fillMaxWidth = true, + kind = MarqueeButtonKind.Plain, + modifier = Modifier.padding(top = 6.dp), ) } 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. " + + MarqueeHeadline( + title = "This invite\nhas expired", + lead = "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", + MarqueeButton( + "Back to sign in", onClick = onNavigateToLogin, - fillMaxWidth = true, + kind = MarqueeButtonKind.Glass, + modifier = Modifier.padding(top = 24.dp), ) } 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), + val invitation = state.invitation ?: return@MarqueeStage + MarqueeHeadline( + title = "Welcome to\n${invitation.serverName}", + lead = listOfNotNull( + invitation.inviterName?.let { "$it invited you." }, + "Choose a password and you're in. You'll sign in with your email address.", + ).joinToString(" "), ) - Spacer(Modifier.height(24.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.padding(top = 24.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)) + Row( + Modifier.fillMaxWidth().height(MarqueeMetrics.FieldHeight).padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Email, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(20.dp)) Text( - text = invitation.email, - fontSize = 16.sp, - color = Color.White.copy(alpha = 0.8f), + invitation.email, + color = MarqueeColors.InkSecondary, + fontSize = MarqueeMetrics.FieldFont, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - AuroraTextField( - label = "Password", + MarqueeSeparator() + MarqueeTextField( 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), - ) - } - }, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, ) - AuroraTextField( - label = "Confirm password", + MarqueeSeparator() + MarqueeTextField( value = state.confirmPassword, onValueChange = viewModel::onConfirmPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, + icon = Icons.Outlined.Lock, + placeholder = "Confirm password", + isSecure = true, 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(), ) } + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp)) } + MarqueeButton( + text = if (state.isSubmitting) "Creating…" else "Create account", + onClick = viewModel::onClaimClick, + isLoading = state.isSubmitting, + enabled = !state.isSubmitting, + modifier = Modifier.padding(top = 14.dp), + ) } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt index bdcc3207dc..ca70adf3d0 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt @@ -1,75 +1,99 @@ package org.siloserver.silo.android.ui.screens.auth +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.ui.Alignment -import androidx.compose.ui.platform.LocalContext -import org.siloserver.silo.android.auth.openNativeSignIn +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable 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.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily 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.siloserver.silo.android.ui.components.aurora.AuroraEyebrow -import org.siloserver.silo.android.ui.components.aurora.AuroraErrorLabel -import org.siloserver.silo.android.ui.components.aurora.AuroraGhostButton -import org.siloserver.silo.android.ui.components.aurora.AuroraInkTertiary -import org.siloserver.silo.android.ui.components.aurora.AuroraPrimaryButton -import org.siloserver.silo.android.ui.components.aurora.AuroraScreen -import org.siloserver.silo.android.ui.components.aurora.AuroraScrim -import org.siloserver.silo.android.ui.components.aurora.AuroraTextField -import org.siloserver.silo.android.ui.components.aurora.AuroraVariant -import org.siloserver.silo.android.ui.components.aurora.auroraGlass +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.auth.openNativeSignIn +import org.siloserver.silo.android.ui.components.marquee.MarqueeButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeButtonKind +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorHaptic +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorText +import org.siloserver.silo.android.ui.components.marquee.MarqueeFieldGroup +import org.siloserver.silo.android.ui.components.marquee.MarqueeHeadline +import org.siloserver.silo.android.ui.components.marquee.MarqueeIconButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeLabeledDivider +import org.siloserver.silo.android.ui.components.marquee.MarqueeMetrics +import org.siloserver.silo.android.ui.components.marquee.MarqueeProviderMark +import org.siloserver.silo.android.ui.components.marquee.MarqueeSeparator +import org.siloserver.silo.android.ui.components.marquee.MarqueeServerChip +import org.siloserver.silo.android.ui.components.marquee.MarqueeStage +import org.siloserver.silo.android.ui.components.marquee.MarqueeTextField +import org.siloserver.silo.android.ui.components.marquee.MarqueeTopBarSpacer +import org.siloserver.silo.common.ui.marquee.marqueeShake +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeScene +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.common.ui.marquee.ServerBrandingLoader +import org.siloserver.silo.common.ui.marquee.SignInHandoff +import org.siloserver.silo.common.ui.marquee.rememberReduceMotion +import org.siloserver.silo.common.ui.marquee.rememberSignInServer +import org.siloserver.silo.model.auth.SignInProvider +import org.siloserver.silo.network.ServerRegistry /** - * Sign-in. Mirrors silo-apple iOS phone `LoginView` (Aurora): wordmark, - * "Step 02 — Sign in" eyebrow + "Welcome back", then a glass card with - * "Continue as " first when the server lists a network provider for - * this device (such as Tailscale: no browser, no password), a - * "Sign in with " button per external provider the server lists - * (OIDC, run in a Custom Tab), "Use a different account" when the server - * takes `prompt=select_account`, Username + Password fields and the cream - * "Sign in" button when any listed provider takes a password (the local one - * or a directory such as LDAP), and the Create-account / Change-server ghost - * buttons. + * Sign-in, laid out like silo-apple's phone `LoginView` (Marquee): "Sign in" + * with the server's branded subtitle, then "Continue as " first when + * the server lists a network provider for this device (such as Tailscale), + * a "Sign in with " button per external provider (OIDC, run in a + * Custom Tab), "Use a different account" when the server takes + * `prompt=select_account`, and the username and password form when any + * listed provider takes a password. With two or more providers the password + * form waits behind "Sign in with a password". [LoginViewModel] owns the + * sign-in rules; this screen lays them out. * - * @param signupEnabled Whether the "Create account" ghost is shown. + * @param signupEnabled Whether "Create account" is shown. * @param onNavigateToSignup Tapped "Create account". - * @param onNavigateToProfiles Called after a successful login. - * @param onChangeServer Tapped "Change server" (back to server setup). + * @param onNavigateToProfiles Called after a successful sign-in. + * @param onChangeServer Back, or "Use a different server" from the server chip. */ @Composable fun LoginScreen( @@ -78,15 +102,24 @@ fun LoginScreen( onNavigateToProfiles: () -> Unit, onChangeServer: () -> Unit = {}, viewModel: LoginViewModel = koinViewModel(), + brandingLoader: ServerBrandingLoader = koinInject(), + serverRegistry: ServerRegistry = koinInject(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } - - // Coming from server setup the next action is always typing a username; - // focus it immediately so the keyboard stays up across the transition. + val activeEntry by serverRegistry.activeEntry.collectAsState() + val server = rememberSignInServer( + serverUrl = activeEntry?.url.orEmpty(), + savedName = activeEntry?.fetchedName, + loader = brandingLoader, + ) + val focusManager = LocalFocusManager.current val usernameFocus = remember { FocusRequester() } - LaunchedEffect(state.showPasswordForm) { - if (state.showPasswordForm) runCatching { usernameFocus.requestFocus() } + var expandsPasswordForm by remember { mutableStateOf(false) } + var passwordFailures by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + MarqueeScene.focus = MarqueeScene.Focus.Account + MarqueeScene.personalTint = null } // A provider's native start opens in a Custom Tab; the app redirect @@ -101,9 +134,19 @@ fun LoginScreen( LaunchedEffect(state.loginSuccess) { if (state.loginSuccess) { viewModel.onLoginSuccessConsumed() + SignInHandoff.skipsSingleProfilePicker = true onNavigateToProfiles() } } + // Each rejected password shakes the fields; an error already showing + // when the screen comes back doesn't. + val initialError = remember { state.error } + LaunchedEffect(state.error) { + if (state.error != null && state.error != initialError && state.showPasswordForm && state.password.isNotEmpty()) { + passwordFailures++ + } + } + MarqueeErrorHaptic(state.error) // Browser sign-in results arrive after the Custom Tab closes, away from // where focus is: announce them. @@ -117,213 +160,289 @@ fun LoginScreen( ) } - AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { - SiloLogo() + // With two or more providers the password form waits behind a button, + // so the providers stay the first thing on screen. + val foldsPasswordForm = state.providers.size >= 2 && !expandsPasswordForm + // Coming from server setup the next action is typing a username; focus + // it as soon as the form shows so the keyboard stays up across the + // transition. A folded form waits until it's opened. + LaunchedEffect(state.showPasswordForm, foldsPasswordForm) { + if (state.showPasswordForm && !foldsPasswordForm) runCatching { usernameFocus.requestFocus() } + } - Spacer(Modifier.height(30.dp)) - AuroraEyebrow(text = "Step 02 — Sign in", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Welcome back", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - ) - state.serverHostLabel?.let { host -> - Spacer(Modifier.height(8.dp)) - Text( - text = host, - modifier = Modifier.fillMaxWidth(), - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - color = AuroraInkTertiary, - textAlign = TextAlign.Center, - maxLines = 1, + // Leaving mid-sign-in would let the finished sign-in pull the app back + // from server setup, so going back waits until it settles. + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + onBack = onChangeServer, + blockBack = state.signInBusy, + topBar = { + MarqueeIconButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Change server", + onClick = onChangeServer, + enabled = !state.signInBusy, ) - } - Spacer(Modifier.height(24.dp)) + MarqueeTopBarSpacer() + MarqueeServerChip( + name = server.name, + hostLabel = server.hostLabel, + markUrl = server.branding?.markUrl, + actionLabel = "Use a different server", + onAction = onChangeServer, + enabled = !state.signInBusy, + ) + }, + ) { + MarqueeHeadline( + title = "Sign in", + lead = server.branding?.loginSubtitle ?: "Use your ${server.name} account.", + ) - Column2Glass { - // No password form until discovery answers: a server that signs in - // only through a provider must not flash one (silo-apple parity). + Column(Modifier.fillMaxWidth().padding(top = 24.dp).animateContentSize()) { if (state.options == null) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp).then(announced), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator(color = AuroraInkTertiary, strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) - Text(text = "Loading sign-in options…", color = AuroraInkTertiary, fontSize = 14.sp) - } + LoadingSkeleton() + return@Column } + state.networkProvider?.let { provider -> - SignInProviderButton( - provider = provider, - label = continueAsLabel(provider), - supportingText = continueViaLabel(provider), + MarqueeButton( onClick = viewModel::onNetworkSignIn, - busy = state.networkSignInBusy, + isLoading = state.networkSignInBusy, enabled = !state.signInBusy, - ) + modifier = Modifier.semantics { + contentDescription = if (state.networkSignInBusy) "Signing in…" else continueAsLabel(provider) + }, + ) { + if (state.networkSignInBusy) { + Text("Signing in…") + } else { + NetworkSignInLabel(provider) + } + } + if (state.providers.isNotEmpty() || state.showPasswordForm) { + MarqueeLabeledDivider("or", Modifier.padding(vertical = 18.dp)) + } } - state.providers.forEach { provider -> - SignInProviderButton( - provider = provider, - label = signInWithLabel(provider), + + state.providers.forEachIndexed { index, provider -> + val busy = state.providerBusy == provider.id + // The first provider leads unless a network sign-in already does. + val primary = index == 0 && state.networkProvider == null + MarqueeButton( onClick = { viewModel.onProviderClick(provider) }, - busy = state.providerBusy == provider.id, + kind = if (primary) MarqueeButtonKind.Primary else MarqueeButtonKind.Glass, + isLoading = busy, enabled = !state.signInBusy, - ) + modifier = Modifier.padding(top = if (index > 0) 10.dp else 0.dp), + ) { + if (busy) { + Text("Waiting for sign-in…") + } else { + MarqueeProviderMark(provider) + Text(signInWithLabel(provider), maxLines = 2, textAlign = TextAlign.Center) + } + } } if (state.offersAccountChoice) { - AuroraGhostButton( - label = differentAccountLabel(state.providers), + MarqueeButton( + text = differentAccountLabel(state.providers), onClick = viewModel::onUseDifferentAccount, - modifier = Modifier.align(Alignment.CenterHorizontally), + kind = MarqueeButtonKind.Plain, + enabled = !state.signInBusy, + modifier = Modifier.padding(top = 6.dp), + ) + } + + if (state.options != null && !state.showPasswordForm && state.providers.isEmpty() && state.networkProvider == null) { + MarqueeErrorText( + "This server doesn't offer a sign-in this app can use. Sign in on the web, or ask the server's admin.", ) } + // Discovery failed: the password form still shows, and this row // says the providers may be missing and retries. if (state.optionsUnavailable) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + Modifier.fillMaxWidth().padding(bottom = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "Couldn't load sign-in options.", - modifier = Modifier.weight(1f).then(announced), - color = AuroraInkTertiary, - fontSize = 13.sp, - ) - AuroraGhostButton(label = "Retry", onClick = viewModel::loadOptions) + MarqueeErrorText("Couldn't load sign-in options.", Modifier.weight(1f).then(announced)) + TextButton(onClick = viewModel::loadOptions, enabled = !state.signInBusy) { + Text("Retry", color = MarqueeColors.Ink, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } } } - if ((state.providers.isNotEmpty() || state.networkProvider != null) && state.showPasswordForm) { - OrDivider() - } - if (!state.showPasswordForm) { - if (state.options != null && state.providers.isEmpty() && state.networkProvider == null) { - // Password sign-in is off and the provider can't run in this app. - AuroraErrorLabel("This server doesn't offer a sign-in this app can use. Sign in on the web, or ask the server's admin.") + + if (state.showPasswordForm) { + if (state.providers.isNotEmpty()) { + MarqueeLabeledDivider( + if (foldsPasswordForm) "or" else "or use your password", + Modifier.padding(vertical = 18.dp), + ) } - state.error?.let { AuroraErrorLabel(it, modifier = announced) } + if (foldsPasswordForm) { + MarqueeButton( + text = "Sign in with a password", + onClick = { expandsPasswordForm = true }, + kind = MarqueeButtonKind.Plain, + icon = Icons.Outlined.Key, + enabled = !state.signInBusy, + ) + // A provider's failure has no form to show it in. + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 14.dp).then(announced)) } + } else { + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.marqueeShake(passwordFailures)) { + MarqueeTextField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = "Username", + imeAction = ImeAction.Next, + focusRequester = usernameFocus, + enabled = !state.signInBusy, + ) + MarqueeSeparator() + MarqueeTextField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, + isError = state.error != null, + enabled = !state.signInBusy, + imeAction = ImeAction.Go, + onImeAction = { + focusManager.clearFocus() + viewModel.onLoginClick() + }, + ) + } + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp, start = 4.dp).then(announced)) } + MarqueeButton( + text = if (state.isLoading) "Signing in…" else "Sign in", + onClick = { + focusManager.clearFocus() + viewModel.onLoginClick() + }, + isLoading = state.isLoading, + // Any sign-in under way: the view model takes no password until it ends. + enabled = !state.signInBusy, + modifier = Modifier.padding(top = 14.dp), + ) + } + } else { + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 14.dp).then(announced)) } if (state.isLoading) { Text( text = "Finishing sign-in…", - modifier = Modifier.fillMaxWidth().then(announced), - color = AuroraInkTertiary, + modifier = Modifier.fillMaxWidth().padding(top = 14.dp).then(announced), + color = MarqueeColors.InkTertiary, textAlign = TextAlign.Center, ) } } - if (state.showPasswordForm) { - AuroraTextField( - label = "Username", - value = state.username, - onValueChange = viewModel::onUsernameChanged, - modifier = Modifier.focusRequester(usernameFocus), - placeholder = "yourname", - imeAction = ImeAction.Next, - enabled = !state.signInBusy, - ) - AuroraTextField( - label = "Password", - value = state.password, - onValueChange = viewModel::onPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - onImeAction = viewModel::onLoginClick, - visualTransformation = if (showPassword) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - enabled = !state.signInBusy, - trailing = { - IconButton(onClick = { showPassword = !showPassword }, enabled = !state.signInBusy) { - 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), - ) - } - }, - ) - state.error?.let { AuroraErrorLabel(it, modifier = announced) } + Footnote(state) - AuroraPrimaryButton( - label = if (state.isLoading) "Signing in…" else "Sign in", - onClick = viewModel::onLoginClick, - isLoading = state.isLoading, - // Any sign-in under way: the view model takes no password until it ends. + if (state.signupEnabled) { + MarqueeButton( + text = "Create account", + onClick = onNavigateToSignup, + kind = MarqueeButtonKind.Plain, enabled = !state.signInBusy, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(top = 4.dp), ) } + } + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(14.dp, androidx.compose.ui.Alignment.CenterHorizontally), - ) { - if (state.signupEnabled) { - AuroraGhostButton(label = "Create account", onClick = onNavigateToSignup) - } - AuroraGhostButton(label = "Change server", onClick = onChangeServer) +@Composable +private fun NetworkSignInLabel(provider: SignInProvider) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + MarqueeProviderMark(provider) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(continueAsLabel(provider), maxLines = 1) + continueViaLabel(provider)?.let { via -> + Text(via, fontSize = 12.sp, fontWeight = FontWeight.Medium, modifier = Modifier.alpha(0.7f), maxLines = 1) } } } } -/** "or" between the provider buttons and the password form. */ +@Composable +private fun Footnote(state: LoginUiState) { + val options = state.options ?: return + val text = when { + options.oauthProviders.isEmpty() && options.showPasswordForm -> "Forgot your password? Ask the server admin." + options.oauthProviders.isNotEmpty() && !options.showPasswordForm -> "Password sign-in is turned off on this server." + else -> return + } + Text( + text, + color = MarqueeColors.InkTertiary, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) +} + +/** Placeholders with a soft shimmer while the server's sign-in options load. */ +@Composable +private fun LoadingSkeleton() { + val reduceMotion = rememberReduceMotion() + val shimmer = if (reduceMotion) { + 0.08f + } else { + val transition = rememberInfiniteTransition(label = "skeleton") + transition.animateFloat( + initialValue = 0.06f, + targetValue = 0.12f, + animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), + label = "skeletonAlpha", + ).value + } + Column( + Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { contentDescription = "Loading sign-in options" }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box(Modifier.fillMaxWidth().height(MarqueeMetrics.ButtonHeight).clip(CircleShape).background(Color.White.copy(alpha = shimmer))) + Box(Modifier.width(140.dp).height(10.dp).clip(CircleShape).background(Color.White.copy(alpha = shimmer))) + Box( + Modifier + .fillMaxWidth() + .height(MarqueeMetrics.FieldHeight * 2) + .clip(RoundedCornerShape(MarqueeMetrics.FieldCorner)) + .background(Color.White.copy(alpha = shimmer)), + ) + } +} + /** "Use a different account" with several providers: which one to sign in with. */ @Composable private fun AccountProviderChooser( - providers: List, - onChoose: (org.siloserver.silo.model.auth.SignInProvider) -> Unit, + providers: List, + onChoose: (SignInProvider) -> Unit, onDismiss: () -> Unit, ) { - androidx.compose.material3.AlertDialog( + AlertDialog( onDismissRequest = onDismiss, title = { Text("Use a different account") }, text = { - androidx.compose.foundation.layout.Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { providers.forEach { provider -> - androidx.compose.material3.TextButton(onClick = { onChoose(provider) }, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = { onChoose(provider) }, modifier = Modifier.fillMaxWidth()) { Text(signInWithLabel(provider)) } } } }, confirmButton = {}, - dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -@Composable -private fun OrDivider() { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Box(Modifier.weight(1f).height(1.dp).background(Color.White.copy(alpha = 0.12f))) - Text( - text = "or", - modifier = Modifier.padding(horizontal = 12.dp), - color = AuroraInkTertiary, - fontSize = 13.sp, - ) - Box(Modifier.weight(1f).height(1.dp).background(Color.White.copy(alpha = 0.12f))) - } -} - -@Composable -private fun Column2Glass(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { - androidx.compose.foundation.layout.Column( - modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - content = content, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt index f0a10b58a2..f3a23c622d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt @@ -1,22 +1,34 @@ package org.siloserver.silo.android.ui.screens.auth import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material3.Icon +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -27,51 +39,71 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.rotate +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle 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.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import org.siloserver.silo.android.R -import org.siloserver.silo.android.ui.components.aurora.AuroraErrorLabel -import org.siloserver.silo.android.ui.components.aurora.AuroraEyebrow -import org.siloserver.silo.android.ui.components.aurora.AuroraFieldLabel -import org.siloserver.silo.android.ui.components.aurora.AuroraGhostButton -import org.siloserver.silo.android.ui.components.aurora.AuroraInk -import org.siloserver.silo.android.ui.components.aurora.AuroraPrimaryButton -import org.siloserver.silo.android.ui.components.aurora.AuroraScreen -import org.siloserver.silo.android.ui.components.aurora.AuroraSegment -import org.siloserver.silo.android.ui.components.aurora.AuroraTextField -import org.siloserver.silo.android.ui.components.aurora.AuroraVariant -import org.siloserver.silo.android.ui.components.aurora.auroraGlass +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.ui.components.marquee.MarqueeButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorHaptic +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorText +import org.siloserver.silo.android.ui.components.marquee.MarqueeHeadline +import org.siloserver.silo.android.ui.components.marquee.MarqueeMetrics +import org.siloserver.silo.android.ui.components.marquee.MarqueeSeparator +import org.siloserver.silo.android.ui.components.marquee.MarqueeStage +import org.siloserver.silo.android.ui.components.marquee.MarqueeTextField +import org.siloserver.silo.android.ui.components.marquee.MarqueeWordmark +import org.siloserver.silo.android.ui.components.marquee.marqueePressHaptic +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeScene +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.common.ui.marquee.MarqueeServerMark +import org.siloserver.silo.common.ui.marquee.ServerBranding +import org.siloserver.silo.common.ui.marquee.ServerBrandingCache +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.ServerRegistry /** - * First screen the user sees. Allows entering the Silo server URL. - * - * Rebuilt to match silo-apple's `ServerSetupView` (Aurora): a single centered - * glass form over the plum backdrop. Protocol + port stay tucked under - * "Advanced options". + * First screen when no server is configured: the address field over the + * brand light, recent servers below it, and protocol and port under + * "Advanced options" (silo-apple `ServerSetupView`). * * After validating the server: * - If the server needs setup, navigates to [onNavigateToSetup]. - * - Otherwise navigates to [onNavigateToLogin], indicating whether signup is - * enabled. + * - A saved server this device is still signed in to goes to [onNavigateToProfiles]. + * - Otherwise navigates to [onNavigateToLogin], indicating whether signup is enabled. */ @Composable fun ServerSetupScreen( onNavigateToSetup: () -> Unit, onNavigateToLogin: (signupEnabled: Boolean) -> Unit, + onNavigateToProfiles: () -> Unit, /** An address to start from, such as the server an app link named. */ prefillUrl: String? = null, viewModel: ServerSetupViewModel = koinViewModel(), + serverRegistry: ServerRegistry = koinInject(), + brandingCache: ServerBrandingCache = koinInject(), ) { val state by viewModel.uiState.collectAsState() + val entries by serverRegistry.entries.collectAsState() + val recents = remember(entries) { entries.sortedByDescending { it.lastUsedAtEpochMs }.take(3) } + var fieldFocused by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { MarqueeScene.showGeneric() } LaunchedEffect(prefillUrl) { prefillUrl?.takeIf { it.isNotBlank() }?.let(viewModel::prefill) } @@ -89,160 +121,290 @@ fun ServerSetupScreen( onNavigateToLogin(dest.signupEnabled) } + ServerSetupDestination.Profiles -> { + viewModel.onNavigationConsumed() + onNavigateToProfiles() + } + null -> Unit } } + MarqueeErrorHaptic(state.error) var showAdvanced by rememberSaveable { mutableStateOf(false) } state.pendingCleartextUrl?.let { origin -> AlertDialog( onDismissRequest = viewModel::cancelCleartextConnection, - title = { Text("Use unencrypted HTTP?") }, + title = { Text("Connect without encryption?") }, 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 sign-in. Continue only on a network you trust.", - ) - } + Text( + "Your password and what you watch will be sent unencrypted to " + + "${ServerBranding.hostLabel(origin)}. Only do this on a network you trust.", + ) }, confirmButton = { - AuroraPrimaryButton( - label = "Use HTTP", - onClick = viewModel::confirmCleartextConnection, - modifier = Modifier.width(140.dp), - isLoading = state.isLoading, - ) + TextButton(onClick = viewModel::confirmCleartextConnection, enabled = !state.isLoading) { + Text("Connect") + } }, dismissButton = { - AuroraGhostButton( - label = "Cancel", - onClick = viewModel::cancelCleartextConnection, - ) + TextButton(onClick = viewModel::cancelCleartextConnection) { Text("Cancel") } }, ) } - AuroraScreen(variant = AuroraVariant.Server) { - // Silo wordmark (iOS width 132). - Image( - painter = painterResource(id = R.drawable.silo_wordmark), - contentDescription = "Silo", + MarqueeStage( + scrim = MarqueeScrimStyle.Bottom, + frostStart = 0.30f, + topBar = { MarqueeWordmark() }, + ) { + MarqueeHeadline(title = "Connect to\nyour server", lead = "Type the address you use for Silo.") + + MarqueeTextField( + value = state.serverUrl, + onValueChange = viewModel::onServerUrlChanged, + icon = Icons.Outlined.Language, + placeholder = "media.example.com", + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + onImeAction = viewModel::onConnectClick, + showsClearButton = true, + isError = state.error != null, modifier = Modifier - .width(132.dp) - .height(40.dp), - contentScale = ContentScale.Fit, + .padding(top = 20.dp) + .onFocusChanged { fieldFocused = it.hasFocus }, ) - Spacer(Modifier.height(30.dp)) + state.error?.let { error -> + MarqueeErrorText(error, Modifier.padding(top = 10.dp, start = 4.dp)) + } + if (state.usesCleartext && state.error == null) { + Text( + "This address uses unencrypted HTTP. Fine on a trusted home network; avoid it on public Wi-Fi.", + color = MarqueeColors.Warning, + fontSize = 12.sp, + modifier = Modifier.padding(top = 10.dp, start = 4.dp), + ) + } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - AuroraEyebrow(text = "Step 01 — Connect", centered = true) + AdvancedOptions( + expanded = showAdvanced, + onToggle = { showAdvanced = !showAdvanced }, + enabled = !state.isLoading, + scheme = state.selectedScheme, + onSchemeSelected = viewModel::onSchemeSelected, + port = state.port, + onPortChanged = viewModel::onPortChanged, + modifier = Modifier.padding(top = 12.dp), + ) + + // Recents are for picking before typing; while the address field has + // the keyboard, the field and Continue need the room. + if (recents.isNotEmpty() && !fieldFocused) { Text( - text = "Add your server", - fontSize = 28.sp, + "Recent", + color = MarqueeColors.InkSecondary, + fontSize = 15.sp, fontWeight = FontWeight.SemiBold, - color = AuroraInk, - textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 22.dp, start = 2.dp, bottom = 10.dp), + ) + RecentServers( + servers = recents, + brandingCache = brandingCache, + enabled = !state.isLoading, + onPick = { server -> + viewModel.useRecent(server.url) + viewModel.onConnectClick() + }, ) } - Spacer(Modifier.height(24.dp)) + MarqueeButton( + text = if (state.isLoading) "Connecting…" else "Continue", + onClick = viewModel::onConnectClick, + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 22.dp), + ) + } +} - // Glass card. - Column( +@Composable +private fun AdvancedOptions( + expanded: Boolean, + onToggle: () -> Unit, + enabled: Boolean, + scheme: ServerSetupScheme, + onSchemeSelected: (ServerSetupScheme) -> Unit, + port: String, + onPortChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxWidth().animateContentSize()) { + val chevron by animateFloatAsState(if (expanded) 180f else 0f, label = "advancedChevron") + val interaction = remember { MutableInteractionSource() } + Row( modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), + .heightIn(min = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onToggle) + .marqueePressHaptic(interaction) + .semantics { stateDescription = if (expanded) "Expanded" else "Collapsed" } + .padding(start = 4.dp, end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, ) { - AuroraTextField( - label = "Server address", - value = state.serverUrl, - onValueChange = viewModel::onServerUrlChanged, - placeholder = "media.example.com", - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - onImeAction = viewModel::onConnectClick, + Icon(Icons.Outlined.Settings, contentDescription = null, tint = MarqueeColors.InkSecondary, modifier = Modifier.size(16.dp)) + Text("Advanced options", color = MarqueeColors.InkSecondary, fontSize = 14.sp) + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MarqueeColors.InkSecondary, + modifier = Modifier.size(16.dp).rotate(chevron), ) - - // Advanced options disclosure. - val chevronRotation by animateFloatAsState( - targetValue = if (showAdvanced) 180f else 0f, - label = "advancedChevron", - ) - AuroraGhostButton( - label = "Advanced options", - onClick = { showAdvanced = !showAdvanced }, - fillMaxWidth = true, - trailing = { - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = null, - tint = AuroraInk.copy(alpha = 0.62f), - modifier = Modifier - .size(16.dp) - .rotate(chevronRotation), - ) - }, - ) - - AnimatedVisibility(visible = showAdvanced) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - AuroraFieldLabel("Protocol") - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf( - ServerSetupScheme.Auto, - ServerSetupScheme.Https, - ServerSetupScheme.Http, - ).forEach { scheme -> - AuroraSegment( - title = scheme.label, - isSelected = state.selectedScheme == scheme, - onClick = { viewModel.onSchemeSelected(scheme) }, - modifier = Modifier.weight(1f), - ) + } + AnimatedVisibility(visible = expanded) { + Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val shape = RoundedCornerShape(MarqueeMetrics.FieldCorner) + Column( + Modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + ) { + Row( + Modifier.fillMaxWidth().height(MarqueeMetrics.FieldHeight).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Protocol", color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont, modifier = Modifier.weight(1f)) + SegmentedPicker( + options = ServerSetupScheme.entries, + selected = scheme, + label = { it.label }, + onSelect = onSchemeSelected, + ) + } + MarqueeSeparator() + Row( + Modifier.fillMaxWidth().height(MarqueeMetrics.FieldHeight).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Port", color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont, modifier = Modifier.weight(1f)) + Box(Modifier.width(120.dp), contentAlignment = Alignment.CenterEnd) { + if (port.isEmpty()) { + Text("Auto", color = MarqueeColors.InkTertiary, fontSize = MarqueeMetrics.FieldFont) } + BasicTextField( + value = port, + onValueChange = onPortChanged, + singleLine = true, + textStyle = TextStyle(color = MarqueeColors.Ink, fontSize = MarqueeMetrics.FieldFont, textAlign = TextAlign.End), + cursorBrush = SolidColor(Color(0xFF0A84FF)), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Port" }, + ) } } - AuroraTextField( - label = "Port", - value = state.port, - onValueChange = viewModel::onPortChanged, - placeholder = "8090", - keyboardType = KeyboardType.Number, - ) } + Text( + "Auto tries HTTPS first, then HTTP, then Silo's port 8090.", + color = MarqueeColors.InkTertiary, + fontSize = 12.sp, + modifier = Modifier.padding(start = 4.dp), + ) } + } + } +} - state.error?.let { error -> - AuroraErrorLabel(error) +/** A small iOS-style segmented control. */ +@Composable +private fun SegmentedPicker(options: List, selected: T, label: (T) -> String, onSelect: (T) -> Unit) { + Row( + Modifier + .width(190.dp) + .height(32.dp) + .clip(RoundedCornerShape(9.dp)) + .background(Color.White.copy(alpha = 0.10f)) + .padding(2.dp), + ) { + options.forEach { option -> + val isSelected = option == selected + Box( + Modifier + .weight(1f) + .height(28.dp) + .clip(RoundedCornerShape(7.dp)) + .background(if (isSelected) Color.White.copy(alpha = 0.28f) else Color.Transparent) + .clickable(role = Role.RadioButton) { onSelect(option) }, + contentAlignment = Alignment.Center, + ) { + Text( + label(option), + color = MarqueeColors.Ink, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + ) } + } + } +} - if (state.usesCleartext && state.error == null) { - androidx.compose.material3.Text( - modifier = androidx.compose.ui.Modifier.fillMaxWidth(), - text = "This server uses unencrypted HTTP. Fine on a trusted home network; " + - "avoid it on public Wi-Fi.", - fontSize = 12.sp, - color = org.siloserver.silo.android.ui.theme.SiloWarning, +@Composable +private fun RecentServers( + servers: List, + brandingCache: ServerBrandingCache, + enabled: Boolean, + onPick: (ServerEntry) -> Unit, +) { + val shape = RoundedCornerShape(16.dp) + Column( + Modifier + .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + ) { + servers.forEachIndexed { index, server -> + if (index > 0) MarqueeSeparator() + val host = ServerBranding.hostLabel(server.url) + val interaction = remember { MutableInteractionSource() } + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null) { onPick(server) } + .marqueePressHaptic(interaction) + .semantics { contentDescription = "${server.displayName}, $host" } + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeServerMark( + name = server.fetchedName, + imageUrl = brandingCache.branding(server.url)?.markUrl, + size = 40.dp, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + server.displayName, + color = MarqueeColors.Ink, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text(host, color = MarqueeColors.InkTertiary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MarqueeColors.InkTertiary, + modifier = Modifier.size(18.dp), ) } - - AuroraPrimaryButton( - label = if (state.isLoading) "Connecting…" else "Connect", - onClick = viewModel::onConnectClick, - isLoading = state.isLoading, - modifier = Modifier.padding(top = 4.dp), - ) } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupViewModel.kt index 796a68e9a1..ca2970948d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupViewModel.kt @@ -49,6 +49,9 @@ sealed class ServerSetupDestination { /** Server is ready -- go to login. Signup may or may not be available. */ data class Login(val signupEnabled: Boolean) : ServerSetupDestination() + + /** A saved server this device is still signed in to: straight to its profiles. */ + data object Profiles : ServerSetupDestination() } class ServerSetupViewModel( @@ -67,6 +70,8 @@ class ServerSetupViewModel( private val candidateUrls: (ServerSetupUiState) -> List = { buildServerSetupCandidateUrls(it.serverUrl, it.selectedScheme, it.port) }, + /** Whether the now-active server already has a signed-in session, as when a saved server is picked from Recent. */ + private val hasSession: suspend () -> Boolean = { false }, ) : ViewModel() { private val _uiState = MutableStateFlow(ServerSetupUiState()) @@ -105,6 +110,15 @@ class ServerSetupViewModel( _uiState.update { it.copy(port = port, error = null) } } + /** + * Fills in a saved server's address. Its URL already names the scheme and + * port, so a protocol or port chosen for an earlier attempt must not + * override them. + */ + fun useRecent(url: String) { + _uiState.update { it.copy(serverUrl = url, selectedScheme = ServerSetupScheme.Auto, port = "", error = null) } + } + /** * Validates the entered server URL: * 1. Build candidate URLs from the host, protocol, and optional port. @@ -257,11 +271,18 @@ class ServerSetupViewModel( contract: ServerContract?, ) { authRepository.setServerUrl(serverUrl, contract) + // A saved server that is still signed in goes straight to its + // profiles; only a server without a session needs sign-in. + val next = if (destination is ServerSetupDestination.Login && hasSession()) { + ServerSetupDestination.Profiles + } else { + destination + } _uiState.update { it.copy( isLoading = false, pendingCleartextUrl = null, - navigateTo = destination, + navigateTo = next, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SetupScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SetupScreen.kt index 9b61dd939f..4757203573 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SetupScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SetupScreen.kt @@ -1,48 +1,35 @@ package org.siloserver.silo.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.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person 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.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.unit.dp -import androidx.compose.ui.unit.sp -import org.siloserver.silo.android.ui.components.aurora.AuroraEyebrow -import org.siloserver.silo.android.ui.components.aurora.AuroraErrorLabel -import org.siloserver.silo.android.ui.components.aurora.AuroraPrimaryButton -import org.siloserver.silo.android.ui.components.aurora.AuroraScreen -import org.siloserver.silo.android.ui.components.aurora.AuroraScrim -import org.siloserver.silo.android.ui.components.aurora.AuroraTextField -import org.siloserver.silo.android.ui.components.aurora.AuroraVariant -import org.siloserver.silo.android.ui.components.aurora.auroraGlass import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.ui.components.marquee.MarqueeButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorHaptic +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorText +import org.siloserver.silo.android.ui.components.marquee.MarqueeFieldGroup +import org.siloserver.silo.android.ui.components.marquee.MarqueeHeadline +import org.siloserver.silo.android.ui.components.marquee.MarqueeSeparator +import org.siloserver.silo.android.ui.components.marquee.MarqueeStage +import org.siloserver.silo.android.ui.components.marquee.MarqueeTextField +import org.siloserver.silo.android.ui.components.marquee.MarqueeWordmark +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle /** - * First-run admin account creation — an Android capability (iOS points to the - * browser for this); kept and reskinned in the Aurora grammar to match the rest - * of onboarding. Wordmark, "Setup needed" eyebrow + "Welcome to Silo", glass - * card with Username / Email / Password + cream "Create account". + * First-run admin account creation on a server that has none yet. An Android + * capability (the Apple apps send the person to the browser for this), laid + * out like the other first-run screens: Username, Email and Password as one + * grouped block and "Create account". */ @Composable fun SetupScreen( @@ -50,7 +37,6 @@ fun SetupScreen( viewModel: SetupViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } LaunchedEffect(state.setupSuccess) { if (state.setupSuccess) { @@ -58,81 +44,50 @@ fun SetupScreen( onNavigateToProfiles() } } + MarqueeErrorHaptic(state.error) - AuroraScreen(variant = AuroraVariant.Server, scrim = AuroraScrim.Soft) { - SiloLogo() - - Spacer(Modifier.height(30.dp)) - AuroraEyebrow(text = "Setup needed", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Welcome to Silo", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - ) - Spacer(Modifier.height(6.dp)) - Text( - text = "Create your first admin profile.", - fontSize = 15.sp, - color = Color(0xFFF3EFE9).copy(alpha = 0.7f), + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + topBar = { MarqueeWordmark() }, + ) { + MarqueeHeadline( + title = "Set up\nyour server", + lead = "This server has no accounts yet. Create the first one; it becomes the admin.", ) - Spacer(Modifier.height(24.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - AuroraTextField( - label = "Username", + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.padding(top = 24.dp)) { + MarqueeTextField( value = state.username, onValueChange = viewModel::onUsernameChanged, - placeholder = "yourname", - imeAction = ImeAction.Next, + icon = Icons.Outlined.Person, + placeholder = "Username", ) - AuroraTextField( - label = "Email", + MarqueeSeparator() + MarqueeTextField( value = state.email, onValueChange = viewModel::onEmailChanged, - placeholder = "you@example.com", + icon = Icons.Outlined.Email, + placeholder = "Email", keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, ) - AuroraTextField( - label = "Password", + MarqueeSeparator() + MarqueeTextField( value = state.password, onValueChange = viewModel::onPasswordChanged, - placeholder = "••••••", - keyboardType = KeyboardType.Password, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, imeAction = ImeAction.Go, onImeAction = viewModel::onCreateAccountClick, - 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), - ) - } - }, - ) - - state.error?.let { AuroraErrorLabel(it) } - - AuroraPrimaryButton( - label = if (state.isLoading) "Creating…" else "Create account", - onClick = viewModel::onCreateAccountClick, - isLoading = state.isLoading, - modifier = Modifier.fillMaxWidth(), ) } + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp)) } + MarqueeButton( + text = if (state.isLoading) "Creating…" else "Create account", + onClick = viewModel::onCreateAccountClick, + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 14.dp), + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInLabels.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInLabels.kt new file mode 100644 index 0000000000..34269bd1c3 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInLabels.kt @@ -0,0 +1,25 @@ +package org.siloserver.silo.android.ui.screens.auth + +import org.siloserver.silo.model.auth.SignInProvider + +/** "Sign in with ". */ +fun signInWithLabel(provider: SignInProvider): String = "Sign in with ${provider.displayName}" + +/** + * A network provider's button: "Continue as " when the provider named + * who owns this device, "Continue with " when it named nobody. + */ +fun continueAsLabel(provider: SignInProvider): String = + provider.networkIdentity?.label?.let { "Continue as $it" } ?: "Continue with ${provider.displayName}" + +/** The line under "Continue as " naming the provider; none when the label already names it. */ +fun continueViaLabel(provider: SignInProvider): String? = + provider.networkIdentity?.label?.let { "via ${provider.displayName}" } + +/** + * The account-choice link under the provider buttons. It picks another + * account at the provider, not another way into Silo, so it names the + * provider when there is one; with several, a chooser follows. + */ +fun differentAccountLabel(providers: List): String = + providers.singleOrNull()?.let { "Use a different ${it.displayName} account" } ?: "Use a different account" diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt deleted file mode 100644 index e07303184c..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignInProviderButton.kt +++ /dev/null @@ -1,153 +0,0 @@ -package org.siloserver.silo.android.ui.screens.auth - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Login -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.svg.SvgDecoder -import org.siloserver.silo.android.ui.components.aurora.AuroraInk -import org.siloserver.silo.model.auth.SignInProvider - -/** "Sign in with ". */ -fun signInWithLabel(provider: SignInProvider): String = "Sign in with ${provider.displayName}" - -/** - * A network provider's button: "Continue as " when the provider named - * who owns this device, "Continue with " when it named nobody. - */ -fun continueAsLabel(provider: SignInProvider): String = - provider.networkIdentity?.label?.let { "Continue as $it" } ?: "Continue with ${provider.displayName}" - -/** The line under "Continue as " naming the provider; none when the label already names it. */ -fun continueViaLabel(provider: SignInProvider): String? = - provider.networkIdentity?.label?.let { "via ${provider.displayName}" } - -/** - * The account-choice link under the provider buttons. It picks another - * account at the provider, not another way into Silo, so it names the - * provider when there is one; with several, a chooser follows. - */ -fun differentAccountLabel(providers: List): String = - providers.singleOrNull()?.let { "Use a different ${it.displayName} account" } ?: "Use a different account" - -/** - * One external sign-in provider's button: its icon (SVG or bitmap from the - * plugin, a generic sign-in glyph when it has none or it can't be drawn), - * [label] and, when given, a smaller [supportingText] line under it. - * Glass-outlined so the cream password button stays the primary action when - * both are offered. The button reads as one element, both lines together. - */ -@Composable -fun SignInProviderButton( - provider: SignInProvider, - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - busy: Boolean = false, - enabled: Boolean = true, - supportingText: String? = null, -) { - val shape = RoundedCornerShape(14.dp) - val active = enabled && !busy - Box( - modifier = modifier - .fillMaxWidth() - .clip(shape) - .background(Color.White.copy(alpha = 0.08f)) - .border(width = 1.dp, color = Color.White.copy(alpha = if (active) 0.22f else 0.10f), shape = shape) - .clickable( - enabled = active, - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick, - ) - .padding(horizontal = 20.dp, vertical = 15.dp), - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (busy) { - CircularProgressIndicator(color = AuroraInk, strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) - } else { - ProviderIcon(provider) - } - Column { - Text( - text = label, - color = AuroraInk.copy(alpha = if (active) 1f else 0.6f), - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - ) - if (supportingText != null) { - Text( - text = supportingText, - color = AuroraInk.copy(alpha = if (active) 0.68f else 0.42f), - fontSize = 13.sp, - ) - } - } - } - } -} - -@Composable -private fun ProviderIcon(provider: SignInProvider) { - val iconUrl = provider.iconUrl - var failed by remember(iconUrl) { mutableStateOf(iconUrl == null) } - if (failed) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.Login, - contentDescription = null, - tint = AuroraInk, - modifier = Modifier.size(22.dp), - ) - return - } - val context = LocalContext.current - AsyncImage( - model = remember(iconUrl) { - ImageRequest.Builder(context) - .data(iconUrl) - .apply { - // The app's image loader has no SVG decoder; plugin icons - // (the OIDC plugin's sso.svg) often are SVG. - if (iconUrl.orEmpty().substringBefore('?').endsWith(".svg", ignoreCase = true)) { - decoderFactory(SvgDecoder.Factory()) - } - } - .build() - }, - contentDescription = null, - onError = { failed = true }, - modifier = Modifier.size(22.dp), - ) -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignupScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignupScreen.kt index 8ced05541f..6b326761f2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignupScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/SignupScreen.kt @@ -1,50 +1,36 @@ package org.siloserver.silo.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.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.ConfirmationNumber +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person 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.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.unit.dp -import androidx.compose.ui.unit.sp -import org.siloserver.silo.android.ui.components.aurora.AuroraEyebrow -import org.siloserver.silo.android.ui.components.aurora.AuroraErrorLabel -import org.siloserver.silo.android.ui.components.aurora.AuroraGhostButton -import org.siloserver.silo.android.ui.components.aurora.AuroraPrimaryButton -import org.siloserver.silo.android.ui.components.aurora.AuroraScreen -import org.siloserver.silo.android.ui.components.aurora.AuroraScrim -import org.siloserver.silo.android.ui.components.aurora.AuroraTextField -import org.siloserver.silo.android.ui.components.aurora.AuroraVariant -import org.siloserver.silo.android.ui.components.aurora.auroraGlass import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.ui.components.marquee.MarqueeButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorHaptic +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorText +import org.siloserver.silo.android.ui.components.marquee.MarqueeFieldGroup +import org.siloserver.silo.android.ui.components.marquee.MarqueeHeadline +import org.siloserver.silo.android.ui.components.marquee.MarqueeIconButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeSeparator +import org.siloserver.silo.android.ui.components.marquee.MarqueeStage +import org.siloserver.silo.android.ui.components.marquee.MarqueeTextField +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle /** - * Account registration. Mirrors silo-apple iOS phone `SignupView` (Aurora): - * wordmark, "Create account" eyebrow + "Create your account", glass card with - * Username / Email / Password / Invite code, cream "Create account" button, and - * a "Back to sign in" ghost. (Android VM has no confirm-password field, so it - * is omitted rather than adding VM state.) + * Account registration with an invite code, laid out like the other + * first-run screens: Username, Email, Password and Invite code as one grouped + * block and "Create account". Back returns to sign-in. */ @Composable fun SignupScreen( @@ -53,7 +39,6 @@ fun SignupScreen( viewModel: SignupViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - var showPassword by remember { mutableStateOf(false) } LaunchedEffect(state.signupSuccess) { if (state.signupSuccess) { @@ -61,88 +46,58 @@ fun SignupScreen( onNavigateToProfiles() } } + MarqueeErrorHaptic(state.error) - AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { - SiloLogo() - - Spacer(Modifier.height(30.dp)) - AuroraEyebrow(text = "Create account", centered = true) - Spacer(Modifier.height(12.dp)) - Text( - text = "Create your account", - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFF3EFE9), - ) - Spacer(Modifier.height(24.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .auroraGlass(cornerRadius = 24.dp, emphasized = true) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - AuroraTextField( - label = "Username", + MarqueeStage( + scrim = MarqueeScrimStyle.BottomDeep, + frostStart = 0.40f, + onBack = onNavigateToLogin, + blockBack = state.isLoading, + topBar = { + MarqueeIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back to sign in", onNavigateToLogin, enabled = !state.isLoading) + }, + ) { + MarqueeHeadline(title = "Create your\naccount", lead = "You'll need an invite code from the server's admin.") + MarqueeFieldGroup(isError = state.error != null, modifier = Modifier.padding(top = 24.dp)) { + MarqueeTextField( value = state.username, onValueChange = viewModel::onUsernameChanged, - placeholder = "yourname", - imeAction = ImeAction.Next, + icon = Icons.Outlined.Person, + placeholder = "Username", ) - AuroraTextField( - label = "Email", + MarqueeSeparator() + MarqueeTextField( value = state.email, onValueChange = viewModel::onEmailChanged, - placeholder = "you@example.com", + icon = Icons.Outlined.Email, + placeholder = "Email", keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, ) - AuroraTextField( - label = "Password", + MarqueeSeparator() + MarqueeTextField( 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), - ) - } - }, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, ) - AuroraTextField( - label = "Invite code", + MarqueeSeparator() + MarqueeTextField( value = state.inviteCode, onValueChange = viewModel::onInviteCodeChanged, - placeholder = "ABCD-1234", + icon = Icons.Outlined.ConfirmationNumber, + placeholder = "Invite code", imeAction = ImeAction.Go, onImeAction = viewModel::onSignupClick, ) - - state.error?.let { AuroraErrorLabel(it) } - - AuroraPrimaryButton( - label = if (state.isLoading) "Creating…" else "Create account", - onClick = viewModel::onSignupClick, - isLoading = state.isLoading, - modifier = Modifier.fillMaxWidth(), - ) - - AuroraGhostButton( - label = "Back to sign in", - onClick = onNavigateToLogin, - fillMaxWidth = true, - ) } + state.error?.let { MarqueeErrorText(it, Modifier.padding(top = 10.dp)) } + MarqueeButton( + text = if (state.isLoading) "Creating…" else "Create account", + onClick = viewModel::onSignupClick, + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 14.dp), + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryDialog.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryDialog.kt deleted file mode 100644 index 9ace8c78e2..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryDialog.kt +++ /dev/null @@ -1,270 +0,0 @@ -package org.siloserver.silo.android.ui.screens.profiles - -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.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Backspace -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -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.sp -import androidx.compose.ui.window.Dialog -import org.siloserver.silo.android.ui.screens.auth.AuthColors -import org.siloserver.silo.common.ui.components.ProfileAvatarRef - -private const val PIN_LENGTH = 4 - -/** - * Modal dialog for entering a 4-digit profile PIN. - * - * @param profileName Name displayed in the title. - * @param isLoading Whether the PIN is being verified. - * @param error Error message to display (e.g. "Incorrect PIN"). - * @param onPinComplete Called with the full 4-digit PIN when the user finishes entering. - * @param onDismiss Called when the user cancels. - */ -@Composable -fun PINEntryDialog( - profileName: String, - profileAvatar: ProfileAvatarRef, - isLoading: Boolean, - error: String?, - onPinComplete: (String) -> Unit, - onDismiss: () -> Unit, -) { - // Deliberately NOT rememberSaveable: saved-instance state is serialized by - // the OS across configuration change and process death, which would put the - // raw PIN in system-managed storage well beyond the request that needs it. - // Losing four digits on rotation is the correct trade. - var pin by remember { mutableStateOf("") } - - // Clear pin on new error so the user can re-enter. - LaunchedEffect(error) { - if (error != null) { - pin = "" - } - } - - Dialog(onDismissRequest = onDismiss) { - // iOS phone presents PIN entry as a sheet over the black background. - Surface( - shape = RoundedCornerShape(24.dp), - color = AuthColors.Background, - ) { - // VStack(spacing: 32) header / dots / pad / cancel. - Column( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // Header: avatar (64) + label, spacing 10. - ProfileAvatar( - avatar = profileAvatar, - name = profileName, - size = 64.dp, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - Text( - text = "Enter PIN for $profileName", - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = AuthColors.OnSurface, - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - // PIN dots row (size 20, spacing 20). - PinDots(filledCount = pin.length) - - if (error != null) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = error, - fontSize = 13.sp, - color = AuthColors.Error, - textAlign = TextAlign.Center, - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Number pad - NumberPad( - enabled = !isLoading, - onDigit = { digit -> - if (pin.length < PIN_LENGTH) { - pin += digit - if (pin.length == PIN_LENGTH) { - onPinComplete(pin) - } - } - }, - onBackspace = { - if (pin.isNotEmpty()) { - pin = pin.dropLast(1) - } - }, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - // Cancel stays live during verification (the user must be able - // to back out of a slow round trip), and the ViewModel's - // generation guard makes that abandon the in-flight answer - // rather than commit it late. - TextButton(onClick = onDismiss) { - Text( - text = "Cancel", - color = AuthColors.Primary, - fontSize = 15.sp, - ) - } - } - } - } -} - -// iOS siloSurfaceVariant (#0E0F12) — used for empty PIN dots and pad keys. -private val SurfaceVariant = Color(0xFF0E0F12) - -@Composable -private fun PinDots(filledCount: Int) { - // iOS phone: dotSize 20, spacing 20; empty dots are a filled surfaceVariant. - Row( - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - repeat(PIN_LENGTH) { index -> - val filled = index < filledCount - Box( - modifier = Modifier - .size(20.dp) - .clip(CircleShape) - .background(if (filled) AuthColors.Primary else SurfaceVariant), - ) - } - } -} - -@Composable -private fun NumberPad( - enabled: Boolean, - onDigit: (String) -> Unit, - onBackspace: () -> Unit, -) { - val rows = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - listOf("", "0", "back"), - ) - - // iOS pad: 3-column grid, key size 64, spacing 16. - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - for (row in rows) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - for (key in row) { - when (key) { - "" -> { - // Empty slot keeps the "0" centered (iOS Color.clear). - Spacer(modifier = Modifier.size(64.dp)) - } - - "back" -> { - Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(SurfaceVariant) - .then( - if (enabled) { - Modifier.clickable(onClick = onBackspace) - } else { - Modifier - }, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Backspace, - contentDescription = "Backspace", - tint = if (enabled) AuthColors.OnSurface else AuthColors.OnSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } - - else -> { - NumberPadKey( - digit = key, - enabled = enabled, - onClick = { onDigit(key) }, - ) - } - } - } - } - } - } -} - -@Composable -private fun NumberPadKey( - digit: String, - enabled: Boolean, - onClick: () -> Unit, -) { - Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(SurfaceVariant) - .then( - if (enabled) Modifier.clickable(onClick = onClick) else Modifier, - ), - contentAlignment = Alignment.Center, - ) { - // iOS siloPIN: 32 bold monospaced. - Text( - text = digit, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = if (enabled) AuthColors.OnSurface else AuthColors.OnSurfaceVariant, - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt new file mode 100644 index 0000000000..120fd08b4b --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt @@ -0,0 +1,284 @@ +package org.siloserver.silo.android.ui.screens.profiles + +import android.view.HapticFeedbackConstants +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Backspace +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.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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.siloserver.silo.android.ui.components.marquee.MarqueeIconButton +import org.siloserver.silo.common.ui.marquee.marqueeShake +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeProfileAvatar +import org.siloserver.silo.common.ui.marquee.MarqueeScrim +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.model.profile.Profile + +private const val PIN_LENGTH = 4 + +/** + * Full-screen PIN prompt over the brand light: the person's avatar, four + * dots, and a keypad laid out like the system passcode screen. A wrong PIN + * keeps the prompt open, turns the dots red, shakes them and clears for + * another try (silo-apple `PINEntryView`). + * + * Back stays live during verification so a slow round trip can be left; the + * view model drops an answer for a prompt that's gone. + * + * @param errorCount bumped on each rejection so the dots shake and the entry clears. + */ +@Composable +fun PINEntryOverlay( + profile: Profile, + isVerifying: Boolean, + error: String?, + errorCount: Int, + onPinComplete: (String) -> Unit, + onDismiss: () -> Unit, +) { + // Deliberately NOT rememberSaveable: saved-instance state is serialized by + // the OS across configuration change and process death, which would put the + // raw PIN in system-managed storage well beyond the request that needs it. + // Losing four digits on rotation is the correct trade. + var pin by remember { mutableStateOf("") } + LaunchedEffect(errorCount) { if (errorCount > 0) pin = "" } + BackHandler(onBack = onDismiss) + + Box( + Modifier + .fillMaxSize() + // Swallow taps so the picker underneath can't be reached. + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, + ) { + MarqueeScrim(MarqueeScrimStyle.Ambient) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + BoxWithConstraints( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = Alignment.TopCenter, + ) { + // A short screen can't fit the full layout (about 690dp); the + // compact one keeps the keypad whole, and scrolling is the last resort. + val compact = maxHeight < 690.dp + val scrolls = maxHeight < 560.dp + Column( + Modifier + .widthIn(max = 440.dp) + .fillMaxSize() + .then(if (scrolls) Modifier.verticalScroll(rememberScrollState()) else Modifier), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, top = 6.dp)) { + MarqueeIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDismiss) + } + Spacer(if (scrolls) Modifier.height(8.dp) else Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clearAndSetSemantics { contentDescription = "Enter PIN for ${profile.name}" }, + ) { + MarqueeProfileAvatar(profile, size = if (compact) 64.dp else 88.dp, showsBadges = false, modifier = Modifier.padding(bottom = 8.dp)) + Text( + profile.name, + color = MarqueeColors.Ink, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + PinDots( + filled = pin.length, + error = error != null && pin.isEmpty(), + modifier = Modifier.padding(top = if (compact) 14.dp else 22.dp).marqueeShake(errorCount), + ) + + Box(Modifier.padding(top = if (compact) 10.dp else 14.dp).height(22.dp), contentAlignment = Alignment.Center) { + when { + error != null -> Text(error, color = MarqueeColors.Error, fontSize = 15.sp, textAlign = TextAlign.Center) + isVerifying -> CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + else -> Text("Enter your PIN", color = MarqueeColors.InkSecondary, fontSize = 15.sp) + } + } + + NumberPad( + enabled = !isVerifying, + rowSpacing = if (compact) 10.dp else 16.dp, + modifier = Modifier.padding(top = if (compact) 18.dp else 28.dp), + onDigit = { digit -> + if (pin.length < PIN_LENGTH && !isVerifying) { + pin += digit + if (pin.length == PIN_LENGTH) onPinComplete(pin) + } + }, + onBackspace = { if (pin.isNotEmpty() && !isVerifying) pin = pin.dropLast(1) }, + ) + + Spacer(if (scrolls) Modifier.height(8.dp) else Modifier.weight(1f)) + if (!compact) { + Text( + "Forgot your PIN? The account admin can reset it.", + color = MarqueeColors.InkTertiary, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 16.dp), + ) + } + } + } + } +} + +@Composable +private fun PinDots(filled: Int, error: Boolean, modifier: Modifier = Modifier) { + val color = if (error) MarqueeColors.Error else MarqueeColors.Ink + Row( + modifier.semantics { contentDescription = "$filled of $PIN_LENGTH digits entered" }, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + repeat(PIN_LENGTH) { index -> + val isFilled = index < filled || error + Box( + Modifier + .size(14.dp) + .clip(CircleShape) + .background(if (isFilled) color else Color.Transparent) + .border(1.5.dp, color.copy(alpha = if (isFilled) 1f else 0.7f), CircleShape), + ) + } + } +} + +private val Letters = mapOf(2 to "ABC", 3 to "DEF", 4 to "GHI", 5 to "JKL", 6 to "MNO", 7 to "PQRS", 8 to "TUV", 9 to "WXYZ") + +@Composable +private fun NumberPad( + enabled: Boolean, + rowSpacing: Dp, + onDigit: (String) -> Unit, + onBackspace: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(rowSpacing)) { + for (row in 0 until 3) { + Row(horizontalArrangement = Arrangement.spacedBy(26.dp)) { + for (column in 1..3) { + val digit = row * 3 + column + PadKey(enabled, "$digit", onClick = { onDigit("$digit") }) { + PadDigit("$digit", Letters[digit]) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(26.dp)) { + Spacer(Modifier.size(PadKeySize)) + PadKey(enabled, "0", onClick = { onDigit("0") }) { PadDigit("0", null) } + PadKey(enabled, "Delete", onClick = onBackspace, filled = false) { + Icon(Icons.AutoMirrored.Outlined.Backspace, contentDescription = null, tint = MarqueeColors.Ink, modifier = Modifier.size(24.dp)) + } + } + } +} + +private val PadKeySize = 76.dp + +@Composable +private fun PadDigit(digit: String, letters: String?) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(digit, color = MarqueeColors.Ink, fontSize = 34.sp, lineHeight = 36.sp) + if (letters != null) { + Text( + letters, + color = MarqueeColors.Ink.copy(alpha = 0.62f), + fontSize = 9.5.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.5.sp, + lineHeight = 10.sp, + ) + } + } +} + +@Composable +private fun PadKey( + enabled: Boolean, + label: String, + onClick: () -> Unit, + filled: Boolean = true, + content: @Composable () -> Unit, +) { + val view = LocalView.current + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + LaunchedEffect(pressed) { if (pressed) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) } + Box( + Modifier + .size(PadKeySize) + .clip(CircleShape) + .then( + if (filled) { + Modifier + .background(Color.White.copy(alpha = if (pressed) 0.3f else 0.13f)) + .border(1.dp, Color.White.copy(alpha = 0.10f), CircleShape) + } else { + Modifier + }, + ) + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onClick) + .alpha(if (enabled) 1f else 0.5f) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayout.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayout.kt new file mode 100644 index 0000000000..8ba93ede29 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayout.kt @@ -0,0 +1,66 @@ +package org.siloserver.silo.android.ui.screens.profiles + +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * How "Who's watching?" arranges its tiles (profiles plus "Add profile") in + * the room it has, in dp: two, three or four per row, whichever gives the + * largest avatars that still fit without scrolling. When even four per row + * would shrink avatars below a comfortable tap size, the picker keeps four + * per row at that size and scrolls. Port of silo-apple's `ProfilePickerLayout`, + * except that a scrolling grid stays at the minimum size: going back up to + * full size would make avatars grow as the household grows. + */ +data class ProfilePickerLayout( + val perRow: Int, + val avatarSize: Float, + /** Alone on the last row, "Add profile" is a utility, not a person, so it is drawn smaller. */ + val addSize: Float, + val rowSpacing: Float, + /** Every row uses the full rows' column width, so a short last row is centered rather than spread out. */ + val columnWidth: Float, + val scrolls: Boolean, +) { + private data class Option(val perRow: Int, val maxAvatar: Float, val gutter: Float, val rowSpacing: Float) + + companion object { + /** Name, optional "Last used" caption and their spacing under an avatar. */ + const val LABEL_HEIGHT = 48f + const val MINIMUM_AVATAR = 64f + + private val options = listOf( + Option(perRow = 2, maxAvatar = 140f, gutter = 36f, rowSpacing = 28f), + Option(perRow = 3, maxAvatar = 104f, gutter = 16f, rowSpacing = 26f), + Option(perRow = 4, maxAvatar = 76f, gutter = 10f, rowSpacing = 22f), + ) + + fun of(tileCount: Int, width: Float, height: Float): ProfilePickerLayout { + val count = tileCount.coerceAtLeast(1) + fun widthFit(option: Option) = min(option.maxAvatar, width / option.perRow - option.gutter) + fun fit(option: Option): Float { + val rows = ((count + option.perRow - 1) / option.perRow).toFloat() + val heightFit = (height - (rows - 1) * option.rowSpacing) / rows - LABEL_HEIGHT + return min(widthFit(option), heightFit) + } + + var best: Pair? = null + for (option in options) { + val size = fit(option) + if (size >= MINIMUM_AVATAR && size > (best?.second ?: 0f)) best = option to size + } + val chosen = best ?: options.last().let { it to min(widthFit(it), MINIMUM_AVATAR) } + val avatar = floor(chosen.second).coerceAtLeast(0f) + val perRow = chosen.first.perRow + return ProfilePickerLayout( + perRow = perRow, + avatarSize = avatar, + addSize = if (count > 1 && count % perRow == 1) (avatar * 0.6f).roundToInt().toFloat() else avatar, + rowSpacing = chosen.first.rowSpacing, + columnWidth = floor(width / perRow), + scrolls = best == null, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt index 2add825119..76d047d02a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt @@ -1,91 +1,144 @@ package org.siloserver.silo.android.ui.screens.profiles +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.ui.draw.drawBehind +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars 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.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll 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.automirrored.outlined.Logout import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Eco import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.ManageAccounts +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.material3.AlertDialog import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +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.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.siloserver.silo.android.ui.screens.auth.AuthColors -import org.siloserver.silo.android.ui.screens.auth.AuthErrorBanner -import org.siloserver.silo.android.ui.components.aurora.AuroraBackdrop -import org.siloserver.silo.android.ui.components.aurora.AuroraScrim -import org.siloserver.silo.android.ui.components.aurora.AuroraVariant -import org.siloserver.silo.common.ui.components.ThumbhashImage -import org.siloserver.silo.common.ui.components.avatarRef -import org.siloserver.silo.common.ui.components.isEmojiAvatar -import org.siloserver.silo.common.ui.components.profileAvatarDisplayText -import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage -import org.siloserver.silo.model.profile.Profile -import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.ui.components.marquee.MarqueeButton +import org.siloserver.silo.android.ui.components.marquee.MarqueeButtonKind +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorHaptic +import org.siloserver.silo.android.ui.components.marquee.MarqueeErrorText +import org.siloserver.silo.android.ui.components.marquee.MarqueeHeadline +import org.siloserver.silo.android.ui.components.marquee.MarqueeMetrics +import org.siloserver.silo.android.ui.components.marquee.MarqueeServerChip +import org.siloserver.silo.android.ui.components.marquee.MarqueeTopBarSpacer +import org.siloserver.silo.android.ui.components.marquee.MarqueeWordmark +import org.siloserver.silo.android.ui.components.marquee.marqueePressHaptic +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeProfileAvatar +import org.siloserver.silo.common.ui.marquee.MarqueeScene +import org.siloserver.silo.common.ui.marquee.MarqueeScrim +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.common.ui.marquee.ProfileTilePalette +import org.siloserver.silo.common.ui.marquee.ServerBrandingLoader +import org.siloserver.silo.common.ui.marquee.rememberSignInServer +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.network.ServerRegistry + +private val TopGap = 24.dp +private val GridGap = 36.dp +private val FooterGap = 32.dp +private val BottomGap = 20.dp /** - * Grid of profile avatars shown after login. + * "Who's watching?" (silo-apple `ProfileSelectionView`): round avatars sized + * to the household, centered between the title and small utilities (change + * server, sign out, and for admins, manage profiles). Pressing a profile + * tints the backdrop with that person's color; a PIN prompt opens full + * screen over the same light. * * @param onNavigateToHome Called after a profile is selected. - * @param onNavigateToCreateProfile Called when the "Add Profile" card is tapped. - * @param onNavigateToEditProfile Called when the edit icon is tapped in manage mode. + * @param onNavigateToCreateProfile Called when "Add profile" is tapped. + * @param onNavigateToEditProfile Called when a profile is tapped in manage mode. + * @param onChangeServer "Change server" from the footer or the server chip. + * @param onSignOut "Sign out" from the footer. */ @Composable fun ProfileSelectionScreen( onNavigateToHome: () -> Unit, onNavigateToCreateProfile: () -> Unit, onNavigateToEditProfile: (profileId: String) -> Unit, + onChangeServer: () -> Unit, + onSignOut: () -> Unit, viewModel: ProfileSelectionViewModel = koinViewModel(), + brandingLoader: ServerBrandingLoader = koinInject(), + serverRegistry: ServerRegistry = koinInject(), ) { val state by viewModel.uiState.collectAsState() + val activeEntry by serverRegistry.activeEntry.collectAsState() + val server = rememberSignInServer( + serverUrl = activeEntry?.url.orEmpty(), + savedName = activeEntry?.fetchedName, + loader = brandingLoader, + ) + var pressedProfileId by remember { mutableStateOf(null) } // Reload on resume so a profile created/edited on a pushed screen is - // reflected when we return (the VM otherwise only loads in init) — the - // TvProfileSelectionScreen pattern. + // reflected when we return (the VM otherwise only loads in init). val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -95,6 +148,14 @@ fun ProfileSelectionScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + LaunchedEffect(Unit) { MarqueeScene.focus = MarqueeScene.Focus.Profiles } + // The profile under a finger, then the one asking for a PIN, tints the light. + val tintedProfileId = state.pinDialogProfile?.id ?: pressedProfileId + LaunchedEffect(tintedProfileId) { + MarqueeScene.personalTint = tintedProfileId?.let(ProfileTilePalette::tint) + } + DisposableEffect(Unit) { onDispose { MarqueeScene.personalTint = null } } + // Navigate after a profile is selected. LaunchedEffect(state.selectedProfileId) { if (state.selectedProfileId != null) { @@ -102,6 +163,7 @@ fun ProfileSelectionScreen( onNavigateToHome() } } + MarqueeErrorHaptic(state.pinErrorCount) state.deleteDialogProfile?.let { profile -> val deletingActive = profile.id == state.activeProfileId @@ -131,365 +193,352 @@ fun ProfileSelectionScreen( ) } - // PIN entry dialog - state.pinDialogProfile?.let { profile -> - PINEntryDialog( - profileName = profile.name, - profileAvatar = profile.avatarRef(), - isLoading = state.pinIsVerifying, - error = state.pinError, - onPinComplete = viewModel::onPinEntered, - onDismiss = viewModel::dismissPinDialog, - ) - } + val pinOpen = state.pinDialogProfile != null + val pickerAlpha by animateFloatAsState(if (pinOpen || state.openingOnlyProfile) 0f else 1f, label = "pickerAlpha") - Box(modifier = Modifier.fillMaxSize()) { - AuroraBackdrop(variant = AuroraVariant.Profile, scrim = AuroraScrim.Soft) - // Vertical scroll on the whole page, mirroring iOS phone's - // `ScrollView { LazyVGrid(adaptive(min: 140, max: 180)) }` pattern at - // `ProfileSelectionView.swift:119`. The previous implementation - // capped the grid at a fixed 220.dp, which scrolled internally as - // soon as profiles spilled to a third row. Now the grid is a - // non-lazy FlowRow that grows naturally; only the page scrolls if - // content exceeds the viewport. + Box(Modifier.fillMaxSize()) { + MarqueeScrim(MarqueeScrimStyle.Ambient) Column( - modifier = Modifier + Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .statusBarsPadding() - .padding(top = 24.dp, bottom = 40.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .alpha(pickerAlpha) + // Behind the PIN prompt the picker is gone for TalkBack too. + .then(if (pinOpen) Modifier.clearAndSetSemantics {} else Modifier) + .windowInsetsPadding(WindowInsets.statusBars), ) { - // Header: title + subtitle, spacing 6 (iOS titleBlock). - Text( - text = "Who's watching?", - fontSize = 32.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.5).sp, - color = AuthColors.OnBackground, - ) - - Spacer(modifier = Modifier.height(6.dp)) - - Text( - text = "Select your profile", - fontSize = 15.sp, - fontWeight = FontWeight.Normal, - color = AuthColors.OnSurfaceVariant, - ) - - state.error?.let { error -> - Spacer(modifier = Modifier.height(16.dp)) - AuthErrorBanner(message = error) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(start = 24.dp, end = 24.dp, top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeWordmark() + MarqueeTopBarSpacer() + MarqueeServerChip( + name = server.name, + hostLabel = server.hostLabel, + markUrl = server.branding?.markUrl, + actionLabel = "Change server", + onAction = onChangeServer, + enabled = !pinOpen && !state.openingOnlyProfile, + ) } + Picker( + state = state, + enabled = !pinOpen && !state.openingOnlyProfile, + onPressChange = { profile, pressed -> + pressedProfileId = if (pressed) profile.id else pressedProfileId.takeIf { it != profile.id } + }, + onProfileTap = { profile -> + if (state.isManageMode) onNavigateToEditProfile(profile.id) else viewModel.onProfileTapped(profile) + }, + onProfileDelete = viewModel::requestDeleteProfile, + onAddProfile = onNavigateToCreateProfile, + onChangeServer = onChangeServer, + onSignOut = onSignOut, + onToggleManage = viewModel::toggleManageMode, + modifier = Modifier.weight(1f).windowInsetsPadding(WindowInsets.navigationBars), + ) + } - if (state.isLoading) { - Spacer(modifier = Modifier.height(36.dp)) - CircularProgressIndicator(color = AuthColors.Primary) - } else { - // VStack(spacing: 36) between header and tile grid. - Spacer(modifier = Modifier.height(36.dp)) - - ProfileFlow( - profiles = state.profiles, - canAddProfile = state.canManageProfiles, - isManageMode = state.isManageMode, - onProfileTap = { viewModel.onProfileTapped(it) }, - onProfileEdit = { onNavigateToEditProfile(it.id) }, - onProfileDelete = { viewModel.requestDeleteProfile(it) }, - onAddProfile = onNavigateToCreateProfile, + AnimatedVisibility(visible = pinOpen, enter = fadeIn(), exit = fadeOut()) { + // Keep the last profile while the prompt fades out. + var shown by remember { mutableStateOf(state.pinDialogProfile) } + state.pinDialogProfile?.let { shown = it } + shown?.let { profile -> + PINEntryOverlay( + profile = profile, + isVerifying = state.pinIsVerifying, + error = state.pinError, + errorCount = state.pinErrorCount, + onPinComplete = viewModel::onPinEntered, + onDismiss = viewModel::dismissPinDialog, ) - - if (state.canManageProfiles) { - Spacer(modifier = Modifier.height(24.dp)) - - TextButton(onClick = viewModel::toggleManageMode) { - Text( - text = if (state.isManageMode) "Done" else "Manage Profiles", - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = AuthColors.OnBackground, - ) - } - } } } } } -/** - * Non-lazy wrapping grid of profile tiles + the AddProfile tile. Uses - * [FlowRow] so the row count grows naturally with the profile count — - * the parent scroll handles overflow only when content exceeds the - * viewport, matching iOS phone's `ScrollView { LazyVGrid }` pattern. - */ -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun ProfileFlow( - profiles: List, - canAddProfile: Boolean, - isManageMode: Boolean, +private fun Picker( + state: ProfileSelectionUiState, + enabled: Boolean, + onPressChange: (Profile, Boolean) -> Unit, onProfileTap: (Profile) -> Unit, - onProfileEdit: (Profile) -> Unit, onProfileDelete: (Profile) -> Unit, onAddProfile: () -> Unit, + onChangeServer: () -> Unit, + onSignOut: () -> Unit, + onToggleManage: () -> Unit, + modifier: Modifier = Modifier, ) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - // iOS adaptive grid: tileSpacing 16, rowSpacing 28. - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(28.dp), - ) { - profiles.forEach { profile -> - ProfileCard( - profile = profile, - isManageMode = isManageMode, - onTap = { onProfileTap(profile) }, - onEdit = { onProfileEdit(profile) }, - onDelete = { onProfileDelete(profile) }, - ) - } - if (canAddProfile) { - AddProfileCard(onClick = onAddProfile) + val density = LocalDensity.current + var headerHeight by remember { mutableStateOf(0) } + var footerHeight by remember { mutableStateOf(0) } + BoxWithConstraints(modifier.fillMaxWidth()) { + val viewportHeight = maxHeight + val tileCount = state.profiles.size + if (state.canManageProfiles) 1 else 0 + // The room between the title and the footer decides how many avatars + // share a row and how large they are. Nothing draws until it's + // measured, so tiles never render at a placeholder size and jump. + val layout = if (headerHeight > 0 && footerHeight > 0) { + with(density) { + ProfilePickerLayout.of( + tileCount = tileCount, + width = minOf(maxWidth.value, 440f) - 48f, + height = viewportHeight.value - headerHeight.toDp().value - footerHeight.toDp().value - + (TopGap + GridGap + FooterGap + BottomGap).value, + ) + } + } else { + null } - } -} - -// iOS phone ProfileTile sizing. -private val TileSize = 140.dp -private val TileCornerRadius = 18.dp -private const val TileEmojiSize = 72f -private const val TileInitialSize = 56f -private val TileNameSize = 17.sp - -@Composable -private fun ProfileCard( - profile: Profile, - isManageMode: Boolean, - onTap: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, -) { - val tint = ProfileTilePalette.tint(profile.id) - - Box(contentAlignment = Alignment.TopEnd) { - // VStack(spacing: 20) tile + name. Column( - modifier = Modifier - .clickable(onClick = if (isManageMode) onEdit else onTap), + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, ) { - ProfileTileBody(profile = profile, tint = tint) - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = profile.name, - fontSize = TileNameSize, - fontWeight = FontWeight.Medium, - color = Color.White.copy(alpha = 0.72f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } - - // Manage mode overlays (Android management affordance). - if (isManageMode) { - IconButton( - onClick = onEdit, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(Color.White), + Column( + Modifier + .heightIn(min = viewportHeight) + .widthIn(max = 440.dp) + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = BottomGap), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = "Edit profile", - tint = Color.Black, - modifier = Modifier.size(16.dp), - ) - } - - // The server never deletes the primary profile (409 - // primary_profile_protected), so don't offer it. - if (!profile.isPrimary) { - IconButton( - onClick = onDelete, - modifier = Modifier - .align(Alignment.TopStart) - .size(28.dp) - .clip(CircleShape) - .background(AuthColors.Error), + Spacer(Modifier.height(TopGap)) + Column( + Modifier.onSizeChanged { headerHeight = it.height }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MarqueeHeadline("Who's watching?", centered = true) + // Keeps its height while the account loads so the picker doesn't shift. + Text( + state.accountName?.let { "Signed in as $it" } ?: " ", + color = MarqueeColors.InkSecondary, + fontSize = MarqueeMetrics.LeadFont, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + state.error?.let { MarqueeErrorText(it) } + } + Spacer(Modifier.height(GridGap)) + if (state.isLoading && state.profiles.isEmpty()) { + CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(28.dp)) + } else { + TileRows( + state = state, + layout = layout, + enabled = enabled, + onPressChange = onPressChange, + onProfileTap = onProfileTap, + onProfileDelete = onProfileDelete, + onAddProfile = onAddProfile, + ) + } + Spacer(Modifier.height(FooterGap)) + Spacer(Modifier.weight(1f)) + // Changing server and signing out are utilities, kept small and below the people. + Row( + Modifier.onSizeChanged { footerHeight = it.height }, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Delete profile", - tint = Color.White, - modifier = Modifier.size(16.dp), + MarqueeButton( + text = "Change server", + onClick = onChangeServer, + kind = MarqueeButtonKind.Plain, + fullWidth = false, + compact = true, + icon = Icons.Outlined.Dns, + enabled = enabled, + ) + MarqueeButton( + text = "Sign out", + onClick = onSignOut, + kind = MarqueeButtonKind.Plain, + fullWidth = false, + compact = true, + icon = Icons.AutoMirrored.Outlined.Logout, + enabled = enabled, ) + if (state.canManageProfiles) { + MarqueeButton( + text = if (state.isManageMode) "Done" else "Manage", + onClick = onToggleManage, + kind = MarqueeButtonKind.Plain, + fullWidth = false, + compact = true, + icon = if (state.isManageMode) null else Icons.Outlined.ManageAccounts, + enabled = enabled, + ) + } } } } } } -/** - * Square tinted profile tile body matching iOS phone `ProfileTile`: - * tinted fill, top-edge highlight stroke, avatar (image fills the tile, - * emoji/initials sit on the tint), and lock/child badges top-right. - */ -@Composable -private fun ProfileTileBody(profile: Profile, tint: Color) { - val shape = RoundedCornerShape(TileCornerRadius) - val avatar = profile.avatarRef() - val avatarImage = rememberProfileAvatarImage(avatar) +private sealed interface PickerItem { + data class Person(val profile: Profile) : PickerItem + data object Add : PickerItem +} - Box( - modifier = Modifier - .size(TileSize) - .clip(shape) - .background(tint) - // Top-edge highlight so the tile reads as a physical surface. - .border( - width = 1.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.22f), - Color.White.copy(alpha = 0.04f), - ), - ), - shape = shape, - ), - contentAlignment = Alignment.Center, +/** Rows are centered, so a short last row sits in the middle instead of hanging off the left. */ +@Composable +private fun TileRows( + state: ProfileSelectionUiState, + layout: ProfilePickerLayout?, + enabled: Boolean, + onPressChange: (Profile, Boolean) -> Unit, + onProfileTap: (Profile) -> Unit, + onProfileDelete: (Profile) -> Unit, + onAddProfile: () -> Unit, +) { + val items = state.profiles.map(PickerItem::Person) + if (state.canManageProfiles) listOf(PickerItem.Add) else emptyList() + val perRow = layout?.perRow ?: 3 + Column( + Modifier.fillMaxWidth().alpha(if (layout == null) 0f else 1f), + verticalArrangement = Arrangement.spacedBy((layout?.rowSpacing ?: 0f).dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - if (avatarImage != null) { - ThumbhashImage( - url = avatarImage.url, - thumbhash = null, - contentDescription = "${profile.name} avatar", - modifier = Modifier - .fillMaxSize() - .clip(shape), - contentScale = ContentScale.Crop, - transparent = true, - cacheKey = avatarImage.cacheKey, - onError = avatarImage.onLoadFailed, - ) - } else if (isEmojiAvatar(avatar)) { - Text( - text = avatar.avatar.orEmpty().trim(), - fontSize = TileEmojiSize.sp, - ) - } else { - Text( - text = profileAvatarDisplayText(avatar = avatar, name = profile.name), - fontSize = TileInitialSize.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.92f), - ) - } - - // Primary / child / lock badges ride the top-right corner. - if (profile.isPrimary || profile.hasPin || profile.isChild) { - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (profile.isPrimary) { - TileBadge(Icons.Filled.WorkspacePremium, contentDescription = "Primary profile") - } - if (profile.isChild) { - TileBadge(Icons.Filled.Eco) - } - if (profile.hasPin) { - TileBadge(Icons.Filled.Lock) + items.chunked(perRow).forEach { row -> + // Top-aligned so a "Last used" caption doesn't lift its avatar above the rest. + Row(verticalAlignment = Alignment.Top) { + row.forEach { item -> + Box(Modifier.width((layout?.columnWidth ?: 0f).dp), contentAlignment = Alignment.TopCenter) { + when (item) { + is PickerItem.Person -> ProfileTile( + profile = item.profile, + size = (layout?.avatarSize ?: 0f).dp, + isLastUsed = item.profile.id == state.activeProfileId, + isManageMode = state.isManageMode, + enabled = enabled, + onPressChange = { onPressChange(item.profile, it) }, + onTap = { onProfileTap(item.profile) }, + onDelete = { onProfileDelete(item.profile) }, + ) + PickerItem.Add -> AddProfileTile(size = (layout?.addSize ?: 0f).dp, enabled = enabled, onClick = onAddProfile) + } + } } } } } } +/** Names grow more slowly than avatars so large avatars keep short labels. */ +private fun nameSize(avatar: Float) = 15f * (1f + (avatar / 92f - 1f) * 0.35f) + @Composable -private fun TileBadge( - icon: androidx.compose.ui.graphics.vector.ImageVector, - contentDescription: String? = null, +private fun ProfileTile( + profile: Profile, + size: androidx.compose.ui.unit.Dp, + isLastUsed: Boolean, + isManageMode: Boolean, + enabled: Boolean, + onPressChange: (Boolean) -> Unit, + onTap: () -> Unit, + onDelete: () -> Unit, ) { - Box( - modifier = Modifier - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.35f)) - .padding(8.dp), - contentAlignment = Alignment.Center, + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + LaunchedEffect(pressed) { onPressChange(pressed) } + Column( + Modifier + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onTap) + .marqueePressHaptic(interaction) + .alpha(if (pressed) 0.8f else 1f) + .semantics(mergeDescendants = true) { + contentDescription = profile.name + // The tile merges its children, so the delete badge is + // reached as an action rather than its own node. + if (isManageMode && !profile.isPrimary) { + customActions = listOf(CustomAccessibilityAction("Delete ${profile.name}") { onDelete(); true }) + } + stateDescription = listOfNotNull( + "Last used".takeIf { isLastUsed }, + "PIN protected".takeIf { profile.hasPin }, + "Kids profile".takeIf { profile.isChild }, + "Edit".takeIf { isManageMode }, + ).joinToString(", ") + }, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = Color.White, - modifier = Modifier.size(16.dp), + Box { + MarqueeProfileAvatar(profile, size = size, modifier = Modifier.alpha(if (isManageMode) 0.6f else 1f)) + if (isManageMode) { + Box(Modifier.size(size), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Edit, contentDescription = null, tint = Color.White, modifier = Modifier.size(size * 0.28f)) + } + // The server never deletes the primary profile (409 + // primary_profile_protected), so don't offer it. + if (!profile.isPrimary) { + Box( + Modifier + .align(Alignment.TopStart) + .size(28.dp) + .clip(CircleShape) + .background(MarqueeColors.Error) + .clickable(role = Role.Button, onClick = onDelete) + .semantics { contentDescription = "Delete ${profile.name}" }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + } + } + } + } + Text( + profile.name, + color = MarqueeColors.Ink, + fontSize = nameSize(size.value).sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp, start = 4.dp, end = 4.dp), ) + if (isLastUsed) { + Text("Last used", color = MarqueeColors.InkTertiary, fontSize = 12.sp, maxLines = 1, modifier = Modifier.padding(top = 2.dp)) + } } } @Composable -private fun AddProfileCard(onClick: () -> Unit) { - val shape = RoundedCornerShape(TileCornerRadius) +private fun AddProfileTile(size: androidx.compose.ui.unit.Dp, enabled: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } Column( - modifier = Modifier.clickable(onClick = onClick), + Modifier + .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onClick) + .marqueePressHaptic(interaction) + .semantics(mergeDescendants = true) { contentDescription = "Add profile" }, horizontalAlignment = Alignment.CenterHorizontally, ) { Box( - modifier = Modifier - .size(TileSize) - .clip(shape) - .background(Color.White.copy(alpha = 0.06f)) - .border(width = 2.dp, color = Color.White.copy(alpha = 0.28f), shape = shape), + Modifier + .size(size) + .drawBehind { + val stroke = 2.dp.toPx() + drawCircle( + color = Color.White.copy(alpha = 0.25f), + radius = this.size.minDimension / 2f - stroke / 2f, + center = Offset(this.size.width / 2f, this.size.height / 2f), + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 5.dp.toPx()))), + ) + }, contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Add profile", - tint = Color.White.copy(alpha = 0.6f), - modifier = Modifier.size(84.dp), - ) + Icon(Icons.Filled.Add, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(size * 0.3f)) } - - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = "Add Profile", - fontSize = TileNameSize, - fontWeight = FontWeight.Medium, - color = Color.White.copy(alpha = 0.55f), - textAlign = TextAlign.Center, + "Add profile", + color = MarqueeColors.InkSecondary, + fontSize = nameSize(size.value).sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 10.dp), ) } } - -/** - * Warm tile palette mirroring iOS `ProfileTilePalette`. Tint is derived - * from the profile id via DJB2 so a profile always gets the same color. - */ -private object ProfileTilePalette { - private val colors = listOf( - Color(red = 0.850f, green = 0.460f, blue = 0.380f), // coral - Color(red = 0.400f, green = 0.560f, blue = 0.720f), // slate blue - Color(red = 0.690f, green = 0.540f, blue = 0.400f), // warm tan - Color(red = 0.460f, green = 0.620f, blue = 0.520f), // sage - Color(red = 0.780f, green = 0.480f, blue = 0.520f), // dusty rose - Color(red = 0.560f, green = 0.480f, blue = 0.720f), // lavender - Color(red = 0.360f, green = 0.580f, blue = 0.620f), // teal - Color(red = 0.780f, green = 0.640f, blue = 0.380f), // amber - ) - - fun tint(profileId: String): Color { - var h = 5381UL - for (byte in profileId.encodeToByteArray()) { - h = ((h shl 5) + h) + byte.toUByte().toULong() - } - return colors[(h % colors.size.toULong()).toInt()] - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt index 6aa242d0d6..3d8fc9a4bb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.siloserver.silo.common.ui.marquee.SignInHandoff data class ProfileSelectionUiState( val profiles: List = emptyList(), @@ -26,6 +27,12 @@ data class ProfileSelectionUiState( val pinDialogProfile: Profile? = null, val pinIsVerifying: Boolean = false, val pinError: String? = null, + /** Bumped on each rejected PIN so the dots shake and the entry clears. */ + val pinErrorCount: Int = 0, + /** The signed-in account's username, shown under the title; null until read. */ + val accountName: String? = null, + /** A one-profile household is being opened straight after sign-in; the picker stays hidden. */ + val openingOnlyProfile: Boolean = false, /** Set after a profile is successfully selected. */ val selectedProfileId: String? = null, /** Non-null when a delete was requested and the confirm dialog should show. */ @@ -38,6 +45,7 @@ data class ProfileSelectionUiState( class ProfileSelectionViewModel( private val profileRepository: ProfileRepository, private val authRepository: AuthRepository? = null, + private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileSelectionUiState()) @@ -49,6 +57,12 @@ class ProfileSelectionViewModel( /** Monotonic generation for profile-list loads; see [loadProfiles]. */ private var loadAttempt: Int = 0 + /** + * Read once, on the first load: a sign-in that just finished asked to + * skip a one-person picker (see [SignInHandoff]). + */ + private var skipsSingleProfile: Boolean? = null + init { loadProfiles() reloadWhenUpdateRequiredLifts() @@ -97,7 +111,9 @@ class ProfileSelectionViewModel( val scope = profileRepository.captureIdentityScope() val activeId = profileRepository.getActiveProfileId() - val isAdmin = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data?.role?.equals("admin", ignoreCase = true) == true + val skipSingle = skipsSingleProfile ?: consumeSkipsSingleProfile().also { skipsSingleProfile = it } + val user = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data + val isAdmin = user?.role?.equals("admin", ignoreCase = true) == true val result = profileRepository.listProfiles() // Two separate reasons to drop this response: a newer load // superseded it, or the identity it was fetched under is gone. @@ -130,9 +146,15 @@ class ProfileSelectionViewModel( // accepted as belonging to the new one. That is worse than // the unguarded commit this was meant to fix. gridScope = scope + // A household with one profile and no PIN doesn't need a + // picker after signing in: open it, once. + val only = result.data.singleOrNull()?.takeIf { skipSingle && !it.hasPin } + skipsSingleProfile = false _uiState.update { it.copy( isLoading = false, + openingOnlyProfile = only != null, + accountName = user?.username ?: it.accountName, profiles = result.data, activeProfileId = activeId, canManageProfiles = isAdmin, @@ -140,6 +162,7 @@ class ProfileSelectionViewModel( deleteDialogProfile = if (isAdmin) it.deleteDialogProfile else null, ) } + if (only != null) onProfileTapped(only) } is ApiResult.Error -> { @@ -231,7 +254,7 @@ class ProfileSelectionViewModel( selectProfile(profile.id, token, scope) } else { _uiState.update { - it.copy(pinIsVerifying = false, pinError = "Incorrect PIN") + it.copy(pinIsVerifying = false, pinError = "Wrong PIN. Try again.", pinErrorCount = it.pinErrorCount + 1) } } } @@ -241,13 +264,14 @@ class ProfileSelectionViewModel( it.copy( pinIsVerifying = false, pinError = result.message.ifBlank { "Verification failed" }, + pinErrorCount = it.pinErrorCount + 1, ) } } is ApiResult.NetworkError -> { _uiState.update { - it.copy(pinIsVerifying = false, pinError = "Network error") + it.copy(pinIsVerifying = false, pinError = "Network error", pinErrorCount = it.pinErrorCount + 1) } } } @@ -317,6 +341,7 @@ class ProfileSelectionViewModel( ) { viewModelScope.launch { val result = profileRepository.selectProfile(profileId, profileToken, expectedScope) + if (result != ProfileCommitResult.Committed) _uiState.update { it.copy(openingOnlyProfile = false) } if (result == ProfileCommitResult.ScopeChanged) { // Someone else owns the identity now. Drop everything bound to // the identity we no longer have — a retained grid would let diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayoutTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayoutTest.kt new file mode 100644 index 0000000000..ab1634f88f --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfilePickerLayoutTest.kt @@ -0,0 +1,64 @@ +package org.siloserver.silo.android.ui.screens.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * "Who's watching?" must lay out 1–20 profiles (plus "Add profile") on every + * phone size: avatars that fit their columns and the screen, and that only + * get smaller as the household grows. + */ +class ProfilePickerLayoutTest { + /** Grid room (width, height between title and footer) on representative screens, in dp. */ + private val screens = listOf( + Triple("small phone", 312f, 340f), + Triple("Pixel 9", 364f, 520f), + Triple("large phone", 392f, 600f), + Triple("tablet", 440f, 900f), + ) + + @Test + fun everyHouseholdSizeFitsEveryScreen() { + for ((name, width, height) in screens) { + var previous: ProfilePickerLayout? = null + for (profiles in 1..20) { + val tiles = profiles + 1 + val layout = ProfilePickerLayout.of(tiles, width, height) + val context = "$name, $profiles profiles" + + assertTrue(layout.avatarSize >= ProfilePickerLayout.MINIMUM_AVATAR, context) + assertTrue(layout.perRow * layout.columnWidth <= width, context) + assertTrue(layout.avatarSize < layout.columnWidth, "$context: avatar wider than its column") + assertTrue(layout.addSize <= layout.avatarSize, context) + + if (!layout.scrolls) { + val rows = ((tiles + layout.perRow - 1) / layout.perRow).toFloat() + val used = rows * (layout.avatarSize + ProfilePickerLayout.LABEL_HEIGHT) + (rows - 1) * layout.rowSpacing + assertTrue(used <= height, "$context: rows overflow without scrolling") + } + previous?.let { + assertTrue(layout.avatarSize <= it.avatarSize, "$context: avatars grew") + assertTrue(layout.perRow >= it.perRow, "$context: fewer per row") + assertTrue(layout.scrolls || !it.scrolls, "$context: stopped scrolling") + } + previous = layout + } + } + } + + @Test + fun smallHouseholdGetsTwoLargeAvatarsPerRow() { + val layout = ProfilePickerLayout.of(tileCount = 3, width = 364f, height = 520f) + assertEquals(2, layout.perRow) + assertEquals(140f, layout.avatarSize) + assertTrue(layout.addSize < layout.avatarSize, "a lone Add profile tile is drawn smaller") + } + + @Test + fun largeHouseholdScrollsFourPerRow() { + val layout = ProfilePickerLayout.of(tileCount = 21, width = 364f, height = 520f) + assertEquals(4, layout.perRow) + assertTrue(layout.scrolls) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt index 05cc08e72e..de800b985f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt @@ -7,6 +7,7 @@ import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import org.siloserver.silo.common.di.playerInfraModule +import org.siloserver.silo.common.ui.marquee.marqueeModule import org.siloserver.silo.common.di.playerModule import org.siloserver.silo.common.diagnostics.DiagnosticsCoordinator import org.siloserver.silo.common.diagnostics.DiagnosticsStartup @@ -35,7 +36,7 @@ class SiloTvApplication : Application(), Configuration.Provider, SingletonImageL DiagnosticsStartup.installCrashCapture(this) val koinApp = startKoin { androidContext(this@SiloTvApplication) - modules(sharedModules() + playerModule + playerInfraModule + androidTvModule + diagnosticsModule) + modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + androidTvModule + diagnosticsModule) } DiagnosticsStartup.startCoordinator { koinApp.koin.get() } koinApp.koin.get().start( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index c04ea09d14..3c621c986e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -368,7 +368,10 @@ val androidTvModule = module { } // Auth ViewModels - viewModel { TvServerSetupViewModel(get(), get()) } + viewModel { + val tokens = get() + TvServerSetupViewModel(get(), get(), hasSession = { !tokens.getAccessToken().isNullOrBlank() }) + } viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSetupViewModel(get()) } viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSignupViewModel(get()) } viewModel { params -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt deleted file mode 100644 index d3dc335d7f..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAuroraChrome.kt +++ /dev/null @@ -1,393 +0,0 @@ -package org.siloserver.silo.tv.ui.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -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.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -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.text.font.FontFamily -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.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text - -/** - * The Aurora first-run chrome — eyebrow, glass panel, cream primary button and - * ghost button — ported from silo-apple's `AuroraStyle.swift`. A warm, - * cinematic skin (gold accent, plum-night) layered over the onboarding screens. - */ - -val AuroraInk = Color(0xFFF3EFE9) -val AuroraAccent = Color(0xFFF3D3A0) -private val AuroraJourneyAccent = Color(0xFFFF7900) -private val AuroraGlassTint = Color(0xFF171019) -private val AuroraCreamTop = Color(0xFFFDF7EC) -private val AuroraCreamBottom = Color(0xFFF1E3CD) -private val AuroraCreamInk = Color(0xFF20160A) -private val AuroraNightBottom = Color(0xFF070509) - -/** Compact Server → Account → Profile progress used across TV onboarding. */ -@Composable -fun AuroraJourneyProgress( - currentStep: Int, - modifier: Modifier = Modifier, -) { - val labels = listOf("SERVER", "ACCOUNT", "PROFILE") - Column(modifier = modifier) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - labels.forEachIndexed { index, _ -> - val step = index + 1 - val reached = step <= currentStep - Box( - modifier = Modifier - .size(22.dp) - .clip(RoundedCornerShape(11.dp)) - .background(if (reached) AuroraJourneyAccent else Color.White.copy(alpha = 0.10f)) - .border( - 1.dp, - if (reached) AuroraJourneyAccent else Color.White.copy(alpha = 0.20f), - RoundedCornerShape(11.dp), - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = if (step < currentStep) "✓" else step.toString(), - color = if (reached) Color.Black else Color.White.copy(alpha = 0.48f), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - ) - } - if (index < labels.lastIndex) { - Box( - modifier = Modifier - .weight(1f) - .padding(horizontal = 7.dp) - .height(1.dp) - .background( - if (step < currentStep) AuroraJourneyAccent - else Color.White.copy(alpha = 0.16f), - ), - ) - } - } - } - Spacer(Modifier.height(5.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - labels.forEachIndexed { index, label -> - Text( - text = label, - color = if (index + 1 == currentStep) Color.White else Color.White.copy(alpha = 0.42f), - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = if (index + 1 == currentStep) FontWeight.Bold else FontWeight.Medium, - letterSpacing = 1.2.sp, - ), - textAlign = when (index) { - 0 -> TextAlign.Start - labels.lastIndex -> TextAlign.End - else -> TextAlign.Center - }, - modifier = Modifier.weight(1f), - ) - } - } - } -} - -/** - * Gold-hairline + mono-caps step label, e.g. "STEP 01 — CONNECT". - * - * The hairline is mirrored on both sides so the label sits on the row's centre - * line. Every eyebrow in the auth flow is laid out in a `CenterHorizontally` - * column under a centered title; with a leading rule only, the row centres but - * the *text* does not, and the eyebrow visibly hangs right of the title beneath - * it. Keep the pair symmetric if you restyle this. - */ -@Composable -fun AuroraEyebrow(text: String, modifier: Modifier = Modifier) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - AuroraEyebrowRule(fadesTowardText = true) - Spacer(Modifier.width(16.dp)) - Text( - text = text.uppercase(), - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - letterSpacing = 3.5.sp, - color = AuroraAccent, - ) - Spacer(Modifier.width(16.dp)) - AuroraEyebrowRule(fadesTowardText = false) - } -} - -/** - * One 46dp hairline of the eyebrow. [fadesTowardText] runs the gradient solid - * at the outer edge and transparent at the inner one, so a mirrored pair reads - * as a single rule interrupted by the label. - */ -@Composable -private fun AuroraEyebrowRule(fadesTowardText: Boolean) { - val stops = if (fadesTowardText) { - listOf(AuroraAccent, AuroraAccent.copy(alpha = 0f)) - } else { - listOf(AuroraAccent.copy(alpha = 0f), AuroraAccent) - } - Box( - modifier = Modifier - .width(46.dp) - .height(1.dp) - .background(Brush.horizontalGradient(stops)), - ) -} - -/** - * Liquid-glass panel chrome (translucent plum tint + gradient hairline + top - * 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 { - val shape = RoundedCornerShape(cornerRadius) - return this - .shadow(elevation = 60.dp, shape = shape, clip = false) - .clip(shape) - .background(AuroraGlassTint.copy(alpha = 0.62f)) - .background( - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.05f), Color.Transparent), - ), - ) - .border( - width = 1.dp, - brush = if (emphasized) { - Brush.verticalGradient( - listOf(AuroraAccent.copy(alpha = 0.6f), AuroraAccent.copy(alpha = 0.16f), Color.White.copy(alpha = 0.04f)), - ) - } else { - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.34f), Color.White.copy(alpha = 0.06f), Color.White.copy(alpha = 0.02f)), - ) - }, - shape = shape, - ) -} - -fun Modifier.auroraPanel(cornerRadius: Dp = 20.dp): Modifier { - val shape = RoundedCornerShape(cornerRadius) - return this - .clip(shape) - .background(AuroraGlassTint.copy(alpha = 0.42f)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.22f), - shape = shape, - ) -} - -/** - * Warm cream pill with a gold focus glow — the Aurora primary action. - * [supportingText], when given, is a smaller second line under [label] - * ("Continue as Alice" / "via Tailscale"); both read as one control. - */ -@Composable -fun AuroraPrimaryButton( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - icon: ImageVector? = null, - focusRequester: FocusRequester? = null, - focusHalo: Boolean = true, - filledAtRest: Boolean = true, - neutralFocusFill: Boolean = false, - enabled: Boolean = true, - supportingText: String? = null, -) { - var isFocused by remember { mutableStateOf(false) } - val shape = RoundedCornerShape(14.dp) - val scale by animateFloatAsState( - if (isFocused && focusHalo) 1.05f else 1f, - label = "auroraPrimaryScale", - ) - val glow by animateDpAsState( - if (!focusHalo) 0.dp else if (isFocused) 26.dp else 14.dp, - label = "auroraPrimaryGlow", - ) - val enabledAlpha = if (enabled) 1f else 0.48f - val fillBrush = if (isFocused && neutralFocusFill) { - Brush.verticalGradient(listOf(Color.White, Color.White)) - } else if (isFocused || filledAtRest) { - Brush.verticalGradient(listOf(AuroraCreamTop, AuroraCreamBottom)) - } else { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.White.copy(alpha = 0.06f))) - } - val contentColor = if (isFocused && neutralFocusFill) { - Color.Black.copy(alpha = 0.88f) - } else if (isFocused || filledAtRest) { - AuroraCreamInk - } else { - AuroraInk.copy(alpha = 0.68f) - } - - Box( - modifier = modifier - .graphicsLayer { scaleX = scale; scaleY = scale } - .shadow( - elevation = glow, - shape = shape, - clip = false, - ambientColor = if (isFocused && focusHalo) AuroraAccent else Color.Black, - spotColor = if (isFocused && focusHalo) AuroraAccent else Color.Black, - ) - .clip(shape) - .background(fillBrush) - .border( - width = if (isFocused) 3.dp else 1.dp, - color = if (isFocused) Color.White else Color.White.copy(alpha = 0.14f), - shape = shape, - ) - .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) - .onFocusChanged { isFocused = it.isFocused } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - enabled = enabled, - onClick = onClick, - ) - // A second line trades vertical padding, so both pills stand about as tall. - .padding(horizontal = 30.dp, vertical = if (supportingText != null) 10.dp else 18.dp), - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor.copy(alpha = enabledAlpha), - modifier = Modifier.size(24.dp), - ) - } - Column { - Text( - text = label, - color = contentColor.copy(alpha = enabledAlpha), - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - ) - if (supportingText != null) { - Text( - text = supportingText, - color = contentColor.copy(alpha = enabledAlpha * 0.72f), - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - ) - } - } - } - } -} - -/** - * Numbered onboarding step — a gold-outlined disc with the step number beside a - * line of ink copy. Ported from silo-apple's `AuroraStepRow`; used by the - * phone-first sign-in hero ("1 Scan with your phone's camera", …). - */ -@Composable -fun AuroraStepRow(number: Int, text: String, modifier: Modifier = Modifier) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(20.dp)) - .background(AuroraAccent.copy(alpha = 0.14f)) - .border(1.dp, AuroraAccent.copy(alpha = 0.55f), RoundedCornerShape(20.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = number.toString(), - color = AuroraAccent, - fontWeight = FontWeight.SemiBold, - fontSize = 19.sp, - ) - } - Spacer(Modifier.width(20.dp)) - Text( - text = text, - color = AuroraInk.copy(alpha = 0.92f), - fontWeight = FontWeight.Medium, - fontSize = 21.sp, - ) - } -} - -/** Tertiary ghost button (e.g. "Use a password instead", "Change server"). */ -@Composable -fun AuroraGhostButton( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - fontSize: TextUnit = 22.sp, - horizontalPadding: Dp = 22.dp, - verticalPadding: Dp = 12.dp, -) { - var isFocused by remember { mutableStateOf(false) } - val shape = RoundedCornerShape(12.dp) - val fill by animateColorAsState(if (isFocused) AuroraInk else Color.White.copy(alpha = 0.06f), label = "auroraGhostFill") - val content by animateColorAsState( - if (isFocused) AuroraNightBottom else AuroraInk.copy(alpha = 0.62f), - label = "auroraGhostContent", - ) - - Box( - modifier = modifier - .clip(shape) - .background(fill) - .border( - width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) Color.White else Color.White.copy(alpha = 0.14f), - shape = shape, - ) - .onFocusChanged { isFocused = it.isFocused } - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - contentAlignment = Alignment.Center, - ) { - Text(text = label, color = content, fontWeight = FontWeight.Medium, fontSize = fontSize) - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt index ffeb893b1f..d68e9d9112 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt @@ -1,5 +1,17 @@ package org.siloserver.silo.tv.ui.components +import org.siloserver.silo.common.ui.marquee.MarqueeBackdrop +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeProfileAvatar +import org.siloserver.silo.common.ui.marquee.MarqueeScrim +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButton +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButtonKind +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -12,12 +24,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Backspace import androidx.compose.runtime.Composable @@ -29,14 +37,10 @@ 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.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontFamily 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.sp import androidx.compose.ui.window.Popup @@ -48,10 +52,6 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.siloserver.silo.common.ui.components.ThumbhashImage -import org.siloserver.silo.common.ui.components.ProfileAvatarRef -import org.siloserver.silo.common.ui.components.profileAvatarDisplayText -import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage import org.siloserver.silo.tv.ui.focus.TvControlState import org.siloserver.silo.tv.ui.focus.tvControlSemantics import org.siloserver.silo.tv.ui.theme.FocusedContainer @@ -59,13 +59,17 @@ import org.siloserver.silo.tv.ui.theme.FocusedContent import org.siloserver.silo.tv.ui.theme.SiloOnSurface private const val PIN_LENGTH = 4 +private val PinKeySize = 50.dp -/** Compact, remote-first PIN keypad for protected TV profiles. */ +/** + * Full-screen, remote-first PIN prompt over the brand light: the person's + * avatar, four dots, and a round keypad (silo-apple `PINEntryView` on tvOS). + * A wrong PIN turns the dots red and clears for another try. + */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvPinEntryDialog( - profileName: String, - profileAvatar: ProfileAvatarRef = ProfileAvatarRef.None, + profile: Profile, onPinEntered: (String) -> Unit, onDismiss: () -> Unit, errorMessage: String? = null, @@ -91,26 +95,14 @@ fun TvPinEntryDialog( ), ) { Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - val panelShape = RoundedCornerShape(17.dp) + MarqueeBackdrop(mirrored = true) + MarqueeScrim(MarqueeScrimStyle.Ambient) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) Column( modifier = Modifier - .width(310.dp) - .shadow( - elevation = 18.dp, - shape = panelShape, - clip = false, - ambientColor = Color.Black.copy(alpha = 0.45f), - spotColor = Color.Black.copy(alpha = 0.45f), - ) - .clip(panelShape) - .background(Color(0xFF15171C)) - .border(0.5.dp, Color.White.copy(alpha = 0.16f), panelShape) - .padding(horizontal = 28.dp, vertical = 22.dp) // Retry-until-focused initial grab targeting the "5" key // (issue #64's fix, now shared). Exits as soon as ANYTHING // in the dialog holds focus, so a user who reaches Cancel @@ -118,32 +110,26 @@ fun TvPinEntryDialog( .then(rememberTvDialogInitialFocus(fiveFocusRequester)), horizontalAlignment = Alignment.CenterHorizontally, ) { - ProfilePinAvatar(profileName = profileName, profileAvatar = profileAvatar) - Spacer(modifier = Modifier.height(5.dp)) + MarqueeProfileAvatar(profile, size = 80.dp, baseSize = 110.dp, showsBadges = false) Text( - text = "Enter PIN for $profileName", - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 14.sp, - lineHeight = 17.sp, - fontWeight = FontWeight.SemiBold, - ), - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 2, + text = profile.name, + fontSize = 19.sp, + fontWeight = FontWeight.Bold, + color = MarqueeColors.Ink, + maxLines = 1, + modifier = Modifier + .padding(top = 11.dp) + .semantics { contentDescription = "Enter PIN for ${profile.name}" }, ) - Spacer(modifier = Modifier.height(14.dp)) - PinDots(pinLength = pin.length, error = latestError != null) - - if (latestError != null || isVerifying) { - Spacer(modifier = Modifier.height(7.dp)) - Text( - text = latestError ?: "Verifying...", - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = if (latestError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - ) + PinDots(pinLength = pin.length, error = latestError != null && pin.isEmpty(), modifier = Modifier.padding(top = 17.dp)) + Box(Modifier.padding(top = 9.dp).height(17.dp), contentAlignment = Alignment.Center) { + when { + latestError != null -> Text(latestError, fontSize = 14.sp, color = MarqueeColors.Error) + isVerifying -> Text("Checking…", fontSize = 14.sp, color = MarqueeColors.InkSecondary) + else -> Text("Enter your PIN", fontSize = 14.sp, color = MarqueeColors.InkSecondary) + } } - - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(15.dp)) PinKeypad( fiveFocusRequester = fiveFocusRequester, enabled = !isVerifying, @@ -156,83 +142,32 @@ fun TvPinEntryDialog( }, onBackspacePressed = { if (pin.isNotEmpty() && !isVerifying) pin = pin.dropLast(1) }, ) - Spacer(modifier = Modifier.height(10.dp)) - Surface( + // Not disabled while the PIN is checked: on TV a disabled + // control loses focus. The view model drops a late answer. + TvMarqueeButton( + text = "Cancel", onClick = onDismiss, - modifier = Modifier.heightIn(min = 48.dp), - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - contentColor = SiloOnSurface, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.045f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.90f)), - shape = RoundedCornerShape(50), - ), - ), - ) { - Text( - text = "Cancel", - modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - ) - } + kind = TvMarqueeButtonKind.Plain, + compact = true, + modifier = Modifier.padding(top = 15.dp), + ) } } } } @Composable -private fun ProfilePinAvatar(profileName: String, profileAvatar: ProfileAvatarRef) { - val avatarImage = rememberProfileAvatarImage(profileAvatar) - Box( - modifier = Modifier - .size(42.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.10f)), - contentAlignment = Alignment.Center, - ) { - if (avatarImage != null) { - ThumbhashImage( - url = avatarImage.url, - thumbhash = null, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - cacheKey = avatarImage.cacheKey, - onError = avatarImage.onLoadFailed, - ) - } else { - Text( - text = profileAvatarDisplayText(profileAvatar, profileName), - style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 21.sp), - color = MaterialTheme.colorScheme.onBackground, - ) - } - } -} - -@Composable -private fun PinDots(pinLength: Int, error: Boolean) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { +private fun PinDots(pinLength: Int, error: Boolean, modifier: Modifier = Modifier) { + val color = if (error) MarqueeColors.Error else MarqueeColors.Ink + Row(modifier, horizontalArrangement = Arrangement.spacedBy(14.dp)) { repeat(PIN_LENGTH) { index -> + val filled = error || index < pinLength Box( modifier = Modifier - .size(12.dp) + .size(13.dp) .clip(CircleShape) - .background( - when { - error -> MaterialTheme.colorScheme.error - index < pinLength -> MaterialTheme.colorScheme.primary - else -> Color.Black.copy(alpha = 0.45f) - }, - ), + .background(if (filled) color else Color.Transparent) + .border(1.5.dp, color.copy(alpha = if (filled) 1f else 0.7f), CircleShape), ) } } @@ -252,10 +187,10 @@ private fun PinKeypad( // the initial-focus policy is one-shot and never re-fires. val keyState = TvControlState.transient(enabled) Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { listOf("123", "456", "789").forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { row.forEach { digit -> PinKey( label = digit.toString(), @@ -266,8 +201,8 @@ private fun PinKeypad( } } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Spacer(modifier = Modifier.size(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Spacer(modifier = Modifier.size(PinKeySize)) PinKey(label = "0", controlState = keyState, onClick = { onDigitPressed('0') }) PinKey( label = null, @@ -290,7 +225,7 @@ private fun PinKey( ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val keyShape = RoundedCornerShape(9.dp) + val keyShape = CircleShape Surface( onClick = { controlState.perform(onClick) }, enabled = controlState.focusable, @@ -300,19 +235,19 @@ private fun PinKey( // (the panel shows its own progress), so the resting and disabled // slots are the same colours either way. colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), + containerColor = Color.White.copy(alpha = 0.13f), contentColor = SiloOnSurface, focusedContainerColor = FocusedContainer, focusedContentColor = FocusedContent, pressedContainerColor = FocusedContainer, pressedContentColor = FocusedContent, - disabledContainerColor = Color.White.copy(alpha = 0.10f), + disabledContainerColor = Color.White.copy(alpha = 0.13f), disabledContentColor = SiloOnSurface, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), border = ClickableSurfaceDefaults.border( border = Border( - border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.18f)), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), shape = keyShape, ), focusedBorder = Border( @@ -321,7 +256,7 @@ private fun PinKey( ), ), modifier = modifier - .size(48.dp) + .size(PinKeySize) .tvControlSemantics(controlState), ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -329,10 +264,9 @@ private fun PinKey( Text( text = label, style = MaterialTheme.typography.titleLarge.copy( - fontSize = 32.sp, - lineHeight = 36.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, + fontSize = 22.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Normal, ), color = if (isFocused) FocusedContent else SiloOnSurface, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvActiveServer.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvActiveServer.kt new file mode 100644 index 0000000000..86dd4cce5c --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvActiveServer.kt @@ -0,0 +1,26 @@ +package org.siloserver.silo.tv.ui.components.marquee + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import org.koin.core.context.GlobalContext +import org.siloserver.silo.common.ui.marquee.ServerBrandingLoader +import org.siloserver.silo.common.ui.marquee.SignInServer +import org.siloserver.silo.common.ui.marquee.rememberSignInServer +import org.siloserver.silo.network.ServerRegistry + +/** + * The active server's name and branding for a TV first-run screen, tinting + * the backdrop to its accent. Null where no Koin graph is running (screen + * tests), so screens fall back to the name their view model already has. + */ +@Composable +fun rememberTvActiveServer(): SignInServer? { + val koin = remember { GlobalContext.getOrNull() } ?: return null + val registry = remember(koin) { koin.getOrNull() } ?: return null + val loader = remember(koin) { koin.getOrNull() } ?: return null + val entry by registry.activeEntry.collectAsState() + val url = entry?.url ?: return null + return rememberSignInServer(serverUrl = url, savedName = entry?.fetchedName, loader = loader) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarquee.kt new file mode 100644 index 0000000000..4a2c539b07 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarquee.kt @@ -0,0 +1,425 @@ +package org.siloserver.silo.tv.ui.components.marquee + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Row +import androidx.compose.foundation.layout.RowScope +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import androidx.compose.material.icons.outlined.PhoneAndroid +import androidx.compose.material3.CircularProgressIndicator +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeScrim +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.common.ui.marquee.MarqueeServerMark +import org.siloserver.silo.common.ui.marquee.rememberReduceMotion +import org.siloserver.silo.tv.R + +/** + * First-run sizes on Android TV: the Apple TV values at half scale, since + * the TV surface is about 960×540dp against Apple TV's 1920×1080 points. + */ +object TvMarqueeMetrics { + val ButtonHeight = 38.dp + val ButtonFont = 14.5.sp + val ButtonPadding = 20.dp + val FieldHeight = 42.dp + val FieldFont = 15.sp + val FieldCorner = 8.dp + val HeroFont = 44.sp + val LeadFont = 14.sp + val CopyWidth = 450.dp + val CardWidth = 320.dp +} + +/** + * Every TV first-run button rests as glass (plain ones as bare text) and + * fills white when focused, like the rest of the TV app. + */ +enum class TvMarqueeButtonKind { Primary, Glass, Plain } + +/** + * Not disabled while work runs by default: on TV a disabled control loses + * focus. Callers ignore repeat presses instead, and pass [enabled] false only + * when the action truly can't run. + */ +@Composable +fun TvMarqueeButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: TvMarqueeButtonKind = TvMarqueeButtonKind.Primary, + isLoading: Boolean = false, + enabled: Boolean = true, + compact: Boolean = false, + focusRequester: FocusRequester? = null, + label: @Composable RowScope.(ink: Color) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.06f else 1f, label = "tvMarqueeButtonScale") + val ink = when { + focused -> Color.Black + kind == TvMarqueeButtonKind.Plain -> MarqueeColors.InkSecondary + else -> MarqueeColors.Ink + } + val height = if (compact) TvMarqueeMetrics.ButtonHeight * 0.8f else TvMarqueeMetrics.ButtonHeight + Row( + modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } + .shadow(if (focused) 12.dp else 0.dp, CircleShape, clip = false) + .height(height) + .clip(CircleShape) + .then( + when { + focused -> Modifier.background(MarqueeColors.Ink) + kind == TvMarqueeButtonKind.Plain -> Modifier + else -> Modifier.background(MarqueeColors.GlassFill).border(1.dp, MarqueeColors.Hairline, CircleShape) + }, + ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { focused = it.isFocused } + .clickable( + enabled = enabled, + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .alpha(if (enabled) 1f else 0.45f) + .padding(horizontal = if (compact) TvMarqueeMetrics.ButtonPadding * 0.7f else TvMarqueeMetrics.ButtonPadding), + horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isLoading) CircularProgressIndicator(color = ink, strokeWidth = 1.5.dp, modifier = Modifier.size(14.dp)) + androidx.compose.runtime.CompositionLocalProvider( + androidx.tv.material3.LocalTextStyle provides androidx.compose.ui.text.TextStyle( + fontSize = if (compact) TvMarqueeMetrics.ButtonFont * 0.85f else TvMarqueeMetrics.ButtonFont, + fontWeight = if (kind == TvMarqueeButtonKind.Plain && !focused) FontWeight.Medium else FontWeight.SemiBold, + ), + ) { + label(ink) + } + } +} + +@Composable +fun TvMarqueeButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: TvMarqueeButtonKind = TvMarqueeButtonKind.Primary, + isLoading: Boolean = false, + enabled: Boolean = true, + compact: Boolean = false, + icon: ImageVector? = null, + focusRequester: FocusRequester? = null, +) { + TvMarqueeButton(onClick, modifier, kind, isLoading, enabled, compact, focusRequester) { ink -> + if (icon != null) Icon(icon, contentDescription = null, tint = ink, modifier = Modifier.size(if (compact) 14.dp else 16.dp)) + Text(text, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** + * Apple TV-style first-run screen: the backdrop full-bleed under a leading + * scrim, copy on the left, the action card on the right. Each side is its + * own focus group. + */ +@Composable +fun TvMarqueeScreen( + modifier: Modifier = Modifier, + /** The copy column scrolls only when it can't fit (large text, the keyboard up). */ + copyScroll: ScrollState = rememberScrollState(), + accessory: @Composable RowScope.() -> Unit = {}, + copy: @Composable ColumnScope.() -> Unit, + card: @Composable () -> Unit, +) { + Box(modifier.fillMaxSize()) { + MarqueeScrim(MarqueeScrimStyle.Leading) + Column(Modifier.fillMaxSize().padding(horizontal = 45.dp, vertical = 30.dp)) { + Row(Modifier.fillMaxWidth().height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.silo_wordmark), + contentDescription = "Silo", + contentScale = ContentScale.Fit, + modifier = Modifier.width(75.dp), + ) + Spacer(Modifier.weight(1f)) + accessory() + } + Row( + Modifier.fillMaxWidth().weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(40.dp), + ) { + Column( + Modifier + .width(TvMarqueeMetrics.CopyWidth) + .verticalScroll(copyScroll) + .focusGroup(), + content = copy, + ) + Spacer(Modifier.weight(1f)) + Box(Modifier.focusGroup()) { card() } + } + } + } +} + +/** The right-hand glass card on TV first-run screens. */ +@Composable +fun TvMarqueeCard( + modifier: Modifier = Modifier, + width: Dp = TvMarqueeMetrics.CardWidth, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(20.dp) + Column( + modifier + .width(width) + .shadow(20.dp, shape, clip = false) + .clip(shape) + .background(Color(0xFF1F1F1F).copy(alpha = 0.72f)) + .border(1.dp, MarqueeColors.Hairline, shape) + .padding(26.dp), + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) +} + +/** Big TV title. */ +@Composable +fun TvMarqueeHeadline(text: String, modifier: Modifier = Modifier, maxLines: Int = 3) { + Text( + text, + modifier = modifier.semantics { heading() }, + color = MarqueeColors.Ink, + fontSize = TvMarqueeMetrics.HeroFont, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-1).sp, + lineHeight = 48.sp, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) +} + +/** Body copy at TV size, secondary ink. */ +@Composable +fun TvMarqueeBody( + text: String, + modifier: Modifier = Modifier, + size: TextUnit = TvMarqueeMetrics.LeadFont, + color: Color = MarqueeColors.InkSecondary, + textAlign: TextAlign? = null, +) { + Text(text, modifier = modifier, color = color, fontSize = size, lineHeight = size * 1.35f, textAlign = textAlign) +} + +/** Inline error under a control. */ +@Composable +fun TvMarqueeErrorText(text: String, modifier: Modifier = Modifier) { + Text( + text, + modifier = modifier.clearAndSetSemantics { contentDescription = "Error: $text" }, + color = MarqueeColors.Error, + fontSize = 14.sp, + lineHeight = 16.sp, + ) +} + +/** Small status capsule ("Looking for a phone or tablet…"). */ +@Composable +fun TvMarqueeStatusChip(text: String, modifier: Modifier = Modifier, showsSpinner: Boolean = false, icon: ImageVector? = null) { + Row( + modifier + .height(30.dp) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape) + .padding(horizontal = 11.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showsSpinner) { + CircularProgressIndicator(color = MarqueeColors.InkSecondary, strokeWidth = 2.dp, modifier = Modifier.size(13.dp)) + } else if (icon != null) { + Icon(icon, contentDescription = null, tint = MarqueeColors.InkSecondary, modifier = Modifier.size(12.dp)) + } + Text(text, color = MarqueeColors.InkSecondary, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1) + } +} + +/** A large symbol in a glass circle, for card states without a QR code or code. */ +@Composable +fun TvMarqueeCardSymbol(icon: ImageVector, tint: Color = MarqueeColors.Ink, size: Dp = 56.dp) { + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(MarqueeColors.GlassFill) + .border(1.dp, MarqueeColors.Hairline, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(size * 0.46f)) + } +} + +/** + * The card beside TV text entry: a nearby phone can still finish setup, so + * nothing has to be typed with the remote. + */ +@Composable +fun TvUsePhoneCard(text: String) { + TvMarqueeCard { + TvMarqueeCardSymbol(androidx.compose.material.icons.Icons.Outlined.PhoneAndroid) + Text( + "Easier with a phone", + color = MarqueeColors.Ink, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 17.dp), + ) + TvMarqueeBody(text, size = 14.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 7.dp)) + } +} + +/** + * "Now showing" card: which server this TV is signing in to, with its mark, + * address, and whether the connection is encrypted. + */ +@Composable +fun TvMarqueeServerCard(name: String, address: String, markUrl: String?, secure: Boolean, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(15.dp) + Row( + modifier + .clip(shape) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, MarqueeColors.Hairline, shape) + .padding(12.dp) + .semantics(mergeDescendants = true) { + contentDescription = "$name, $address, ${if (secure) "Secure" else "HTTP"}" + }, + horizontalArrangement = Arrangement.spacedBy(11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MarqueeServerMark(name = name, imageUrl = markUrl, size = 40.dp) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(name, color = MarqueeColors.Ink, fontSize = 17.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(address, color = MarqueeColors.InkTertiary, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val warning = !secure + Row( + Modifier + .height(19.dp) + .clip(CircleShape) + .background(if (warning) MarqueeColors.Warning.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.45f)) + .border(1.dp, if (warning) MarqueeColors.Warning.copy(alpha = 0.35f) else MarqueeColors.Hairline, CircleShape) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (secure) "SECURE" else "HTTP", + color = if (warning) MarqueeColors.Warning else MarqueeColors.Ink, + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.8.sp, + ) + } + } +} + +/** + * The code a person compares between the TV and their phone. Codes are + * server-generated and may be longer than eight characters, so tiles shrink + * to keep the row inside the card. + */ +@Composable +fun TvMarqueeCodeTiles(code: String, modifier: Modifier = Modifier, maxWidth: Dp = 260.dp) { + val characters = code.uppercase().toList() + val gap = 7.dp + val tileWidth = minOf(42.dp, (maxWidth - gap * (characters.size - 1).coerceAtLeast(0)) / characters.size.coerceAtLeast(1)) + Row( + modifier.clearAndSetSemantics { contentDescription = "Code: " + characters.joinToString(", ") }, + horizontalArrangement = Arrangement.spacedBy(gap), + ) { + characters.forEach { character -> + val separator = character == '-' || character == ' ' + Box( + Modifier + .width(if (separator) tileWidth * 0.45f else tileWidth) + .height(tileWidth * 1.24f) + .then( + if (separator) { + Modifier + } else { + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MarqueeColors.GlassFill) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(8.dp)) + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + if (character == '-') "–" else character.toString(), + color = if (separator) MarqueeColors.InkTertiary else MarqueeColors.Ink, + fontSize = (tileWidth.value * 0.66f).sp, + fontWeight = FontWeight.Bold, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeField.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeField.kt new file mode 100644 index 0000000000..7b5171825d --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeField.kt @@ -0,0 +1,161 @@ +package org.siloserver.silo.tv.ui.components.marquee + +import androidx.compose.animation.core.animateFloatAsState +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.Row +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +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.draw.alpha +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +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.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.rememberReduceMotion +import org.siloserver.silo.tv.ui.components.tvShowImeOnSelect + +/** + * TV text field in the first-run look: glass at rest, white with black ink + * when focused. SELECT opens the keyboard ([tvShowImeOnSelect]), so focus + * moving through the form never raises it by itself. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TvMarqueeField( + value: String, + onValueChange: (String) -> Unit, + icon: ImageVector, + placeholder: String, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Next, + onImeAction: (() -> Unit)? = null, + isSecure: Boolean = false, + /** With [isSecure], shows the characters instead of dots. */ + revealed: Boolean = false, + isError: Boolean = false, + enabled: Boolean = true, + focusRequester: FocusRequester? = null, + onFocusChange: (Boolean) -> Unit = {}, +) { + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + // Text is typed on the keyboard; without it up, Left and Right move + // between controls (the show-password button beside a field) rather + // than the invisible cursor. + val imeVisible = WindowInsets.isImeVisible + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.04f else 1f, label = "tvMarqueeFieldScale") + val shape = RoundedCornerShape(TvMarqueeMetrics.FieldCorner) + val ink = if (focused) Color.Black else MarqueeColors.Ink + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = true, + textStyle = TextStyle(color = ink, fontSize = TvMarqueeMetrics.FieldFont), + cursorBrush = SolidColor(if (focused) Color.Black else MarqueeColors.Ink), + visualTransformation = if (isSecure && !revealed) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions( + keyboardType = if (isSecure) KeyboardType.Password else keyboardType, + imeAction = imeAction, + autoCorrectEnabled = false, + showKeyboardOnFocus = false, + ), + keyboardActions = KeyboardActions(onAny = { onImeAction?.invoke() }), + modifier = modifier + .fillMaxWidth() + .height(TvMarqueeMetrics.FieldHeight) + .graphicsLayer { scaleX = scale; scaleY = scale } + .background(if (focused) MarqueeColors.Ink else Color.White.copy(alpha = if (isError) 0.06f else 0.08f), shape) + .border( + width = if (isError) 1.5.dp else 1.dp, + color = when { + isError -> MarqueeColors.Error.copy(alpha = 0.75f) + focused -> Color.Transparent + else -> Color.White.copy(alpha = 0.12f) + }, + shape = shape, + ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { + focused = it.isFocused + onFocusChange(it.isFocused) + } + .onPreviewKeyEvent { event -> + if (imeVisible || event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.DirectionRight -> focusManager.moveFocus(FocusDirection.Right) + Key.DirectionLeft -> focusManager.moveFocus(FocusDirection.Left) + else -> false + } + } + .tvShowImeOnSelect() + .alpha(if (enabled) 1f else 0.5f) + .semantics { contentDescription = placeholder }, + decorationBox = { inner -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 15.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = if (focused) Color.Black.copy(alpha = 0.45f) else MarqueeColors.InkTertiary, + modifier = Modifier.size(16.dp), + ) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text( + placeholder, + color = if (focused) Color.Black.copy(alpha = 0.4f) else MarqueeColors.InkTertiary, + fontSize = TvMarqueeMetrics.FieldFont, + maxLines = 1, + ) + } + inner() + } + } + }, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt new file mode 100644 index 0000000000..afad8c5f60 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt @@ -0,0 +1,157 @@ +package org.siloserver.silo.tv.ui.components.marquee + +import androidx.compose.foundation.Canvas +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import kotlin.math.tan + +/** + * How to set this TV up from a phone: three numbered steps, one action each, + * with a small picture of exactly that action. Short titles only, so they + * read from across the room (silo-apple `MarqueeTVSetupSteps`). + */ +@Composable +fun TvMarqueeSetupSteps(modifier: Modifier = Modifier) { + Column(modifier.width(350.dp), verticalArrangement = Arrangement.spacedBy(15.dp)) { + Step(1, "Open Silo on your phone") { AppIconPicture() } + Step(2, "Tap Set up") { SetupCardPicture() } + Step(3, "Finish on your phone") { DonePicture() } + } +} + +@Composable +private fun Step(number: Int, title: String, picture: @Composable () -> Unit) { + Row( + Modifier.clearAndSetSemantics { contentDescription = "Step $number: $title" }, + horizontalArrangement = Arrangement.spacedBy(17.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + Box( + Modifier + .size(84.dp) + .clip(RoundedCornerShape(17.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .border(1.dp, MarqueeColors.Hairline, RoundedCornerShape(17.dp)), + contentAlignment = Alignment.Center, + ) { picture() } + Box( + Modifier + .offset(x = (-8).dp, y = (-8).dp) + .shadow(4.dp, CircleShape) + .size(26.dp) + .clip(CircleShape) + .background(MarqueeColors.Ink), + contentAlignment = Alignment.Center, + ) { + Text("$number", color = Color.Black, fontSize = 14.sp, fontWeight = FontWeight.ExtraBold) + } + } + Text(title, color = MarqueeColors.Ink, fontSize = 19.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp) + } +} + +/** The Silo app icon: the three-bar mark on black. */ +@Composable +private fun AppIconPicture() { + Box( + Modifier + .size(46.dp) + .clip(RoundedCornerShape(11.dp)) + .background(Color.Black) + .border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(11.dp)), + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.size(width = 13.dp, height = 28.dp)) { + val bar = size.height / 3.6f + val slope = tan(Math.toRadians(-18.0)).toFloat() + listOf(MarqueeColors.BrandBlue, MarqueeColors.BrandRed, MarqueeColors.BrandOrange).forEachIndexed { index, color -> + val top = index * (bar + size.height * 0.05f) + // A bar slanted like CSS skewY(-18deg). + val path = Path().apply { + moveTo(0f, top) + lineTo(size.width, top + slope * size.width) + lineTo(size.width, top + bar + slope * size.width) + lineTo(0f, top + bar) + close() + } + drawPath(path, color) + } + } + } +} + +/** The setup card Silo shows on a nearby phone, with its Set up button ringed. */ +@Composable +private fun SetupCardPicture() { + Column( + Modifier + .width(64.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF262626).copy(alpha = 0.95f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(10.dp)) + .padding(start = 6.dp, end = 6.dp, top = 7.dp, bottom = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(Modifier.width(40.dp).height(4.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.3f))) + Box(Modifier.width(28.dp).height(4.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.16f))) + Box( + Modifier + .fillMaxWidth() + .border(2.dp, MarqueeColors.BrandOrange.copy(alpha = 0.85f), CircleShape) + .padding(2.dp) + .height(15.dp) + .clip(CircleShape) + .background(MarqueeColors.Ink), + contentAlignment = Alignment.Center, + ) { + Text("Set up", color = Color.Black, fontSize = 7.5.sp, fontWeight = FontWeight.Bold) + } + } +} + +/** A green check: the rest happens on the phone. */ +@Composable +private fun DonePicture() { + val green = MarqueeColors.Live + Box( + Modifier + .size(48.dp) + .clip(CircleShape) + .background(green.copy(alpha = 0.16f)) + .border(1.5.dp, green, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Check, contentDescription = null, tint = green, modifier = Modifier.size(24.dp)) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 35d8bf9384..c12cb00c94 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.tv.ui.navigation import androidx.compose.foundation.layout.fillMaxWidth import android.net.Uri import android.util.Log +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -23,6 +24,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState +import org.siloserver.silo.common.ui.marquee.MarqueeBackdrop import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import org.siloserver.silo.common.player.video.VideoPlayerRouteArgs @@ -329,6 +331,15 @@ private val preMainAuthRoutes: Set = setOf( TvRoute.EditProfile.ROUTE, ) +/** First-run routes, drawn transparent over the shared [MarqueeBackdrop]. */ +private val tvMarqueeRoutes: Set = setOf( + TvRoute.ServerSetup.route, + TvRoute.Setup.route, + TvRoute.Signup.route, + TvRoute.Login.ROUTE, + TvRoute.ProfileSelection.route, +) + /** * Page-to-page cross-fade duration (ms). A middle ground between Compose Nav's * sluggish 700ms default and a phone-snappy 200ms — a touch more deliberate for @@ -569,6 +580,16 @@ fun TvAppNavigation( ProvideCardPresentation(store = cardPresentationStore, sessionKey = overlaySessionKey) { ProvideTitleArt(store = titleArtStore, sessionKey = overlaySessionKey) { Box(modifier = Modifier.fillMaxSize()) { + // The first-run screens draw over one shared brand-light backdrop that + // lives outside the destinations, so it keeps moving while they fade. + // Mirrored: TV copy sits on the left, so the light pools behind the card. + AnimatedVisibility( + visible = currentEntry?.destination?.route in tvMarqueeRoutes, + enter = fadeIn(tween(TvPageFadeDurationMs)), + exit = fadeOut(tween(TvPageFadeDurationMs)), + ) { + MarqueeBackdrop(mirrored = true) + } NavHost( navController = navController, startDestination = startDestination, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt index 916338f861..5f5d232fdd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreen.kt @@ -1,14 +1,11 @@ package org.siloserver.silo.tv.ui.screens.auth import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image 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.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 @@ -16,22 +13,24 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Lan +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material.icons.outlined.PhoneAndroid import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable 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.setValue @@ -40,41 +39,30 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.InputMode -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.ExperimentalTextApi -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.VerbatimTtsAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily 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.text.withAnnotation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -85,27 +73,32 @@ import org.koin.core.parameter.parametersOf import org.siloserver.silo.common.pairing.PairingReceiver import org.siloserver.silo.common.pairing.PairingReceiverStatus import org.siloserver.silo.common.pairing.TvPairingAdvertiser -import org.siloserver.silo.model.auth.DeviceCodeFormat +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeScene +import org.siloserver.silo.common.ui.marquee.marqueeShake +import org.siloserver.silo.common.ui.marquee.SignInHandoff import org.siloserver.silo.model.auth.SignInProvider import org.siloserver.silo.tv.R -import org.siloserver.silo.tv.ui.components.AuroraEyebrow -import org.siloserver.silo.tv.ui.components.AuroraGhostButton -import org.siloserver.silo.tv.ui.components.AuroraJourneyProgress -import org.siloserver.silo.tv.ui.components.AuroraPrimaryButton -import org.siloserver.silo.tv.ui.components.AuroraStepRow -import org.siloserver.silo.tv.ui.components.TvAuroraBackdrop -import org.siloserver.silo.tv.ui.components.TvAuroraVariant -import org.siloserver.silo.tv.ui.components.TvAuthFormDefaults -import org.siloserver.silo.tv.ui.components.auroraGlass -import org.siloserver.silo.tv.ui.components.auroraPanel +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeBody +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButton +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButtonKind +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCard +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCardSymbol +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCodeTiles +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeErrorText +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeField +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeHeadline +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeMetrics +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeScreen +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeServerCard +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeStatusChip +import org.siloserver.silo.tv.ui.components.marquee.TvUsePhoneCard +import org.siloserver.silo.tv.ui.components.marquee.rememberTvActiveServer import org.siloserver.silo.tv.ui.components.rememberTvImeAwareFormScrollState import org.siloserver.silo.tv.ui.components.tvImeAwareFieldContext -import org.siloserver.silo.tv.ui.components.tvOutlinedTextFieldColors -import org.siloserver.silo.tv.ui.components.tvShowImeOnSelect import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvFocusLog import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.theme.Spacing /** * TV sign-in: every way in is visible at once — scan the QR, type @@ -184,6 +177,8 @@ fun TvLoginScreen( val routeOnce = { if (!routed) { routed = true + // A one-profile household with no PIN goes straight to Home. + SignInHandoff.skipsSingleProfilePicker = true onLoginSuccess() } } @@ -300,139 +295,190 @@ fun TvLoginScreen( ) } + val server = rememberTvActiveServer() + LaunchedEffect(Unit) { + MarqueeScene.focus = MarqueeScene.Focus.Account + MarqueeScene.personalTint = null + } + val displayName = server?.name ?: serverName + // No code and no password on this server: "Continue as …" stands alone. + val networkOnly = device.passwordOnly && !state.passwordAvailable && network != null + Box( modifier = Modifier .fillMaxSize() .imePadding(), ) { - TvAuroraBackdrop(variant = TvAuroraVariant.SignIn) - if (isActivePairing) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(Spacing.xl), - contentAlignment = Alignment.Center, - ) { - ActivePairingPanel( - status = pairingStatus, - onCancel = pairingReceiver::cancelActiveSession, - onContinue = routeOnce, - onAllow = pairingReceiver::allowPendingServer, - onDeny = pairingReceiver::denyPendingServer, - onUseAlternate = pairingReceiver::useAlternateAddress, - onRetryAddress = pairingReceiver::retryPushedAddress, - signIn = true, - modifier = Modifier.widthIn(max = 440.dp), - ) - } + ActivePairingPanel( + status = pairingStatus, + onCancel = pairingReceiver::cancelActiveSession, + onContinue = routeOnce, + onAllow = pairingReceiver::allowPendingServer, + onDeny = pairingReceiver::denyPendingServer, + onUseAlternate = pairingReceiver::useAlternateAddress, + onRetryAddress = pairingReceiver::retryPushedAddress, + signIn = true, + ) return@Box } - Column( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxSize() - // The scroll is an IME/odd-surface safety valve only. At the - // reference TV surface (1920x1080 @ 320dpi = 960x540dp) BOTH - // branches must measure shorter than the viewport, because a - // scrolled column takes the header chrome off the top with no - // D-pad way back. - .verticalScroll(formScrollState) - .padding( - top = Spacing.safeAreaVertical, - bottom = Spacing.safeAreaVertical, - start = 54.dp, - end = 54.dp, - ), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - BrandHeader() - AuroraJourneyProgress( - currentStep = 2, - modifier = Modifier.width(215.dp), + val notices: @Composable () -> Unit = { + if (state.sessionExpired && displayName.isNotBlank()) { + TvMarqueeStatusChip( + stringResource(R.string.tv_signin_session_expired, displayName), + icon = Icons.Outlined.ErrorOutline, + modifier = Modifier.padding(bottom = 15.dp).semantics { liveRegion = LiveRegionMode.Polite }, ) } - - if (state.sessionExpired && serverName.isNotBlank()) { - Spacer(modifier = Modifier.height(Spacing.sm)) - SessionExpiredBanner(serverName) - } // A password refused because password sign-in is off closes the // form, the only place its error shows: say it here instead. if (state.passwordTurnedOff) { - Spacer(modifier = Modifier.height(Spacing.sm)) - StatusBanner(stringResource(R.string.tv_signin_error_local_login_disabled)) + TvMarqueeStatusChip( + stringResource(R.string.tv_signin_error_local_login_disabled), + icon = Icons.Outlined.ErrorOutline, + modifier = Modifier.padding(bottom = 15.dp).semantics { liveRegion = LiveRegionMode.Polite }, + ) } + TvMarqueeServerCard( + name = displayName, + address = server?.hostLabel ?: state.serverHost.orEmpty(), + markUrl = server?.branding?.markUrl, + secure = server?.isSecure ?: false, + ) + } - Spacer(modifier = Modifier.height(Spacing.sm)) + when { + passwordFormVisible -> PasswordScreen( + state = state, + passwordOnly = device.passwordOnly, + serverName = displayName, + notices = notices, + usernameFocus = usernameFocus, + usernameFocusTracking = trackFocus("username"), + passwordFocus = passwordFocus, + signInFocus = signInFocus, + createAccountFocus = createAccountFocus, + backToPhoneFocus = backToPhoneFocus, + changeServerFocus = formChangeServerFocus, + onUsernameChanged = viewModel::onUsernameChanged, + onPasswordChanged = viewModel::onPasswordChanged, + onLoginClick = viewModel::onLoginClick, + signupEnabled = signupEnabled, + onCreateAccount = onCreateAccount, + onBackToPhone = backToPhone, + onChangeServer = changeServer, + network = network.takeIf { networkInForm }, + ) - if (passwordFormVisible) { - AuroraEyebrow( - text = stringResource(R.string.tv_signin_eyebrow), - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - Spacer(modifier = Modifier.height(Spacing.md)) - CredentialFormCard( - state = state, - passwordOnly = device.passwordOnly, - usernameFocus = usernameFocus, - usernameFocusTracking = trackFocus("username"), - passwordFocus = passwordFocus, - signInFocus = signInFocus, - createAccountFocus = createAccountFocus, - backToPhoneFocus = backToPhoneFocus, - changeServerFocus = formChangeServerFocus, - onUsernameChanged = viewModel::onUsernameChanged, - onPasswordChanged = viewModel::onPasswordChanged, - onLoginClick = viewModel::onLoginClick, - signupEnabled = signupEnabled, - onCreateAccount = onCreateAccount, - onBackToPhone = backToPhone, - onChangeServer = changeServer, - network = network.takeIf { networkInForm }, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .width(520.dp), - ) - } else { - Row( - horizontalArrangement = Arrangement.spacedBy(40.dp), - verticalAlignment = Alignment.Top, - modifier = Modifier.fillMaxWidth(), - ) { - SignInHero( - serverName = serverName, - activateText = device.code?.activateText ?: state.serverActivateText.orEmpty(), + networkOnly -> TvMarqueeScreen( + copy = { + notices() + TvMarqueeHeadline(stringResource(R.string.tv_signin_title, displayName), Modifier.padding(top = 22.dp)) + network?.let { NetworkSignInButton(it, below = changeServerFocus, modifier = Modifier.padding(top = 20.dp)) } + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_change_server), + onClick = changeServer, + kind = TvMarqueeButtonKind.Plain, + focusRequester = changeServerFocus, + modifier = Modifier + .padding(top = 15.dp) + .then(trackFocus("changeServer")) + .focusProperties { network?.let { up = it.focusRequester } }, + ) + }, + card = { + TvMarqueeCard { + TvMarqueeCardSymbol(Icons.Outlined.Lan) + Text( + "No code needed", + color = MarqueeColors.Ink, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 17.dp), + ) + TvMarqueeBody( + "This TV reached $displayName through ${network?.provider?.displayName ?: "its network"}, " + + "which knows who it belongs to.", + size = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 7.dp), + ) + } + }, + ) + + else -> TvMarqueeScreen( + copy = { + notices() + TvMarqueeHeadline(stringResource(R.string.tv_signin_title, displayName), Modifier.padding(top = 22.dp)) + if (network != null) { + NetworkSignInButton( + action = network, + // The copy between holds nothing focusable: name the way down. + below = codeActionBelowNetwork(device.status, state.passwordAvailable, actionFocus, usePasswordFocus, changeServerFocus), + modifier = Modifier.padding(top = 16.dp), + ) + Text( + "Or sign in with your phone", + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 15.dp), + ) + } + val activate = device.code?.activateText ?: state.serverActivateText.orEmpty() + TvMarqueeBody( + "Scan the code with your phone's camera, or go to " + + (activate.ifBlank { "your server's /activate page" }) + + " and enter it. Approve on your phone and this TV signs in by itself.", + modifier = Modifier.padding(top = if (network == null) 13.dp else 6.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 12.dp), + ) { + Icon(Icons.Outlined.PhoneAndroid, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(13.dp)) + Text(stringResource(R.string.tv_signin_nearby_hint), color = MarqueeColors.InkTertiary, fontSize = 14.sp) + } + CodeActions( + status = device.status, + showPasswordAction = state.passwordAvailable, + network = network, + actionFocus = actionFocus, + actionTracking = trackFocus("action"), usePasswordFocus = usePasswordFocus, - changeServerFocus = changeServerFocus, usePasswordTracking = trackFocus("usePassword"), + changeServerFocus = changeServerFocus, changeServerTracking = trackFocus("changeServer"), + onAction = viewModel::restartDeviceLogin, // The form would hide "Signing in…" while "Continue as …" // waits, and its Sign in would do nothing until then. onUsePassword = { if (!state.networkBusy) showPasswordForm = true }, onChangeServer = changeServer, - showPasswordAction = state.passwordAvailable, - network = network, - modifier = Modifier.weight(1f), - ) - DeviceCodePanel( - device = device, - serverHost = state.serverHost.orEmpty(), - actionFocus = actionFocus, - actionTracking = trackFocus("action"), - onAction = viewModel::restartDeviceLogin, + modifier = Modifier.padding(top = 24.dp), ) - } - } + }, + card = { TvMarqueeCard { CodeCard(device = device, serverHost = state.serverHost.orEmpty()) } }, + ) } } } +/** Where Down from "Continue as …" lands on the code screen: the first local action. */ +private fun codeActionBelowNetwork( + status: TvSignInStatus, + passwordAvailable: Boolean, + actionFocus: FocusRequester, + usePasswordFocus: FocusRequester, + changeServerFocus: FocusRequester, +): FocusRequester = when { + status.action() != null -> actionFocus + passwordAvailable && status != TvSignInStatus.UpdateRequired -> usePasswordFocus + else -> changeServerFocus +} + /** The "Try again" / "Show a new code" action a status offers, if any. */ private fun TvSignInStatus.action(): Int? = when (this) { TvSignInStatus.CouldntFinish, @@ -456,28 +502,9 @@ private fun TvSignInStatus.actionTakesFocus(): Boolean = when (this) { else -> false } -@Composable -private fun SessionExpiredBanner(serverName: String) = - StatusBanner(stringResource(R.string.tv_signin_session_expired, serverName)) - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun StatusBanner(text: String) { - Text( - text = text, - style = TvLoginTextStyles.Body, - color = Color.White, - modifier = Modifier - .fillMaxWidth() - .auroraGlass(12.dp) - .padding(horizontal = Spacing.md, vertical = Spacing.sm) - .semantics { liveRegion = LiveRegionMode.Polite }, - ) -} - /** - * "Continue as " through a network provider, for [SignInHero] and, on - * a server without device sign-in, [CredentialFormCard]. [error] is why the + * "Continue as " through a network provider, for the code screen and, + * on a server without device sign-in, the password screen. [error] is why the * last press was refused. */ private class NetworkSignInAction( @@ -490,82 +517,68 @@ private class NetworkSignInAction( ) /** - * Left column: title, "Continue as " when a network provider offers - * it, the three ways in, the nearby-phone hint, and the two local actions. - * Mirrors tvOS `TVLoginView.heroColumn`. + * The state's action ("Try again" / "Show a new code"), "Sign in with a + * password" and "Change server" on one row, as on Apple TV. Controls stay + * enabled while work runs so focus doesn't drop; the actions ignore repeats. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SignInHero( - serverName: String, - activateText: String, +private fun CodeActions( + status: TvSignInStatus, + showPasswordAction: Boolean, + network: NetworkSignInAction?, + actionFocus: FocusRequester, + actionTracking: Modifier, usePasswordFocus: FocusRequester, - changeServerFocus: FocusRequester, usePasswordTracking: Modifier, + changeServerFocus: FocusRequester, changeServerTracking: Modifier, + onAction: () -> Unit, onUsePassword: () -> Unit, onChangeServer: () -> Unit, modifier: Modifier = Modifier, - showPasswordAction: Boolean = true, - network: NetworkSignInAction? = null, ) { - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier, - ) { - AuroraEyebrow(text = stringResource(R.string.tv_signin_eyebrow)) - Text( - text = stringResource(R.string.tv_signin_title, serverName), - style = TvLoginTextStyles.Hero, - color = MaterialTheme.colorScheme.onBackground, - ) - if (network != null) { - NetworkSignInButton( - action = network, - // The steps between hold nothing focusable: name the way down. - below = if (showPasswordAction) usePasswordFocus else changeServerFocus, + val upTo = network?.focusRequester + // An update requirement hides the password button: no password can help. + val offersPassword = showPasswordAction && status != TvSignInStatus.UpdateRequired + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(11.dp)) { + status.action()?.let { label -> + TvMarqueeButton( + text = stringResource(label), + onClick = onAction, + icon = Icons.Filled.Refresh, + focusRequester = actionFocus, + modifier = Modifier + .then(actionTracking) + .focusProperties { upTo?.let { up = it } }, ) } - AuroraStepRow(number = 1, text = stringResource(R.string.tv_signin_step_scan)) - AuroraStepRow(number = 2, text = stringResource(R.string.tv_signin_step_url, activateText)) - AuroraStepRow(number = 3, text = stringResource(R.string.tv_signin_step_approve)) - Text( - text = stringResource(R.string.tv_signin_nearby_hint), - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - if (showPasswordAction) { - AuroraGhostButton( - label = stringResource(R.string.tv_signin_action_password), - onClick = onUsePassword, - fontSize = 17.sp, - horizontalPadding = 16.dp, - verticalPadding = 8.dp, - modifier = Modifier - .then(usePasswordTracking) - .focusRequester(usePasswordFocus) - .focusProperties { - right = changeServerFocus - if (network != null) up = network.focusRequester - }, - ) - } - AuroraGhostButton( - label = stringResource(R.string.tv_signin_action_change_server), - onClick = onChangeServer, - fontSize = 17.sp, - horizontalPadding = 16.dp, - verticalPadding = 8.dp, + if (offersPassword) { + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_password), + onClick = onUsePassword, + kind = TvMarqueeButtonKind.Glass, + icon = Icons.Outlined.Key, + focusRequester = usePasswordFocus, modifier = Modifier - .then(changeServerTracking) - .focusRequester(changeServerFocus) + .then(usePasswordTracking) .focusProperties { - if (showPasswordAction) left = usePasswordFocus - if (network != null) up = network.focusRequester + right = changeServerFocus + upTo?.let { up = it } }, ) } + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_change_server), + onClick = onChangeServer, + kind = TvMarqueeButtonKind.Plain, + focusRequester = changeServerFocus, + modifier = Modifier + .then(changeServerTracking) + .focusProperties { + if (offersPassword) left = usePasswordFocus + upTo?.let { up = it } + }, + ) } } @@ -575,117 +588,115 @@ private fun SignInHero( * when offered, with its refusal under it. It stays focusable while signing * in so focus doesn't jump; the view model ignores a second press. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun NetworkSignInButton(action: NetworkSignInAction, below: FocusRequester) { +private fun NetworkSignInButton(action: NetworkSignInAction, below: FocusRequester, modifier: Modifier = Modifier) { val providerName = action.provider.displayName val owner = action.provider.networkIdentity?.label - AuroraPrimaryButton( - label = when { - action.busy -> stringResource(R.string.tv_signin_form_submitting) - owner != null -> stringResource(R.string.tv_signin_network_continue_as, owner) - else -> stringResource(R.string.tv_signin_network_continue_with, providerName) - }, - supportingText = owner?.let { stringResource(R.string.tv_signin_network_via, providerName) }, - onClick = action.onClick, - focusRequester = action.focusRequester, - modifier = Modifier - .then(action.focusTracking) - .focusProperties { down = below }, - ) - action.error?.let { error -> - Text( - text = error, - style = TvLoginTextStyles.Error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) + val title = when { + action.busy -> stringResource(R.string.tv_signin_form_submitting) + owner != null -> stringResource(R.string.tv_signin_network_continue_as, owner) + else -> stringResource(R.string.tv_signin_network_continue_with, providerName) + } + val via = owner?.takeIf { !action.busy }?.let { stringResource(R.string.tv_signin_network_via, providerName) } + Column(modifier, verticalArrangement = Arrangement.spacedBy(7.dp)) { + TvMarqueeButton( + onClick = action.onClick, + isLoading = action.busy, + focusRequester = action.focusRequester, + modifier = Modifier + .height(if (via != null) 52.dp else TvMarqueeMetrics.ButtonHeight) + .then(action.focusTracking) + .focusProperties { down = below }, + ) { ink -> + if (!action.busy) TvProviderMark(action.provider) + Column { + Text(title, color = ink, maxLines = 1) + if (via != null) Text(via, color = ink.copy(alpha = 0.7f), fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1) + } + } + action.error?.let { error -> + TvMarqueeErrorText(error, Modifier.width(380.dp).semantics { liveRegion = LiveRegionMode.Polite }) + } + } +} + +/** A provider's initial on a warm gradient, beside "Continue as …". */ +@Composable +private fun TvProviderMark(provider: SignInProvider) { + Box( + Modifier + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Brush.linearGradient(listOf(Color(0xFFFD4B2D), Color(0xFFA21D5C)))), + contentAlignment = Alignment.Center, + ) { + Text(provider.displayName.firstOrNull()?.lowercase() ?: "s", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.ExtraBold) } } /** - * Right panel: the QR (at least a third of the screen height, dark on white, - * 4-module quiet zone), the code in large monospace grouped 4+4, the typed-URL - * form, and a polite live status line. No countdown: codes renew in place. + * The card: the QR (dark on white, quiet zone), the code as tiles, the + * typed-URL form, and a polite live status line. No countdown: codes renew + * in place. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun DeviceCodePanel( - device: TvDeviceSignInUi, - serverHost: String, - actionFocus: FocusRequester, - actionTracking: Modifier, - onAction: () -> Unit, -) { - val qrSize = maxOf(MinQrSize, (LocalConfiguration.current.screenHeightDp / 3).dp) +private fun CodeCard(device: TvDeviceSignInUi, serverHost: String) { val code = device.code - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .width(qrSize + 72.dp) - .auroraGlass(15.dp) - .padding(horizontal = 18.dp, vertical = 14.dp), - ) { - if (code != null) { + when { + code != null -> { val qrHost = code.activateText.removeSuffix("/activate") - QrCodePanel( - content = code.qrContent, - size = qrSize, - description = stringResource(R.string.tv_signin_qr_description, qrHost, code.spokenCode), - ) - SignInCodeText( - code = code.userCode, - spokenCode = code.spokenCode, - style = TvSignInCodeStyle, - ) + Box( + Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .padding(10.dp), + ) { + QrCodePanel( + content = code.qrContent, + size = QrSize, + description = stringResource(R.string.tv_signin_qr_description, qrHost, code.spokenCode), + ) + } + TvMarqueeCodeTiles(code = code.userCode, modifier = Modifier.padding(top = 17.dp)) Text( - text = code.activateText, - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, + code.activateText, + color = MarqueeColors.InkSecondary, + fontSize = 14.sp, + maxLines = 2, textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 9.dp), ) - } else { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(qrSize) - .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(8.dp)) - .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(8.dp)), - ) { - if (device.status == TvSignInStatus.GettingCode) { - CircularProgressIndicator( - color = Color.White.copy(alpha = 0.7f), - strokeWidth = 3.dp, - modifier = Modifier.size(36.dp), - ) - } + } + device.status == TvSignInStatus.SignedIn -> TvMarqueeCardSymbol(Icons.Filled.Check, tint = MarqueeColors.Live, size = 70.dp) + device.status.isProblem() -> TvMarqueeCardSymbol(Icons.Outlined.ErrorOutline, tint = MarqueeColors.Warning) + else -> Box( + Modifier + .size(QrSize + 20.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White), + contentAlignment = Alignment.Center, + ) { + if (device.status == TvSignInStatus.GettingCode) { + CircularProgressIndicator(color = Color.Black.copy(alpha = 0.5f), strokeWidth = 2.dp, modifier = Modifier.size(28.dp)) } } - + } + Row( + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(top = 12.dp) + .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, + ) { + if (device.status == TvSignInStatus.Waiting || device.status == TvSignInStatus.Opened || device.status == TvSignInStatus.GettingCode) { + CircularProgressIndicator(color = MarqueeColors.InkSecondary, strokeWidth = 2.dp, modifier = Modifier.size(13.dp)) + } Text( - text = device.statusText(serverHost), - style = TvLoginTextStyles.Status, - color = if (device.status.isProblem()) MaterialTheme.colorScheme.error else Color.White, + device.statusText(serverHost), + color = if (device.status.isProblem()) MarqueeColors.Error else MarqueeColors.InkSecondary, + fontSize = 14.sp, textAlign = TextAlign.Center, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) - - device.status.action()?.let { label -> - // Ghost style at a compact size so "Show a new code" fits the - // panel on one line; focus fills it like the other actions. - AuroraGhostButton( - label = stringResource(label), - onClick = onAction, - fontSize = 18.sp, - horizontalPadding = 14.dp, - verticalPadding = 8.dp, - modifier = Modifier - .fillMaxWidth() - .then(actionTracking) - .focusRequester(actionFocus), - ) - } } } @@ -717,54 +728,6 @@ private fun TvDeviceSignInUi.statusText(serverHost: String): String = when (stat TvSignInStatus.UpdateRequired -> stringResource(R.string.tv_signin_status_update_required) } -/** - * The sign-in code as ONE element TalkBack reads character by character - * (`TtsSpan.TYPE_VERBATIM`), so `4821 7730` is never read as a number. - */ -@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalTextApi::class) -@Composable -internal fun SignInCodeText( - code: String, - spokenCode: String, - style: TextStyle, - modifier: Modifier = Modifier, - label: String? = null, -) { - val annotated = remember(code) { - buildAnnotatedString { - withAnnotation(VerbatimTtsAnnotation(DeviceCodeFormat.normalize(code))) { append(code) } - } - } - val description = label?.let { "$it, $spokenCode" } - Text( - text = annotated, - style = style, - color = Color.White, - textAlign = TextAlign.Center, - modifier = modifier.semantics(mergeDescendants = true) { - if (description != null) contentDescription = description - }, - ) -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun BrandHeader() { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Image( - painter = painterResource(id = R.drawable.silo_wordmark), - contentDescription = "Silo", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, - ) - } -} - /** * [providerName] is the server's browser sign-in provider: an account that * signs in with it has no password here, so a refused password points to the @@ -804,11 +767,17 @@ private fun TvLoginError.message(providerName: String?, externalName: String?): } } -@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +/** + * Username and password on the left, laid out like Apple TV's password + * screen; the card beside them says a nearby phone can sign in instead. + */ +@OptIn(ExperimentalComposeUiApi::class) @Composable -private fun CredentialFormCard( +private fun PasswordScreen( state: TvLoginUiState, passwordOnly: Boolean, + serverName: String, + notices: @Composable () -> Unit, usernameFocus: FocusRequester, usernameFocusTracking: Modifier, passwordFocus: FocusRequester, @@ -823,303 +792,189 @@ private fun CredentialFormCard( onCreateAccount: () -> Unit, onBackToPhone: () -> Unit, onChangeServer: () -> Unit, - modifier: Modifier = Modifier, network: NetworkSignInAction? = null, ) { + // Each rejected password shakes the fields and puts focus back on the + // password: the fields were disabled while it was checked, so focus left. + var failures by remember { mutableIntStateOf(0) } + var passwordFocused by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) } - // Height budget, not taste: this card plus the screen chrome above it has - // to measure under 540dp (the 1920x1080 @ 320dpi TV surface) with the 24dp - // overscan inset intact, or the root Column starts scrolling and the header - // chrome leaves the screen unreachably. - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .auroraPanel(20.dp) - .padding(horizontal = 24.dp, vertical = 14.dp), - ) { - Text( - text = stringResource(R.string.tv_signin_form_title), - style = TvLoginTextStyles.Title, - color = MaterialTheme.colorScheme.onBackground, - ) - if (passwordOnly && network == null) { - Text( - text = stringResource(R.string.tv_signin_password_only), - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, + LaunchedEffect(state.error) { + if (state.error != null && state.password.isNotEmpty()) { + failures++ + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = passwordFocus::requestFocus, + isFocused = { passwordFocused }, ) } - val firstSecondary = when { - signupEnabled -> createAccountFocus - !passwordOnly -> backToPhoneFocus - else -> changeServerFocus - } - // While either sign-in waits, the fields and Sign in are disabled and - // can't take focus: "Continue as …" (which keeps focus while it waits) - // and the secondary actions reach each other directly. - val aboveSecondary = if (state.signingIn && network != null) network.focusRequester else signInFocus - if (network != null) { - NetworkSignInButton(action = network, below = if (state.signingIn) firstSecondary else usernameFocus) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier.tvImeAwareFieldContext(), - ) { - Text( - text = stringResource(R.string.tv_signin_form_username), - style = TvLoginTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.username, - onValueChange = onUsernameChanged, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.signingIn, - textStyle = TvLoginTextStyles.Field, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .semantics { contentType = ContentType.Username } - .then(usernameFocusTracking) - .tvShowImeOnSelect() - .focusRequester(usernameFocus) - .focusProperties { if (network != null) up = network.focusRequester }, - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier.tvImeAwareFieldContext(), - ) { - Text( - text = stringResource(R.string.tv_signin_form_password), - style = TvLoginTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.password, - onValueChange = onPasswordChanged, - singleLine = true, - visualTransformation = if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton( - onClick = { passwordVisible = !passwordVisible }, - enabled = !state.signingIn, - ) { - Icon( - imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = stringResource( - if (passwordVisible) R.string.tv_signin_form_hide_password else R.string.tv_signin_form_show_password, - ), - ) - } - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onDone = { - if (canSubmitTvCredentialLogin(state.username, state.password, state.signingIn)) { - onLoginClick() - } + } + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + copy = { + Column { + notices() + TvMarqueeHeadline("Sign in with\na password", Modifier.padding(top = 22.dp)) + TvMarqueeBody( + if (passwordOnly && network == null) { + stringResource(R.string.tv_signin_password_only) + } else { + "Use your $serverName username and password." }, - ), - enabled = !state.signingIn, - textStyle = TvLoginTextStyles.Field, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .semantics { contentType = ContentType.Password } - .tvShowImeOnSelect() - .focusRequester(passwordFocus), - colors = tvOutlinedTextFieldColors(), - ) - } - - state.error?.let { error -> - Text( - text = error.message(state.providerName, state.directoryName), - style = TvLoginTextStyles.Error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) - } - // An account that signs in with the server's provider has no password - // here (silo-apple parity). The 401 message says the same, so the hint - // steps aside while that message shows. - state.providerName?.takeIf { state.error != TvLoginError.InvalidCredentials }?.let { provider -> - Text( - text = stringResource(R.string.tv_signin_form_provider_hint, provider), - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - AuroraPrimaryButton( - label = stringResource(if (state.isLoading) R.string.tv_signin_form_submitting else R.string.tv_signin_form_submit), - icon = Icons.AutoMirrored.Filled.Login, - onClick = onLoginClick, - focusRequester = signInFocus, - focusHalo = false, - filledAtRest = false, - neutralFocusFill = true, - enabled = !state.signingIn, - modifier = Modifier - // Explicit chain: the label Texts are not focusable, so there - // is no default search to fall back on. - .focusProperties { down = firstSecondary } - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), - ) - - // Secondary actions on ONE row, not stacked: three full-width ghost - // buttons would cost ~120dp of the 540dp viewport. - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.fillMaxWidth(), - ) { - if (signupEnabled) { - AuroraGhostButton( - label = stringResource(R.string.tv_signin_form_create_account), - onClick = onCreateAccount, - fontSize = TvLoginSecondaryActionFontSize, - horizontalPadding = TvLoginSecondaryActionPadding, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(createAccountFocus) - .focusProperties { - up = aboveSecondary - right = if (passwordOnly) changeServerFocus else backToPhoneFocus - } - .weight(1f), + modifier = Modifier.padding(top = 11.dp), ) - } - if (!passwordOnly) { - // Back to the device code (a fresh one if the old one lapsed). - AuroraGhostButton( - label = stringResource(R.string.tv_signin_form_use_phone), - onClick = onBackToPhone, - fontSize = TvLoginSecondaryActionFontSize, - horizontalPadding = TvLoginSecondaryActionPadding, - verticalPadding = 8.dp, + val firstSecondary = when { + signupEnabled -> createAccountFocus + !passwordOnly -> backToPhoneFocus + else -> changeServerFocus + } + // While either sign-in waits, the fields and Sign in are disabled and + // can't take focus: "Continue as …" (which keeps focus while it waits) + // and the secondary actions reach each other directly. + val aboveSecondary = if (state.signingIn && network != null) network.focusRequester else signInFocus + if (network != null) { + NetworkSignInButton( + action = network, + below = if (state.signingIn) firstSecondary else usernameFocus, + modifier = Modifier.padding(top = 15.dp), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier - .focusRequester(backToPhoneFocus) - .focusProperties { - up = aboveSecondary - if (signupEnabled) left = createAccountFocus - right = changeServerFocus + .width(380.dp) + .padding(top = 20.dp) + .marqueeShake(failures), + ) { + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.username, + onValueChange = onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = stringResource(R.string.tv_signin_form_username_placeholder), + enabled = !state.signingIn, + focusRequester = usernameFocus, + modifier = Modifier + .semantics { contentType = ContentType.Username } + .then(usernameFocusTracking) + .focusProperties { if (network != null) up = network.focusRequester }, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.password, + onValueChange = onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = stringResource(R.string.tv_signin_form_password_placeholder), + isSecure = true, + revealed = passwordVisible, + isError = state.error != null, + imeAction = ImeAction.Done, + onImeAction = { + if (canSubmitTvCredentialLogin(state.username, state.password, state.signingIn)) onLoginClick() + }, + enabled = !state.signingIn, + focusRequester = passwordFocus, + onFocusChange = { passwordFocused = it }, + modifier = Modifier.semantics { contentType = ContentType.Password }, + ) } - .weight(1f), - ) - } - AuroraGhostButton( - label = stringResource(R.string.tv_signin_action_change_server), - onClick = onChangeServer, - fontSize = TvLoginSecondaryActionFontSize, - horizontalPadding = TvLoginSecondaryActionPadding, - verticalPadding = 8.dp, - modifier = Modifier - .focusRequester(changeServerFocus) - .focusProperties { - up = aboveSecondary - left = when { - !passwordOnly -> backToPhoneFocus - signupEnabled -> createAccountFocus - else -> signInFocus + TvMarqueeButton( + onClick = { passwordVisible = !passwordVisible }, + kind = TvMarqueeButtonKind.Glass, + enabled = !state.signingIn, + modifier = Modifier.semantics { + contentDescription = if (passwordVisible) "Hide password" else "Show password" + }, + ) { ink -> + Icon( + if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = null, + tint = ink, + modifier = Modifier.size(16.dp), + ) } } - .weight(1f), + } + state.error?.let { error -> + TvMarqueeErrorText( + error.message(state.providerName, state.directoryName), + Modifier + .width(380.dp) + .padding(top = 8.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + // An account that signs in with the server's provider has no password + // here (silo-apple parity). The 401 message says the same, so the hint + // steps aside while that message shows. + state.providerName?.takeIf { state.error != TvLoginError.InvalidCredentials }?.let { provider -> + TvMarqueeBody( + stringResource(R.string.tv_signin_form_provider_hint, provider), + size = 14.sp, + color = MarqueeColors.InkTertiary, + modifier = Modifier.width(380.dp).padding(top = 8.dp), + ) + } + Row( + Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + TvMarqueeButton( + text = stringResource(if (state.isLoading) R.string.tv_signin_form_submitting else R.string.tv_signin_form_submit), + onClick = onLoginClick, + isLoading = state.isLoading, + focusRequester = signInFocus, + enabled = !state.signingIn, + modifier = Modifier.focusProperties { down = firstSecondary }, + ) + if (signupEnabled) { + TvMarqueeButton( + text = stringResource(R.string.tv_signin_form_create_account), + onClick = onCreateAccount, + kind = TvMarqueeButtonKind.Plain, + focusRequester = createAccountFocus, + modifier = Modifier.focusProperties { up = aboveSecondary }, + ) + } + if (!passwordOnly) { + // Back to the device code (a fresh one if the old one lapsed). + TvMarqueeButton( + text = stringResource(R.string.tv_signin_form_use_phone), + onClick = onBackToPhone, + kind = TvMarqueeButtonKind.Plain, + focusRequester = backToPhoneFocus, + modifier = Modifier.focusProperties { up = aboveSecondary }, + ) + } + TvMarqueeButton( + text = stringResource(R.string.tv_signin_action_change_server), + onClick = onChangeServer, + kind = TvMarqueeButtonKind.Plain, + focusRequester = changeServerFocus, + modifier = Modifier.focusProperties { up = aboveSecondary }, + ) + } + } + }, + card = { + TvUsePhoneCard( + if (passwordOnly) { + "Signed in to Silo on your phone? Open it on the same Wi‑Fi and it can sign this TV in for you." + } else { + "Choose \"Use your phone instead\" to scan a code and approve on your phone. Nothing to type with the remote." + }, ) - } - } -} - -/** The sign-in code in large monospace, grouped 4+4; shared with the nearby-phone panel. */ -internal val TvSignInCodeStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 34.sp, - lineHeight = 40.sp, - letterSpacing = 2.sp, -) - -/** A QR at least a third of the 540dp reference surface. */ -private val MinQrSize: Dp = 180.dp - -/** - * Type and inset for the sign-in card's side-by-side secondary actions. Branch - * -local on purpose: [TvAuthFormDefaults] is shared with server setup, sign-up - * and first-run setup, and those screens have no reason to shrink. - */ -private val TvLoginSecondaryActionFontSize = 16.sp -private val TvLoginSecondaryActionPadding = 10.dp - -private object TvLoginTextStyles { - val Hero = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 26.sp, - lineHeight = 30.sp, - letterSpacing = 0.sp, - ) - - val Title = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 26.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp, - ) - - val Body = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - ) - - val Status = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - - val Field = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 17.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - color = Color.White, - ) - - /** Mono uppercase caption that labels each input — mirrors server setup. */ - val InputLabel = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 3.sp, - ) - - val Error = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, + }, ) } +/** The QR, at least a third of the 540dp reference surface with its frame. */ +private val QrSize: Dp = 170.dp + /** How long a finished sign-in waits for a nearby phone's session to wrap up before routing on. */ private const val NEARBY_RESULT_WAIT_MS = 5_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt index 57b53ff3ad..a768ae59e7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvPairingPanel.kt @@ -1,16 +1,12 @@ package org.siloserver.silo.tv.ui.screens.auth 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.Row 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward @@ -21,35 +17,41 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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 import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily 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.sp import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.siloserver.silo.common.pairing.PairingReceiverStatus import org.siloserver.silo.model.auth.DeviceCodeFormat import org.siloserver.silo.pairing.PairingFailureCode import org.siloserver.silo.tv.R -import org.siloserver.silo.tv.ui.components.AuroraEyebrow -import org.siloserver.silo.tv.ui.components.AuroraGhostButton -import org.siloserver.silo.tv.ui.components.AuroraPrimaryButton -import org.siloserver.silo.tv.ui.components.auroraGlass +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.rememberReduceMotion +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeBody +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButton +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButtonKind +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCard +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCardSymbol +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCodeTiles +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeHeadline +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeScreen +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeStatusChip +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.PhoneAndroid +import kotlinx.coroutines.delay import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.theme.Spacing /** Whether the receiver is in a phone session the TV should show in place of its own screen. */ internal val PairingReceiverStatus.isActivePairing: Boolean @@ -85,7 +87,8 @@ internal fun ActivePairingPanel( val primaryFocus = remember { FocusRequester() } var panelHasFocus by remember { mutableStateOf(false) } // Every state with a decision puts focus on it; a prompt whose button - // never takes focus can't be answered from a remote at all. + // never takes focus can't be answered from a remote at all. Waiting + // states don't, so a stray Select can't cancel the phone's session. val focusKey = when (status) { is PairingReceiverStatus.ConsentRequested -> "consent" is PairingReceiverStatus.Unreachable -> "unreachable" @@ -103,188 +106,204 @@ internal fun ActivePairingPanel( ) } } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = modifier - .onFocusChanged { panelHasFocus = it.hasFocus } - .fillMaxWidth(), - ) { - when (status) { - PairingReceiverStatus.Connected -> { - AuroraEyebrow(text = stringResource(if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect)) - PanelTitle(stringResource(R.string.tv_pairing_connected_title)) - WaitingDots() - PanelDetail( - stringResource( - if (signIn) R.string.tv_pairing_connected_detail_signin else R.string.tv_pairing_connected_detail, - ), - ) - AuroraGhostButton(label = stringResource(R.string.tv_pairing_cancel), onClick = onCancel) - } - is PairingReceiverStatus.ConsentRequested -> { - AuroraEyebrow(text = stringResource(if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect)) - PanelTitle( - stringResource(if (signIn) R.string.tv_pairing_consent_title_signin else R.string.tv_pairing_consent_title), - ) - PanelDetail(stringResource(R.string.tv_pairing_consent_detail, status.serverName)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.padding(top = Spacing.sm), - ) { - AuroraPrimaryButton( - label = stringResource(R.string.tv_pairing_allow), - onClick = onAllow, - focusRequester = primaryFocus, - modifier = Modifier - .width(180.dp) - .height(60.dp), + val eyebrow = when (status) { + is PairingReceiverStatus.SignedIn, is PairingReceiverStatus.Completed -> R.string.tv_pairing_eyebrow_done + is PairingReceiverStatus.Pairing, is PairingReceiverStatus.Unreachable, is PairingReceiverStatus.AwaitingApproval -> + R.string.tv_pairing_eyebrow_almost + else -> if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect + } + TvMarqueeScreen( + modifier = modifier.onFocusChanged { panelHasFocus = it.hasFocus }, + copy = { + TvMarqueeStatusChip(stringResource(eyebrow), icon = Icons.Outlined.PhoneAndroid, modifier = Modifier.padding(bottom = 18.dp)) + when (status) { + PairingReceiverStatus.Connected -> { + PanelTitle(stringResource(R.string.tv_pairing_connected_title)) + PanelDetail( + stringResource( + if (signIn) R.string.tv_pairing_connected_detail_signin else R.string.tv_pairing_connected_detail, + ), ) - AuroraGhostButton( - label = stringResource(R.string.tv_pairing_dont_allow), - onClick = onDeny, - modifier = Modifier - .width(180.dp) - .height(60.dp), + PanelActions { CancelButton(onCancel, primaryFocus) } + } + is PairingReceiverStatus.ConsentRequested -> { + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_consent_title_signin else R.string.tv_pairing_consent_title), ) + PanelDetail(stringResource(R.string.tv_pairing_consent_detail, status.serverName)) + PanelActions { + TvMarqueeButton(text = stringResource(R.string.tv_pairing_allow), onClick = onAllow, focusRequester = primaryFocus) + TvMarqueeButton(text = stringResource(R.string.tv_pairing_dont_allow), onClick = onDeny, kind = TvMarqueeButtonKind.Plain) + } } - } - is PairingReceiverStatus.Pairing -> { - AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_almost)) - PanelTitle( - stringResource(if (signIn) R.string.tv_pairing_starting_title_signin else R.string.tv_pairing_starting_title), - ) - ServerNameLabel(status.serverName, signIn) - WaitingDots(compact = true) - PanelDetail(stringResource(R.string.tv_pairing_starting_detail)) - AuroraGhostButton(label = stringResource(R.string.tv_pairing_cancel), onClick = onCancel) - } - is PairingReceiverStatus.Unreachable -> { - AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_almost)) - PanelTitle(stringResource(R.string.tv_pairing_unreachable_title, status.serverName)) - val providerName = status.providerName - PanelDetail( - if (providerName != null) { - stringResource(R.string.tv_pairing_unreachable_provider, status.serverName, providerName) - } else { + is PairingReceiverStatus.Pairing -> { + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_starting_title_signin else R.string.tv_pairing_starting_title), + ) + PanelDetail(stringResource(R.string.tv_pairing_starting_detail)) + PanelActions { CancelButton(onCancel, primaryFocus) } + } + is PairingReceiverStatus.Unreachable -> { + PanelTitle(stringResource(R.string.tv_pairing_unreachable_title, status.serverName)) + val providerName = status.providerName + PanelDetail( + if (providerName != null) { + stringResource(R.string.tv_pairing_unreachable_provider, status.serverName, providerName) + } else { + stringResource( + R.string.tv_pairing_unreachable_public, + status.serverName, + DeviceCodeFormat.host(status.serverURL), + ) + }, + ) + PanelActions { + val alternate = status.alternateUrl + if (alternate != null) { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_use_alternate, DeviceCodeFormat.host(alternate)), + onClick = onUseAlternate, + focusRequester = primaryFocus, + ) + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_try_again), + onClick = onRetryAddress, + kind = TvMarqueeButtonKind.Glass, + ) + } else { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_try_again), + onClick = onRetryAddress, + focusRequester = primaryFocus, + ) + } + CancelButton(onCancel) + } + } + is PairingReceiverStatus.AwaitingApproval -> { + PanelTitle( stringResource( - R.string.tv_pairing_unreachable_public, - status.serverName, - DeviceCodeFormat.host(status.serverURL), - ) - }, - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - val alternate = status.alternateUrl - if (alternate != null) { - AuroraPrimaryButton( - label = stringResource(R.string.tv_pairing_use_alternate, DeviceCodeFormat.host(alternate)), - onClick = onUseAlternate, - focusRequester = primaryFocus, - modifier = Modifier.width(320.dp), - ) - AuroraGhostButton( - label = stringResource(R.string.tv_pairing_try_again), - onClick = onRetryAddress, - modifier = Modifier.width(320.dp), - ) - } else { - AuroraPrimaryButton( - label = stringResource(R.string.tv_pairing_try_again), - onClick = onRetryAddress, + if (status.automatic) R.string.tv_pairing_confirm_title_automatic else R.string.tv_pairing_confirm_title, + ), + ) + PanelDetail( + stringResource( + if (status.automatic) R.string.tv_pairing_confirm_detail_automatic else R.string.tv_pairing_confirm_detail, + ), + ) + PanelActions { CancelButton(onCancel, primaryFocus) } + } + is PairingReceiverStatus.SignedIn -> { + PanelTitle( + if (status.serverCount <= 1) { + stringResource(R.string.tv_pairing_signed_in) + } else { + stringResource(R.string.tv_pairing_signed_in_count, status.serverCount) + }, + ) + PanelDetail(stringResource(R.string.tv_pairing_finishing)) + } + is PairingReceiverStatus.Completed -> { + PanelTitle(stringResource(R.string.tv_pairing_completed_title)) + PanelDetail( + if (status.serverNames.isEmpty()) { + stringResource(R.string.tv_pairing_completed_none) + } else { + stringResource(R.string.tv_pairing_completed_named, status.serverNames.joinToString(", ")) + }, + ) + PanelActions { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_continue), + icon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onContinue, focusRequester = primaryFocus, - modifier = Modifier.width(320.dp), ) } - AuroraGhostButton( - label = stringResource(R.string.tv_pairing_cancel), - onClick = onCancel, - modifier = Modifier.width(320.dp), - ) } - } - is PairingReceiverStatus.AwaitingApproval -> { - AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_almost)) - PanelTitle( - stringResource( - if (status.automatic) R.string.tv_pairing_confirm_title_automatic else R.string.tv_pairing_confirm_title, - ), - ) - SignInCodeCard(userCode = status.userCode) - olderPhonesMatchWords(status)?.let { words -> + is PairingReceiverStatus.Failed -> { + PanelTitle( + stringResource(if (signIn) R.string.tv_pairing_failed_title_signin else R.string.tv_pairing_failed_title), + ) Text( - text = stringResource(R.string.tv_pairing_confirm_older_phones, words), - style = TvPairingTextStyles.Caption, - color = Color.White.copy(alpha = 0.48f), - textAlign = TextAlign.Center, + text = stringResource(status.code.messageRes(), status.serverName), + color = MarqueeColors.Error, + fontSize = 14.sp, + lineHeight = 19.sp, + modifier = Modifier.padding(top = 13.dp), ) + PanelActions { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_try_again), + onClick = onCancel, + focusRequester = primaryFocus, + ) + } } - ServerNameLabel(status.serverName, signIn) - WaitingDots(compact = true) - PanelDetail( - stringResource( - if (status.automatic) R.string.tv_pairing_confirm_detail_automatic else R.string.tv_pairing_confirm_detail, - ), - ) - AuroraGhostButton(label = stringResource(R.string.tv_pairing_cancel), onClick = onCancel) - } - is PairingReceiverStatus.SignedIn -> { - AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_done)) - SuccessMark() - PanelTitle( - if (status.serverCount <= 1) { - stringResource(R.string.tv_pairing_signed_in) - } else { - stringResource(R.string.tv_pairing_signed_in_count, status.serverCount) - }, - ) - WaitingDots(compact = true) - PanelDetail(stringResource(R.string.tv_pairing_finishing)) + else -> Unit } - is PairingReceiverStatus.Completed -> { - AuroraEyebrow(text = stringResource(R.string.tv_pairing_eyebrow_done)) - SuccessMark() - PanelTitle(stringResource(R.string.tv_pairing_completed_title)) - PanelDetail( - if (status.serverNames.isEmpty()) { - stringResource(R.string.tv_pairing_completed_none) - } else { - stringResource(R.string.tv_pairing_completed_named, status.serverNames.joinToString(", ")) - }, - ) - AuroraPrimaryButton( - label = stringResource(R.string.tv_pairing_continue), - icon = Icons.AutoMirrored.Filled.ArrowForward, - onClick = onContinue, - focusRequester = primaryFocus, - modifier = Modifier.width(320.dp), - ) - } - is PairingReceiverStatus.Failed -> { - AuroraEyebrow(text = stringResource(if (signIn) R.string.tv_pairing_eyebrow_signin else R.string.tv_pairing_eyebrow_connect)) - PanelTitle( - stringResource(if (signIn) R.string.tv_pairing_failed_title_signin else R.string.tv_pairing_failed_title), - ) - Text( - text = stringResource(status.code.messageRes(), status.serverName), - style = TvPairingTextStyles.Detail, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - ) - AuroraPrimaryButton( - label = stringResource(R.string.tv_pairing_try_again), - onClick = onCancel, - focusRequester = primaryFocus, - modifier = Modifier.width(320.dp), - ) + }, + card = { + TvMarqueeCard { + when (status) { + is PairingReceiverStatus.AwaitingApproval -> { + if (status.userCode.isNotBlank()) { + Text( + stringResource(R.string.tv_signin_code_label), + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + ) + TvMarqueeCodeTiles(DeviceCodeFormat.display(status.userCode), Modifier.padding(top = 11.dp)) + } + olderPhonesMatchWords(status)?.let { words -> + Text( + text = stringResource(R.string.tv_pairing_confirm_older_phones, words), + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 11.dp), + ) + } + ServerNameLabel(status.serverName, signIn) + WaitingDots(Modifier.padding(top = 15.dp)) + } + is PairingReceiverStatus.Pairing -> { + ServerNameLabel(status.serverName, signIn) + WaitingDots(Modifier.padding(top = 15.dp)) + } + is PairingReceiverStatus.SignedIn, is PairingReceiverStatus.Completed -> + TvMarqueeCardSymbol(Icons.Filled.Check, tint = MarqueeColors.Live, size = 70.dp) + is PairingReceiverStatus.Failed, is PairingReceiverStatus.Unreachable -> + TvMarqueeCardSymbol(Icons.Outlined.ErrorOutline, tint = MarqueeColors.Warning, size = 70.dp) + else -> { + TvMarqueeCardSymbol(Icons.Outlined.PhoneAndroid, size = 70.dp) + WaitingDots(Modifier.padding(top = 17.dp)) + } + } } - else -> Unit - } - } + }, + ) +} + +@Composable +private fun PanelActions(content: @Composable RowScope.() -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + content = content, + ) +} + +@Composable +private fun CancelButton(onCancel: () -> Unit, focusRequester: FocusRequester? = null) { + TvMarqueeButton( + text = stringResource(R.string.tv_pairing_cancel), + onClick = onCancel, + kind = TvMarqueeButtonKind.Glass, + focusRequester = focusRequester, + ) } /** @@ -304,117 +323,56 @@ private fun PairingFailureCode.messageRes(): Int = when (this) { PairingFailureCode.AuthFailed -> R.string.tv_pairing_failed_generic } -@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun PanelTitle(text: String) { - Text( - text = text, - style = TvPairingTextStyles.Title, - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) + TvMarqueeHeadline(text, Modifier.semantics { liveRegion = LiveRegionMode.Polite }) } -@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun PanelDetail(text: String) { - Text( - text = text, - style = TvPairingTextStyles.Detail, - color = Color.White.copy(alpha = 0.72f), - textAlign = TextAlign.Center, - ) + TvMarqueeBody(text, size = 16.sp, modifier = Modifier.padding(top = 13.dp)) } -/** - * The TV's sign-in code, grouped 4+4, the same code the phone asks the person - * to check. Read to TalkBack character by character. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SignInCodeCard(userCode: String) { - if (userCode.isBlank()) return - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .auroraGlass(12.dp) - .padding(horizontal = Spacing.md, vertical = Spacing.md), - ) { - SignInCodeText( - code = DeviceCodeFormat.display(userCode), - spokenCode = DeviceCodeFormat.spoken(userCode), - label = stringResource(R.string.tv_signin_code_label), - style = TvSignInCodeStyle, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun ServerNameLabel(serverName: String, signIn: Boolean) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - Text( - text = stringResource(if (signIn) R.string.tv_pairing_server_label_signin else R.string.tv_pairing_server_label), - style = TvPairingTextStyles.Label, - color = Color.White.copy(alpha = 0.48f), - ) - Text( - text = serverName, - style = TvPairingTextStyles.Headline, - color = Color.White, - ) - } + Text( + text = stringResource(if (signIn) R.string.tv_pairing_server_label_signin else R.string.tv_pairing_server_label), + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.5.sp, + modifier = Modifier.padding(top = 17.dp), + ) + Text( + text = serverName, + color = MarqueeColors.Ink, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp), + ) } +/** Pulsing dots while something happens on the phone. */ @Composable -private fun WaitingDots(compact: Boolean = false) { - val dotSize = if (compact) 8.dp else 12.dp - Row(horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 12.dp)) { - repeat(3) { +private fun WaitingDots(modifier: Modifier = Modifier, count: Int = 5) { + val reduceMotion = rememberReduceMotion() + var phase by remember { mutableStateOf(if (reduceMotion) count / 2 else 0) } + if (!reduceMotion) { + LaunchedEffect(Unit) { + while (true) { + delay(280) + phase = (phase + 1) % (count + 1) + } + } + } + Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(count) { index -> Box( - modifier = Modifier - .size(dotSize) - .background(Color.White.copy(alpha = 0.82f), RoundedCornerShape(999.dp)), + Modifier + .size(6.dp) + .background(MarqueeColors.Ink.copy(alpha = if (index <= phase) 0.9f else 0.22f), RoundedCornerShape(999.dp)), ) } } } - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SuccessMark() { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(74.dp) - .background(Color(0xFF22C55E).copy(alpha = 0.18f), RoundedCornerShape(999.dp)) - .border(2.dp, Color(0xFF22C55E).copy(alpha = 0.68f), RoundedCornerShape(999.dp)), - ) { - Text( - text = "✓", - style = TvPairingTextStyles.SuccessMark, - color = Color(0xFF86EFAC), - ) - } -} - -private object TvPairingTextStyles { - val Title = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 30.sp) - val Detail = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp) - val Caption = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp) - val Headline = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 26.sp) - val Label = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 2.sp, - ) - val SuccessMark = TextStyle(fontWeight = FontWeight.Bold, fontSize = 38.sp, lineHeight = 38.sp) -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt index e857e02426..a69e590ffa 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupScreen.kt @@ -1,43 +1,24 @@ package org.siloserver.silo.tv.ui.screens.auth -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.StartOffset -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.focusable +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Smartphone +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Tv import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -45,33 +26,23 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.input.InputMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily 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.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.delay import org.koin.compose.koinInject @@ -80,39 +51,35 @@ import org.siloserver.silo.common.pairing.PairingAdvertisement import org.siloserver.silo.common.pairing.PairingReceiver import org.siloserver.silo.common.pairing.PairingReceiverStatus import org.siloserver.silo.common.pairing.TvPairingAdvertiser +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeScene +import org.siloserver.silo.common.ui.marquee.ServerBranding +import org.siloserver.silo.common.ui.marquee.SignInHandoff import org.siloserver.silo.tv.R -import org.siloserver.silo.tv.ui.components.AuroraAccent -import org.siloserver.silo.tv.ui.components.AuroraEyebrow -import org.siloserver.silo.tv.ui.components.AuroraGhostButton -import org.siloserver.silo.tv.ui.components.AuroraInk -import org.siloserver.silo.tv.ui.components.AuroraJourneyProgress -import org.siloserver.silo.tv.ui.components.AuroraPrimaryButton -import org.siloserver.silo.tv.ui.components.TvAuroraBackdrop -import org.siloserver.silo.tv.ui.components.TvAuroraVariant import org.siloserver.silo.tv.ui.components.TvHideStockImeOnDispose -import org.siloserver.silo.tv.ui.components.auroraGlass +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeBody +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButton +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButtonKind +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeErrorText +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeField +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeHeadline +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeScreen +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeSetupSteps +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeStatusChip +import org.siloserver.silo.tv.ui.components.marquee.TvUsePhoneCard import org.siloserver.silo.tv.ui.components.rememberTvImeAwareFormScrollState import org.siloserver.silo.tv.ui.components.tvImeAwareFieldContext -import org.siloserver.silo.tv.ui.components.tvShowImeOnSelect -import org.siloserver.silo.tv.ui.components.TvAuthFormDefaults -import org.siloserver.silo.tv.ui.components.tvOutlinedTextFieldColors import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvFocusLog import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.theme.Spacing /** - * Server setup — connects the app to a Silo server. - * - * While idle, this mirrors tvOS `TVServerSetupView`, scaled for the Shield's - * ~960×540dp canvas (the iOS source is laid out in 1920×1080 points): the - * wordmark sits top-left in normal flow, a centered eyebrow + title header - * block sits below it, and the phone-card / OR / manual-card chooser is - * centered in the remaining vertical space via a `weight(1f)` box. There are - * no fixed row heights and no negative offsets — the weight box is what keeps - * the title clear of the eyebrow and the phone headline clear of its - * description. Once a phone connects, the chooser swaps in-place to the live - * pairing receiver flow. + * First-run server setup on Android TV (silo-apple `TVServerSetupView`). The + * screen advertises on the LAN the moment it appears, so the lead path is a + * nearby phone or tablet setting this TV up with nothing to type, shown as + * three numbered steps. Typing the address is one button away, under "No + * phone nearby?". When a phone connects, the screen swaps in place to the + * pairing receiver. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -120,17 +87,18 @@ fun TvServerSetupScreen( onContinueToLogin: (signupEnabled: Boolean) -> Unit, onNeedsSetup: () -> Unit, onPairedSignIn: () -> Unit = {}, + /** A saved server this TV is still signed in to: straight to its profiles. */ + onSignedIn: () -> Unit = onPairedSignIn, viewModel: TvServerSetupViewModel = koinViewModel(), pairingReceiver: PairingReceiver = koinInject(), pairingAdvertiser: TvPairingAdvertiser = koinInject(), ) { val state by viewModel.uiState.collectAsState() val pairingStatus by pairingReceiver.status.collectAsState() - val focusRequester = remember { FocusRequester() } - val phoneSetupFocus = remember { FocusRequester() } - var phoneCardHasFocus by remember { mutableStateOf(false) } - val formScrollState = rememberTvImeAwareFormScrollState() val isActivePairing = pairingStatus.isActivePairing + var enteringAddress by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { MarqueeScene.showGeneric() } // Companion LAN pairing: advertise `_silopair._tcp` while this screen is on // so a phone running Silo can push the server URL + drive device-login, @@ -142,38 +110,16 @@ fun TvServerSetupScreen( advertiserToken = token onDispose { pairingAdvertiser.stop(token) } } - // Snapshot-backed: re-keys the claim when the viewer switches between - // pointer and key input. - val inputMode = LocalInputModeManager.current.inputMode - LaunchedEffect(isActivePairing, inputMode) { - // Pointer users click what they want — and in touch mode the claim - // could not land anyway. Re-run on mode flip so the D-pad always has - // somewhere to start. - if (!isActivePairing && inputMode != InputMode.Touch) { - // Land on the phone-pairing card: companion setup is the - // recommended path, so it gets first focus (product call - // 2026-08-14, reversing the 2026-07-10 field-first default). - // Landing on the URL field also popped the IME over the form, - // and the IME resize scrolled the header chrome off-screen. - TvFocusLog.d { "serverSetup: claiming phone card (mode=$inputMode)" } - val result = requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = phoneSetupFocus::requestFocus, - isFocused = { phoneCardHasFocus }, - ) - TvFocusLog.d { "serverSetup: claim result=$result" } - } else { - TvFocusLog.d { - "serverSetup: claim skipped (pairing=$isActivePairing, mode=$inputMode)" - } - } + val finishPairing = { + pairingAdvertiser.stop(advertiserToken) + // A one-profile household with no PIN goes straight to Home. + SignInHandoff.skipsSingleProfilePicker = true + onPairedSignIn() } LaunchedEffect(pairingStatus) { if (pairingStatus is PairingReceiverStatus.Completed) { delay(1_800) - pairingAdvertiser.stop(advertiserToken) - onPairedSignIn() + finishPairing() } } @@ -187,6 +133,10 @@ fun TvServerSetupScreen( viewModel.onNavigationConsumed() onContinueToLogin(dest.signupEnabled) } + TvServerSetupDestination.Profiles -> { + viewModel.onNavigationConsumed() + onSignedIn() + } null -> Unit } } @@ -194,399 +144,219 @@ fun TvServerSetupScreen( state.pendingCleartextUrl?.let { origin -> AlertDialog( onDismissRequest = viewModel::cancelCleartextConnection, - title = { Text("Use unencrypted HTTP?") }, + title = { androidx.compose.material3.Text("Connect without encryption?") }, 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 sign-in. Continue only on a network you trust.", - ) - } + androidx.compose.material3.Text( + "Your password and what you watch will be sent unencrypted to " + + "${ServerBranding.hostLabel(origin)}. Only do this on a network you trust.", + ) }, confirmButton = { - AuroraPrimaryButton( - label = "Use HTTP", + TvMarqueeButton( + text = "Connect", onClick = viewModel::confirmCleartextConnection, - modifier = Modifier.width(180.dp), enabled = !state.isLoading, ) }, dismissButton = { - AuroraGhostButton( - label = "Cancel", + TvMarqueeButton( + text = "Cancel", onClick = viewModel::cancelCleartextConnection, + kind = TvMarqueeButtonKind.Plain, ) - }, + }, containerColor = Color(0xFF1C1C1E), + titleContentColor = MarqueeColors.Ink, + textContentColor = MarqueeColors.InkSecondary, ) } - Box( - modifier = Modifier - .fillMaxSize() - .imePadding(), - ) { - TvAuroraBackdrop(variant = TvAuroraVariant.Server) - - if (isActivePairing) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(Spacing.xl), - contentAlignment = Alignment.Center, - ) { - ActivePairingPanel( + Box(Modifier.fillMaxSize().imePadding()) { + AnimatedContent( + targetState = when { + isActivePairing -> SetupPage.Pairing + enteringAddress -> SetupPage.Address + else -> SetupPage.Phone + }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "tvServerSetupPage", + ) { page -> + when (page) { + SetupPage.Pairing -> ActivePairingPanel( status = pairingStatus, onCancel = pairingReceiver::cancelActiveSession, - onContinue = { - pairingAdvertiser.stop(advertiserToken) - onPairedSignIn() - }, + onContinue = finishPairing, onAllow = pairingReceiver::allowPendingServer, onDeny = pairingReceiver::denyPendingServer, onUseAlternate = pairingReceiver::useAlternateAddress, onRetryAddress = pairingReceiver::retryPushedAddress, - // tvOS renders pairing states directly on the Aurora backdrop - // in an 880pt content column. Shield uses a 2x density, so - // 440dp produces the same 880px maximum footprint. - modifier = Modifier.widthIn(max = 440.dp), ) - } - } else { - Column( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxSize() - .verticalScroll(formScrollState) - .padding(horizontal = 48.dp, vertical = 32.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - BrandHeader() - AuroraJourneyProgress( - currentStep = 1, - modifier = Modifier.width(215.dp), - ) - } - - Spacer(modifier = Modifier.height(Spacing.lg)) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.fillMaxWidth(), - ) { - AuroraEyebrow(text = "Connect") - Text( - text = "Connect this Android TV", - style = TvServerSetupTextStyles.Title, - color = Color.White, - ) - Text( - text = stringResource(R.string.tv_setup_subtitle), - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - textAlign = TextAlign.Center, - ) - } - - // Keep the chooser at a real card height even while the - // platform IME resizes the activity. Without the scrollable, - // top-anchored container, Android TV can squeeze the field's - // editable text line to a few pixels and make typed input - // appear blank. - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = Spacing.lg), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .widthIn(max = 642.dp) - .fillMaxWidth() - // Intrinsic-min, floored — not a bare heightIn and - // not an exact height. Both of those fail, in - // opposite directions: - // - a loose max makes the cards' fillMaxHeight a - // no-op, so the phone card collapses to its pill - // and the weight(1f) beacon box measures zero; - // - an exact height clips the taller card, which - // at 300dp squeezed "Connect to server" down to - // a blank pill (label measured 6px in a 96px - // button). - // Resolving the intrinsic first hands the Row a - // tight height, so fillMaxHeight still resolves, - // while the floor keeps the chooser at a real card - // height when content is short. tvOS pins 580pt; - // here the content decides above that floor. - .height(IntrinsicSize.Min) - .heightIn(min = SERVER_SETUP_CHOOSER_MIN_HEIGHT), - ) { - PhoneSetupCard( - focusRequester = phoneSetupFocus, - modifier = Modifier - .onFocusChanged { phoneCardHasFocus = it.hasFocus } - .weight(1f) - .fillMaxHeight(), - ) - OrDivider( - modifier = Modifier - .width(42.dp) - .fillMaxHeight(), - ) - ManualEntryCard( - state = state, - onServerUrlChanged = viewModel::onServerUrlChanged, - onConnectClick = viewModel::onConnectClick, - focusRequester = focusRequester, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - } - } + SetupPage.Phone -> PhoneFirst(onEnterAddress = { enteringAddress = true }) + SetupPage.Address -> AddressEntry( + state = state, + onServerUrlChanged = viewModel::onServerUrlChanged, + onConnect = viewModel::onConnectClick, + // Leaving mid-probe would let a late connect pull the app on. + onBack = { if (!state.isLoading) enteringAddress = false }, + ) } } } } -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun PhoneSetupCard( - modifier: Modifier = Modifier, - focusRequester: FocusRequester? = null, -) { - // Focusable so FIRST sign-in can land here instead of committing the user - // to the manual URL path — the card itself needs no click action (the TV - // is already advertising; the phone drives the rest). - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .let { m -> focusRequester?.let { m.focusRequester(it) } ?: m } - .onFocusChanged { focused = it.isFocused } - .focusable() - .auroraGlass(16.dp) - .border( - width = 2.dp, - color = if (focused) Color.White.copy(alpha = 0.55f) else Color.Transparent, - shape = RoundedCornerShape(16.dp), - ) - .padding(24.dp), - ) { - // Top-leading pill, matching tvOS TVServerSetupView.phoneCard. - Text( - text = stringResource(R.string.tv_setup_phone_pill), - style = TvServerSetupTextStyles.Pill, - color = Color.White.copy(alpha = 0.70f), - modifier = Modifier - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(50)) - .border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(50)) - .padding(horizontal = 14.dp, vertical = 7.dp), - ) +private enum class SetupPage { Phone, Address, Pairing } - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - PhoneSetupBody() +/** This TV's name, as the person named it in Android TV settings. */ +@Composable +private fun rememberTvName(): String { + val context = LocalContext.current + return remember(context) { + // DEVICE_NAME arrived in API 25; older TVs show the generic name. + val named = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) + } else { + null } + named?.takeIf { it.isNotBlank() } ?: "Android TV" } } +/** + * Lands focus on [requester] for D-pad users once the page is up, and keeps + * asking for a moment: a request made while the previous page is still + * fading out can be dropped. + */ @Composable -private fun PhoneSetupBody(modifier: Modifier = Modifier) { - // Beacon centered, copy left-aligned beneath it — mirrors tvOS - // TVServerSetupView.phoneCard, with its neutral "phone or tablet" copy. - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = modifier.fillMaxWidth(), - ) { - SearchingBeacon( - modifier = Modifier - .size(PHONE_SETUP_BEACON_SIZE) - .align(Alignment.CenterHorizontally), - ) - - Text( - text = stringResource(R.string.tv_setup_phone_looking), - style = TvServerSetupTextStyles.Headline, - color = Color.White, - ) - Text( - text = stringResource(R.string.tv_setup_phone_detail), - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), - maxLines = 4, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), +internal fun ClaimInitialFocus(requester: FocusRequester, isFocused: () -> Boolean, tag: String) { + val inputMode = LocalInputModeManager.current.inputMode + LaunchedEffect(inputMode) { + // Pointer users click what they want, and in touch mode the claim + // could not land anyway. + if (inputMode == InputMode.Touch) return@LaunchedEffect + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = requester::requestFocus, + isFocused = isFocused, ) + TvFocusLog.d { "$tag: claim result=$result" } } } -private val PHONE_SETUP_BEACON_SIZE = 96.dp - -/** - * Floor for the phone/manual chooser. The manual card's intrinsic height - * normally exceeds this; the floor only matters when it doesn't, keeping the - * phone card from collapsing to its pill. - */ -private val SERVER_SETUP_CHOOSER_MIN_HEIGHT = 300.dp +@Composable +private fun PhoneFirst(onEnterAddress: () -> Unit) { + val enterAddressFocus = remember { FocusRequester() } + var enterAddressFocused by remember { mutableStateOf(false) } + ClaimInitialFocus(enterAddressFocus, { enterAddressFocused }, "serverSetup.phone") + TvMarqueeScreen( + accessory = { TvMarqueeStatusChip(rememberTvName(), icon = Icons.Outlined.Tv) }, + copy = { + TvMarqueeStatusChip( + stringResource(R.string.tv_setup_phone_looking), + showsSpinner = true, + modifier = Modifier.padding(bottom = 18.dp), + ) + TvMarqueeHeadline("Set up with\nyour phone") + TvMarqueeBody( + "It's the easiest way. There's nothing to type with the remote.", + size = 16.sp, + modifier = Modifier.padding(top = 14.dp), + ) + Text( + "No phone nearby?", + color = MarqueeColors.InkTertiary, + fontSize = 14.sp, + modifier = Modifier.padding(top = 32.dp), + ) + Row(Modifier.fillMaxWidth().padding(top = 9.dp)) { + TvMarqueeButton( + text = "Enter server address", + onClick = onEnterAddress, + kind = TvMarqueeButtonKind.Glass, + icon = Icons.Outlined.Language, + focusRequester = enterAddressFocus, + modifier = Modifier.onFocusChanged { enterAddressFocused = it.isFocused }, + ) + } + }, + card = { TvMarqueeSetupSteps() }, + ) +} -@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ManualEntryCard( +private fun AddressEntry( state: TvServerSetupUiState, onServerUrlChanged: (String) -> Unit, - onConnectClick: () -> Unit, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, + onConnect: () -> Unit, + onBack: () -> Unit, ) { TvHideStockImeOnDispose() - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier - .auroraGlass(16.dp, emphasized = true) - .padding(24.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Text( - text = "Enter the server address", - style = TvServerSetupTextStyles.Headline, - color = Color.White, - ) - - Text( - text = "SERVER ADDRESS", - style = TvServerSetupTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - - OutlinedTextField( - value = state.serverUrl, - onValueChange = onServerUrlChanged, - placeholder = { - Text( - text = "silo.example.com", - style = TvServerSetupTextStyles.FieldText, - ) - }, - singleLine = true, - textStyle = TvServerSetupTextStyles.FieldText, - keyboardOptions = KeyboardOptions( + BackHandler(onBack = onBack) + val fieldFocus = remember { FocusRequester() } + var fieldFocused by remember { mutableStateOf(false) } + ClaimInitialFocus(fieldFocus, { fieldFocused }, "serverSetup.address") + val connect = { if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) onConnect() } + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + accessory = { TvMarqueeStatusChip(rememberTvName(), icon = Icons.Outlined.Tv) }, + copy = { + TvMarqueeHeadline("Enter your\nserver address") + TvMarqueeBody("Type the address you use for Silo.", modifier = Modifier.padding(top = 11.dp)) + Box(Modifier.width(380.dp).padding(top = 20.dp).tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.serverUrl, + onValueChange = onServerUrlChanged, + icon = Icons.Outlined.Language, + placeholder = "media.example.com", keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onGo = { - if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) { - onConnectClick() - } - }, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(focusRequester), - colors = tvOutlinedTextFieldColors(), - ) - } - - UrlShortcutRow( - enabled = !state.isLoading, - onShortcut = { shortcut -> - onServerUrlChanged(applyServerUrlShortcut(state.serverUrl, shortcut)) - }, - ) - - if (state.error != null) { - Text( - text = state.error, - style = TvServerSetupTextStyles.Error, - color = MaterialTheme.colorScheme.error, - ) - } else if (state.usesCleartext) { + onImeAction = connect, + isError = state.error != null, + // Fixed while the address is probed, so what's shown is + // what's connecting. + enabled = !state.isLoading, + focusRequester = fieldFocus, + onFocusChange = { fieldFocused = it }, + ) + } + state.error?.let { TvMarqueeErrorText(it, Modifier.width(380.dp).padding(top = 8.dp)) } + // Typing aids for the remote's keyboard. + Row(Modifier.fillMaxWidth().padding(top = 11.dp), horizontalArrangement = Arrangement.spacedBy(7.dp)) { + ServerUrlShortcuts.forEach { shortcut -> + TvMarqueeButton( + text = shortcut, + onClick = { if (!state.isLoading) onServerUrlChanged(applyServerUrlShortcut(state.serverUrl, shortcut)) }, + kind = TvMarqueeButtonKind.Glass, + compact = true, + ) + } + } Text( - text = "This server uses unencrypted HTTP. Fine on a trusted home network; " + - "avoid it on public Wi-Fi.", - style = TvServerSetupTextStyles.Error, - color = MaterialTheme.colorScheme.onSurfaceVariant, + if (state.usesCleartext) { + "This address uses unencrypted HTTP. Fine on a trusted home network; avoid it on public Wi-Fi." + } else { + "Secure HTTPS is tried automatically." + }, + color = if (state.usesCleartext) MarqueeColors.Warning else MarqueeColors.InkTertiary, + fontSize = 14.sp, + modifier = Modifier.width(380.dp).padding(top = 9.dp), ) - } else { - // Mirrors tvOS's lock.shield reassurance line. Truthful here too: - // bare hosts probe https:// first and fall to http:// only when - // the viewer typed it (probeTvServerSetupCandidates). - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = null, - tint = Color.White.copy(alpha = 0.72f), - modifier = Modifier.size(14.dp), + Row(Modifier.fillMaxWidth().padding(top = 20.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) { + TvMarqueeButton( + text = if (state.isLoading) "Connecting…" else "Connect", + onClick = connect, + isLoading = state.isLoading, ) - Text( - text = "Secure HTTPS is tried automatically.", - style = TvServerSetupTextStyles.PairingDetail, - color = Color.White.copy(alpha = 0.72f), + TvMarqueeButton( + text = "Back", + onClick = onBack, + kind = TvMarqueeButtonKind.Plain, ) } - } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Connecting…" else "Connect to server", - icon = null, - enabled = canSubmitTvServerUrl(state.serverUrl, state.isLoading), - onClick = { - if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) { - onConnectClick() - } - }, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), - ) - } - } -} - -@Composable -private fun UrlShortcutRow( - enabled: Boolean, - onShortcut: (String) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.fillMaxWidth(), - ) { - ServerUrlShortcuts.forEach { shortcut -> - AuroraGhostButton( - label = shortcut, - onClick = { if (enabled) onShortcut(shortcut) }, - fontSize = 16.sp, - horizontalPadding = 12.dp, - verticalPadding = 6.dp, - ) - } - } + }, + card = { + TvUsePhoneCard("Open Silo on a phone or tablet on the same Wi‑Fi and tap Set up. It finishes this for you.") + }, + ) } private val ServerUrlShortcuts = listOf("https://", "http://", ".com") @@ -598,160 +368,3 @@ private fun applyServerUrlShortcut(value: String, shortcut: String): String = shortcut.endsWith("://") -> shortcut + value.trimStart() else -> value + shortcut } - -@Composable -private fun OrDivider(modifier: Modifier = Modifier) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = modifier, - ) { - // Hairlines fade toward the screen edges, matching tvOS orDivider. - Box( - modifier = Modifier - .width(1.dp) - .weight(1f) - .background( - Brush.verticalGradient( - listOf(Color.Transparent, Color.White.copy(alpha = 0.16f)), - ), - ), - ) - Text( - text = "OR", - style = TvServerSetupTextStyles.Pill, - color = Color.White.copy(alpha = 0.50f), - modifier = Modifier.padding(vertical = 14.dp), - ) - Box( - modifier = Modifier - .width(1.dp) - .weight(1f) - .background( - Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.16f), Color.Transparent), - ), - ), - ) - } -} - -/** - * Pulsing "searching for a phone" beacon — three gold rings expanding outward - * behind a phone glyph. Mirrors tvOS `SearchingBeacon`, scaled by the caller. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SearchingBeacon(modifier: Modifier = Modifier) { - val transition = rememberInfiniteTransition(label = "phoneBeacon") - Box( - contentAlignment = Alignment.Center, - modifier = modifier, - ) { - repeat(3) { index -> - val progress by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 2400, easing = EaseOut), - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(index * 800), - ), - label = "phoneBeaconRing$index", - ) - val ringScale = 0.6f + (1.7f - 0.6f) * progress - val ringAlpha = (1f - progress) * 0.55f - Box( - modifier = Modifier - .size(54.dp) - .graphicsLayer { - scaleX = ringScale - scaleY = ringScale - alpha = ringAlpha - } - .border(2.dp, AuroraAccent, CircleShape), - ) - } - Icon( - imageVector = Icons.Default.Smartphone, - contentDescription = null, - tint = AuroraInk, - modifier = Modifier.size(36.dp), - ) - } -} - -private object TvServerSetupTextStyles { - val Title = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 28.sp, - lineHeight = 34.sp, - letterSpacing = 0.sp, - ) - - val FieldText = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 17.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - color = Color.White, - ) - - /** tvOS continuumHeadline (36pt → 18dp at the 0.5x map, +2 readability). */ - val Headline = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 20.sp, - lineHeight = 26.sp, - letterSpacing = 0.sp, - ) - - val InputLabel = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 3.sp, - ) - - val Pill = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 2.sp, - ) - - val Error = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - - val PairingDetail = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - ) -} - -/** Compact horizontal brand row — replaces the oversized centered logo/title pair. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun BrandHeader(modifier: Modifier = Modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = modifier, - ) { - Image( - painter = painterResource(id = R.drawable.silo_wordmark), - contentDescription = "Silo", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, - ) - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt index b21b6ebd97..8f0a708d4a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvServerSetupViewModel.kt @@ -42,6 +42,9 @@ data class TvServerSetupUiState( sealed class TvServerSetupDestination { data object Setup : TvServerSetupDestination() data class Login(val signupEnabled: Boolean) : TvServerSetupDestination() + + /** A saved server this TV is still signed in to: straight to its profiles. */ + data object Profiles : TvServerSetupDestination() } internal sealed class TvServerSetupProbeResult { @@ -83,6 +86,8 @@ class TvServerSetupViewModel( private val probeContract: suspend (String) -> ApiV2ProbeResult? = { authRepository.probeServerContract(it) }, + /** Whether the now-active server already has a signed-in session. */ + private val hasSession: suspend () -> Boolean = { false }, ) : ViewModel() { private val _uiState = MutableStateFlow(TvServerSetupUiState()) @@ -216,12 +221,19 @@ class TvServerSetupViewModel( contract: ServerContract?, ) { authRepository.setServerUrl(serverUrl, contract) + // A saved server that is still signed in goes straight to its + // profiles; only a server without a session needs sign-in. + val next = if (destination is TvServerSetupDestination.Login && hasSession()) { + TvServerSetupDestination.Profiles + } else { + destination + } _uiState.update { it.copy( serverUrl = serverUrl, isLoading = false, pendingCleartextUrl = null, - navigateTo = destination, + navigateTo = next, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSetupScreen.kt index ca07ab8049..1f2b4e5834 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSetupScreen.kt @@ -1,72 +1,51 @@ package org.siloserver.silo.tv.ui.screens.auth -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.InputMode -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -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.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeBody +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButton +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeErrorText +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeField +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeHeadline +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeScreen +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCard +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeCardSymbol +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import androidx.compose.material.icons.outlined.AdminPanelSettings +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.siloserver.silo.tv.R -import org.siloserver.silo.tv.ui.components.TvAuroraBackdrop -import org.siloserver.silo.tv.ui.components.TvAuroraVariant -import org.siloserver.silo.tv.ui.components.AuroraPrimaryButton import org.siloserver.silo.tv.ui.components.rememberTvImeAwareFormScrollState import org.siloserver.silo.tv.ui.components.tvImeAwareFieldContext -import org.siloserver.silo.tv.ui.components.tvShowImeOnSelect -import org.siloserver.silo.tv.ui.components.TvAuthFormDefaults -import org.siloserver.silo.tv.ui.components.tvOutlinedTextFieldColors -import org.siloserver.silo.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus /** - * First-time server setup — creates the initial admin account on a freshly - * installed Silo server. Mirrors the phone's `SetupScreen` logic via - * [TvSetupViewModel]; on success the user is already signed in (tokens - * persisted by [org.siloserver.silo.repository.AuthRepository.setup]) so the - * flow advances to profile selection. - * - * Layout follows [TvServerSetupScreen]: TOP-anchored + `imePadding` + - * `bringIntoView` so the focused field stays above the soft IME on Android TV. + * First-admin account creation on a server that has none yet, laid out like + * the other TV first-run screens: the form on the left, a card on the right. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvSetupScreen( onSetupComplete: () -> Unit, @@ -74,7 +53,6 @@ fun TvSetupScreen( ) { val state by viewModel.uiState.collectAsState() val usernameFocus = remember { FocusRequester() } - val formScrollState = rememberTvImeAwareFormScrollState() LaunchedEffect(state.setupSuccess) { if (state.setupSuccess) { @@ -83,223 +61,88 @@ fun TvSetupScreen( } } // A text field on a first-run screen: if this claim is dropped the - // remote has nothing to act on and no touch fallback exists. - // Snapshot-backed input mode drives the claim: null contentKey while the - // viewer is in touch mode (a programmatic claim on a text field pops the - // IME; pointer users click the field themselves), and the key change on - // flipping back to key input re-runs the claim so the D-pad always has - // somewhere to land. + // remote has nothing to act on and no touch fallback exists. Null + // contentKey in touch mode (a programmatic claim on a text field pops the + // IME); flipping back to key input re-runs the claim. val inputMode = LocalInputModeManager.current.inputMode val usernameFocusModifier = rememberTvContentInitialFocus( target = usernameFocus, contentKey = if (inputMode == InputMode.Touch) null else inputMode, ) - Box( - modifier = Modifier - .fillMaxSize() - .then(usernameFocusModifier) - .imePadding(), - ) { - TvAuroraBackdrop(variant = TvAuroraVariant.Welcome) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = Modifier - .align(Alignment.TopCenter) - .width(420.dp) - .fillMaxSize() - .verticalScroll(formScrollState) - .padding(top = 24.dp, bottom = Spacing.lg, start = Spacing.xl, end = Spacing.xl), - ) { - BrandHeader() - - Spacer(modifier = Modifier.height(Spacing.xs)) - - Text( - text = "Welcome to Silo", - style = TvAuthFormTextStyles.Title, - color = Color.White, - ) - Text( - text = "Create your first admin account.", - style = TvAuthFormTextStyles.Body, - color = Color.White.copy(alpha = 0.72f), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "USERNAME", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(usernameFocus), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "EMAIL", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "PASSWORD", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChanged, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onDone = { if (!state.isLoading) viewModel.onCreateAccountClick() }, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), + Box(Modifier.fillMaxSize().then(usernameFocusModifier).imePadding()) { + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + copy = { + TvMarqueeHeadline("Set up\nyour server") + TvMarqueeBody( + "This server has no accounts yet. Create the first one; it becomes the admin.", + modifier = Modifier.padding(top = 11.dp), ) - } - - if (state.error != null) { - Text( - text = state.error!!, - style = TvAuthFormTextStyles.Error, - color = MaterialTheme.colorScheme.error, - ) - } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Creating account…" else "Create Account", - icon = Icons.AutoMirrored.Filled.ArrowForward, - enabled = !state.isLoading, + Column( + Modifier.width(380.dp).padding(top = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = "Username", + enabled = !state.isLoading, + focusRequester = usernameFocus, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + icon = Icons.Outlined.Email, + placeholder = "Email", + keyboardType = KeyboardType.Email, + enabled = !state.isLoading, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, + imeAction = ImeAction.Done, + onImeAction = { if (!state.isLoading) viewModel.onCreateAccountClick() }, + isError = state.error != null, + enabled = !state.isLoading, + ) + } + } + state.error?.let { TvMarqueeErrorText(it, Modifier.width(380.dp).padding(top = 8.dp)) } + TvMarqueeButton( + text = if (state.isLoading) "Creating account…" else "Create account", onClick = viewModel::onCreateAccountClick, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), + isLoading = state.isLoading, + enabled = !state.isLoading, + modifier = Modifier.padding(top = 20.dp), ) - } - } - - } -} - -/** Compact horizontal brand row, matching the other auth screens. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun BrandHeader() { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Image( - painter = painterResource(id = R.drawable.silo_wordmark), - contentDescription = "Silo", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, + }, + card = { + TvMarqueeCard { + TvMarqueeCardSymbol(Icons.Outlined.AdminPanelSettings) + Text( + "The first account", + color = MarqueeColors.Ink, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 17.dp), + ) + TvMarqueeBody( + "It runs the server. Add more accounts later from the web admin.", + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 7.dp), + ) + } + }, ) } } - -/** Shared text styles for the setup + signup forms (mirrors TvServerSetup styling). */ -internal object TvAuthFormTextStyles { - val Title = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 17.sp, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - val Body = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 18.sp, - lineHeight = 24.sp, - letterSpacing = 0.sp, - ) - /** Mono uppercase caption above each input — the auth-flow field idiom - * (server setup and sign-in); Material's floating label renders oversized - * in the border notch at TV type scale. */ - val InputLabel = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 19.sp, - letterSpacing = 3.sp, - ) - val FieldText = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 17.sp, - lineHeight = 22.sp, - letterSpacing = 0.sp, - color = Color.White, - ) - val Error = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 16.sp, - letterSpacing = 0.sp, - ) -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSignupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSignupScreen.kt index 1b385db487..9b326592ac 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSignupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvSignupScreen.kt @@ -1,70 +1,49 @@ package org.siloserver.silo.tv.ui.screens.auth -import androidx.compose.foundation.Image +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material.icons.outlined.ConfirmationNumber +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.InputMode -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.res.painterResource 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.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.siloserver.silo.tv.R -import org.siloserver.silo.tv.ui.components.TvAuroraBackdrop -import org.siloserver.silo.tv.ui.components.TvAuroraVariant -import org.siloserver.silo.tv.ui.components.AuroraGhostButton -import org.siloserver.silo.tv.ui.components.AuroraPrimaryButton +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeBody +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButton +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButtonKind +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeErrorText +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeField +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeHeadline +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeScreen +import org.siloserver.silo.tv.ui.components.marquee.TvUsePhoneCard import org.siloserver.silo.tv.ui.components.rememberTvImeAwareFormScrollState import org.siloserver.silo.tv.ui.components.tvImeAwareFieldContext -import org.siloserver.silo.tv.ui.components.tvShowImeOnSelect -import org.siloserver.silo.tv.ui.components.TvAuthFormDefaults -import org.siloserver.silo.tv.ui.components.tvOutlinedTextFieldColors -import org.siloserver.silo.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus /** - * New-user registration with an invite code. Mirrors the phone's `SignupScreen` - * via [TvSignupViewModel]; reachable only when the server reports signup is - * enabled (the login screen surfaces the entry point). On success the user is - * signed in (tokens persisted by [org.siloserver.silo.repository.AuthRepository.signup]) - * so the flow advances to profile selection. - * - * Layout follows [TvServerSetupScreen]/[TvSetupScreen]: TOP-anchored + - * `imePadding` + `bringIntoView` so the focused field stays above the IME. + * Account registration with an invite code, laid out like the other TV + * first-run screens. Back returns to sign-in. */ -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvSignupScreen( onSignupComplete: () -> Unit, @@ -73,7 +52,6 @@ fun TvSignupScreen( ) { val state by viewModel.uiState.collectAsState() val usernameFocus = remember { FocusRequester() } - val formScrollState = rememberTvImeAwareFormScrollState() LaunchedEffect(state.signupSuccess) { if (state.signupSuccess) { @@ -81,223 +59,89 @@ fun TvSignupScreen( onSignupComplete() } } + BackHandler(enabled = state.isLoading) {} // A text field on a first-run screen: if this claim is dropped the - // remote has nothing to act on and no touch fallback exists. - // Snapshot-backed input mode drives the claim: null contentKey while the - // viewer is in touch mode (a programmatic claim on a text field pops the - // IME; pointer users click the field themselves), and the key change on - // flipping back to key input re-runs the claim so the D-pad always has - // somewhere to land. + // remote has nothing to act on and no touch fallback exists. Null + // contentKey in touch mode (a programmatic claim on a text field pops the + // IME); flipping back to key input re-runs the claim. val inputMode = LocalInputModeManager.current.inputMode val usernameFocusModifier = rememberTvContentInitialFocus( target = usernameFocus, contentKey = if (inputMode == InputMode.Touch) null else inputMode, ) - Box( - modifier = Modifier - .fillMaxSize() - .then(usernameFocusModifier) - .imePadding(), - ) { - TvAuroraBackdrop(variant = TvAuroraVariant.SignIn) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), - modifier = Modifier - .align(Alignment.TopCenter) - .width(420.dp) - .fillMaxSize() - .verticalScroll(formScrollState) - .padding(top = 24.dp, bottom = Spacing.lg, start = Spacing.xl, end = Spacing.xl), - ) { - BrandHeader() - - Spacer(modifier = Modifier.height(Spacing.xs)) - - Text( - text = "Create Account", - style = TvAuthFormTextStyles.Title, - color = Color.White, - ) - Text( - text = "Join this Silo server with your invite.", - style = TvAuthFormTextStyles.Body, - color = Color.White.copy(alpha = 0.72f), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "USERNAME", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect() - .focusRequester(usernameFocus), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "EMAIL", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "PASSWORD", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChanged, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - showKeyboardOnFocus = false, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.xs), - modifier = Modifier - .fillMaxWidth() - .tvImeAwareFieldContext(), - ) { - Text( - text = "INVITE CODE", - style = TvAuthFormTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) - OutlinedTextField( - value = state.inviteCode, - onValueChange = viewModel::onInviteCodeChanged, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onDone = { if (!state.isLoading) viewModel.onSignupClick() }, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.FieldHeight) - .tvShowImeOnSelect(), - colors = tvOutlinedTextFieldColors(), - ) - } - - if (state.error != null) { - Text( - text = state.error!!, - style = TvAuthFormTextStyles.Error, - color = MaterialTheme.colorScheme.error, - ) - } - - Box { - AuroraPrimaryButton( - label = if (state.isLoading) "Signing up…" else "Sign Up", - icon = Icons.AutoMirrored.Filled.ArrowForward, - enabled = !state.isLoading, - onClick = viewModel::onSignupClick, - modifier = Modifier - .fillMaxWidth() - .height(TvAuthFormDefaults.PrimaryButtonHeight), - ) - } - AuroraGhostButton( - label = "Sign In Instead", - onClick = onBackToLogin, - fontSize = 18.sp, - horizontalPadding = 18.dp, - verticalPadding = 8.dp, - modifier = Modifier.fillMaxWidth(), - ) - } - - } -} - -/** Compact horizontal brand row, matching the other auth screens. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun BrandHeader() { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Image( - painter = painterResource(id = R.drawable.silo_wordmark), - contentDescription = "Silo", - modifier = Modifier - .width(66.dp) - .height(35.dp), - contentScale = ContentScale.Fit, + Box(Modifier.fillMaxSize().then(usernameFocusModifier).imePadding()) { + TvMarqueeScreen( + copyScroll = rememberTvImeAwareFormScrollState(), + copy = { + TvMarqueeHeadline("Create your\naccount") + TvMarqueeBody("Join this Silo server with your invite.", modifier = Modifier.padding(top = 11.dp)) + Column( + Modifier.width(380.dp).padding(top = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + icon = Icons.Outlined.Person, + placeholder = "Username", + enabled = !state.isLoading, + focusRequester = usernameFocus, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + icon = Icons.Outlined.Email, + placeholder = "Email", + keyboardType = KeyboardType.Email, + enabled = !state.isLoading, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + icon = Icons.Outlined.Lock, + placeholder = "Password", + isSecure = true, + enabled = !state.isLoading, + ) + } + Box(Modifier.tvImeAwareFieldContext()) { + TvMarqueeField( + value = state.inviteCode, + onValueChange = viewModel::onInviteCodeChanged, + icon = Icons.Outlined.ConfirmationNumber, + placeholder = "Invite code", + imeAction = ImeAction.Done, + onImeAction = { if (!state.isLoading) viewModel.onSignupClick() }, + isError = state.error != null, + enabled = !state.isLoading, + ) + } + } + state.error?.let { TvMarqueeErrorText(it, Modifier.width(380.dp).padding(top = 8.dp)) } + Row(Modifier.fillMaxWidth().padding(top = 20.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) { + TvMarqueeButton( + text = if (state.isLoading) "Creating account…" else "Create account", + onClick = viewModel::onSignupClick, + isLoading = state.isLoading, + enabled = !state.isLoading, + ) + TvMarqueeButton( + text = "Sign in instead", + onClick = onBackToLogin, + kind = TvMarqueeButtonKind.Plain, + enabled = !state.isLoading, + ) + } + }, + card = { + TvUsePhoneCard("Already have an account? Open Silo on a phone or tablet on the same Wi‑Fi and it can sign this TV in for you.") + }, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt index 1c1daf8880..9b4cb3c250 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt @@ -1,113 +1,101 @@ package org.siloserver.silo.tv.ui.screens.profiles -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChildCare +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Logout -import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner 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.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.siloserver.silo.common.ui.components.ThumbhashImage -import org.siloserver.silo.common.ui.components.avatarRef -import org.siloserver.silo.common.ui.components.isEmojiAvatar -import org.siloserver.silo.common.ui.components.profileAvatarDisplayText -import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.common.ui.marquee.MarqueeProfileAvatar +import org.siloserver.silo.common.ui.marquee.MarqueeScene +import org.siloserver.silo.common.ui.marquee.MarqueeScrim +import org.siloserver.silo.common.ui.marquee.MarqueeScrimStyle +import org.siloserver.silo.common.ui.marquee.ProfileTilePalette +import org.siloserver.silo.common.ui.marquee.rememberReduceMotion import org.siloserver.silo.model.profile.Profile -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.runtime.withFrameNanos -import androidx.compose.runtime.setValue -import androidx.compose.runtime.mutableStateOf -import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult -import org.siloserver.silo.tv.ui.focus.TvFocusTargetState -import org.siloserver.silo.tv.ui.focus.tvProfileFocusTarget -import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.focus.TvFrameRelocationMaxAttempts -import org.siloserver.silo.tv.ui.components.TvAuroraBackdrop -import org.siloserver.silo.tv.ui.components.AuroraJourneyProgress -import org.siloserver.silo.tv.ui.components.TvAuroraVariant import org.siloserver.silo.tv.ui.components.TvDialogOption import org.siloserver.silo.tv.ui.components.TvErrorScreen -import org.siloserver.silo.tv.ui.components.TvHeroActionPill import org.siloserver.silo.tv.ui.components.TvLoadingScreen import org.siloserver.silo.tv.ui.components.TvOptionDialog -import org.siloserver.silo.tv.ui.components.TvPillVariant import org.siloserver.silo.tv.ui.components.TvPinEntryDialog -import org.siloserver.silo.tv.ui.theme.Spacing -import org.siloserver.silo.tv.ui.theme.navRailLabel -import org.siloserver.silo.tv.ui.theme.siloCardDefaults -import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButton +import org.siloserver.silo.tv.ui.components.marquee.TvMarqueeButtonKind +import org.siloserver.silo.tv.ui.components.marquee.rememberTvActiveServer +import org.siloserver.silo.tv.ui.focus.TvFocusTargetState +import org.siloserver.silo.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.focus.tvProfileFocusTarget -private const val ProfileGridColumns = 4 -private val ProfileTileSize = 140.dp -private val ProfileTileCornerRadius = 18.dp -private val AddProfilePlusSize = 44.dp -private val AddProfilePlusStrokeWidth = 2.dp -private val ProfileUtilityChromeTop = 40.dp -private val ProfileUtilityChromeEnd = 64.dp -// The utility pills size to their own labels. Fixed widths clipped them: -// "Sign Out" rendered as "Sign" at 100dp, and the label a control shows is the -// one thing about it that cannot be allowed to be wrong. -private val ProfileUtilityChipHeight = 28.dp -private val ProfileHeaderTop = 92.dp -private val ProfileGridWidth = 772.dp -private val ProfileGridGap = 70.dp +/** Six avatars and their gaps fit the 870dp safe width. */ +private const val ProfileGridColumns = 6 +private val ProfileAvatarSize = 110.dp +private val ProfileColumnWidth = 128.dp +private val ProfileColumnGap = 14.dp +/** + * "Who's watching?" on Android TV (silo-apple `ProfileSelectionView` on + * tvOS): round avatars centered over the brand light, "Signed in as", and + * the utilities (change server, sign out, and for admins, manage) small and + * below the people. A PIN prompt opens full screen over the same light, + * tinted with that person's color. + */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvProfileSelectionScreen( @@ -119,6 +107,7 @@ fun TvProfileSelectionScreen( viewModel: TvProfileSelectionViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + rememberTvActiveServer() // Reload on resume so a profile created/edited on a pushed screen is // reflected when we return (the VM otherwise only loads in init). @@ -131,6 +120,34 @@ fun TvProfileSelectionScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + // One requester per profile ID; see the anchoring effect below. + val tileFocusRequesters = remember { mutableMapOf() } + var focusedProfileId by remember { mutableStateOf(null) } + // Closing a PIN prompt puts focus back on the profile that opened it, + // not the first one. + var pinOpenedBy by remember { mutableStateOf(null) } + LaunchedEffect(state.pinProfile?.id) { + val opened = state.pinProfile?.id + if (opened != null) { + pinOpenedBy = opened + return@LaunchedEffect + } + val returnTo = pinOpenedBy ?: return@LaunchedEffect + pinOpenedBy = null + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = { tileFocusRequesters[returnTo]?.requestFocus() ?: false }, + isFocused = { focusedProfileId == returnTo }, + ) + } + + LaunchedEffect(Unit) { MarqueeScene.focus = MarqueeScene.Focus.Profiles } + LaunchedEffect(state.pinProfile?.id) { + MarqueeScene.personalTint = state.pinProfile?.id?.let(ProfileTilePalette::tint) + } + DisposableEffect(Unit) { onDispose { MarqueeScene.personalTint = null } } + LaunchedEffect(state.selectedProfileId) { if (state.selectedProfileId != null) { viewModel.onSelectionConsumed() @@ -145,85 +162,51 @@ fun TvProfileSelectionScreen( // stale-while-revalidate — keep the grid up during a refresh, like // the error branch already does. state.isLoading && state.profiles.isEmpty() -> TvLoadingScreen() + // A one-profile household opening straight after sign-in: nothing to + // pick, and nothing hidden left for the remote to press. + state.openingOnlyProfile -> MarqueeScrim(MarqueeScrimStyle.Ambient) state.error != null && state.profiles.isEmpty() -> TvErrorScreen( message = state.error!!, onRetry = viewModel::loadProfiles, ) else -> { - Box(modifier = Modifier.fillMaxSize()) { - TvAuroraBackdrop(variant = TvAuroraVariant.Profile) - - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = ProfileUtilityChromeTop, end = ProfileUtilityChromeEnd), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (state.canManageProfiles) { - TvHeroActionPill( - label = if (state.isManageMode) "Done" else "Manage", - icon = Icons.Filled.Edit, - variant = TvPillVariant.Hollow, - heightOverride = ProfileUtilityChipHeight, - horizontalPaddingOverride = 10.dp, - labelStyle = MaterialTheme.typography.labelMedium, - onClick = viewModel::toggleManageMode, - ) - } - TvHeroActionPill( - label = "Change Server", - icon = Icons.Filled.Dns, - variant = TvPillVariant.Hollow, - heightOverride = ProfileUtilityChipHeight, - horizontalPaddingOverride = 10.dp, - labelStyle = MaterialTheme.typography.labelMedium, - onClick = onChangeServer, - ) - TvHeroActionPill( - label = "Sign Out", - icon = Icons.Filled.Logout, - variant = TvPillVariant.Hollow, - heightOverride = ProfileUtilityChipHeight, - horizontalPaddingOverride = 10.dp, - labelStyle = MaterialTheme.typography.labelMedium, - onClick = onSignOut, - ) - } - + Box( + modifier = Modifier + .fillMaxSize() + // Hidden behind the PIN prompt. + .alpha(if (state.pinProfile != null) 0f else 1f), + ) { + MarqueeScrim(MarqueeScrimStyle.Ambient) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter) - // No journey row here any more, so the title band sits - // at its own top inset rather than offsetting one. - .padding( - top = ProfileHeaderTop, - start = Spacing.safeArea, - end = Spacing.safeArea, - ), + .fillMaxSize() + .padding(vertical = 30.dp), ) { + Spacer(Modifier.weight(1f)) Text( text = "Who's watching?", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, + fontSize = 32.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.8).sp, + color = MarqueeColors.Ink, + maxLines = 1, ) - Spacer(modifier = Modifier.height(6.dp)) + // Keeps its height while the account loads so the picker doesn't shift. Text( - text = "Select your profile", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f), + text = state.accountName?.let { "Signed in as $it" } ?: " ", + fontSize = 14.sp, + color = MarqueeColors.InkSecondary, + maxLines = 1, + modifier = Modifier.padding(top = 7.dp), ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(36.dp)) // The screen reloads on every resume, so re-anchoring on // the first tile overrode the viewer's position on every // return. Keep a requester per profile ID and move focus // only where tvProfileFocusTarget says it belongs. - val tileFocusRequesters = remember { mutableMapOf() } - var focusedProfileId by remember { mutableStateOf(null) } var previousProfileIds by remember { mutableStateOf(emptyList()) } var hasAnchored by remember { mutableStateOf(false) } val profileIds = state.profiles.map { it.id } @@ -281,11 +264,41 @@ fun TvProfileSelectionScreen( ) if (state.error != null) { - Spacer(modifier = Modifier.height(24.dp)) Text( text = state.error!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, + fontSize = 14.sp, + color = MarqueeColors.Error, + modifier = Modifier.padding(top = 18.dp), + ) + } + Spacer(Modifier.weight(1f)) + // Full width, so Down from any avatar column finds the row. + Row( + modifier = Modifier.fillMaxWidth().focusGroup(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + ) { + if (state.canManageProfiles) { + TvMarqueeButton( + text = if (state.isManageMode) "Done" else "Manage", + onClick = viewModel::toggleManageMode, + kind = TvMarqueeButtonKind.Glass, + compact = true, + icon = if (state.isManageMode) null else Icons.Outlined.ManageAccounts, + ) + } + TvMarqueeButton( + text = "Change server", + onClick = onChangeServer, + kind = TvMarqueeButtonKind.Glass, + compact = true, + icon = Icons.Outlined.Dns, + ) + TvMarqueeButton( + text = "Sign out", + onClick = onSignOut, + kind = TvMarqueeButtonKind.Glass, + compact = true, + icon = Icons.AutoMirrored.Outlined.Logout, ) } } @@ -293,12 +306,11 @@ fun TvProfileSelectionScreen( } } - // PIN entry dialog — shown when a PIN-protected profile is selected. + // PIN entry — shown when a PIN-protected profile is selected. val pinProfile = state.pinProfile if (pinProfile != null) { TvPinEntryDialog( - profileName = pinProfile.name, - profileAvatar = pinProfile.avatarRef(), + profile = pinProfile, errorMessage = state.pinError, isVerifying = state.isVerifyingPin, onPinEntered = viewModel::onPinEntered, @@ -347,28 +359,25 @@ private fun ProfileTileGrid( val rowCount = (itemCount + ProfileGridColumns - 1) / ProfileGridColumns Column( modifier = Modifier - .width(ProfileGridWidth) + .focusGroup() .onFocusChanged { if (!it.hasFocus) onProfileFocused(null) }, - verticalArrangement = Arrangement.spacedBy(56.dp), + verticalArrangement = Arrangement.spacedBy(30.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { repeat(rowCount) { rowIndex -> val firstItemIndex = rowIndex * ProfileGridColumns val itemsInRow = minOf(ProfileGridColumns, itemCount - firstItemIndex) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - ProfileGridGap, - Alignment.CenterHorizontally, - ), + horizontalArrangement = Arrangement.spacedBy(ProfileColumnGap), verticalAlignment = Alignment.Top, ) { repeat(itemsInRow) { columnIndex -> val itemIndex = firstItemIndex + columnIndex - Box(modifier = Modifier.width(ProfileTileSize), contentAlignment = Alignment.TopCenter) { + Box(modifier = Modifier.width(ProfileColumnWidth), contentAlignment = Alignment.TopCenter) { when { itemIndex < profiles.size -> { val profile = profiles[itemIndex] - TvProfileCard( + TvProfileTile( profile = profile, manageMode = isManageMode, onClick = { @@ -386,7 +395,7 @@ private fun ProfileTileGrid( }, ) } - itemIndex == profiles.size && canAddProfile -> TvAddProfileCard( + itemIndex == profiles.size && canAddProfile -> TvAddProfileTile( onClick = onAddProfile, modifier = Modifier.onFocusChanged { if (it.isFocused) onProfileFocused(null) @@ -400,146 +409,84 @@ private fun ProfileTileGrid( } } +/** A white ring around a focused avatar, drawn outside it. */ +private fun Modifier.focusRing(focused: Boolean): Modifier = drawBehind { + if (focused) { + drawCircle( + color = MarqueeColors.Ink, + radius = size.minDimension / 2f + 6.dp.toPx(), + center = Offset(size.width / 2f, size.height / 2f), + style = Stroke(width = 3.dp.toPx()), + ) + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvProfileCard( +private fun TvProfileTile( profile: Profile, manageMode: Boolean, onClick: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, ) { - val avatar = profile.avatarRef() - val avatarText = remember(avatar, profile.name) { - profileAvatarDisplayText(avatar, profile.name) - } - val avatarImage = rememberProfileAvatarImage(avatar) - - val shape = RoundedCornerShape(ProfileTileCornerRadius) - val cardFocus = siloCardDefaults(shape = shape) - val tileTint = profile.tintColor() - // Per-profile tinted focus halo ("this profile is alive"), mirroring tvOS - // ProfileTile's colored glow. - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val haloElevation by animateDpAsState( - targetValue = if (isFocused) 20.dp else 0.dp, - label = "profileTileHalo", - ) + var focused by remember { mutableStateOf(false) } + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.12f else 1f, label = "tvProfileTileScale") Column(horizontalAlignment = Alignment.CenterHorizontally) { - Card( - onClick = onClick, - interactionSource = interactionSource, - shape = CardDefaults.shape(shape = shape), - scale = cardFocus.scale, - border = cardFocus.border, - glow = cardFocus.glow, + Box( modifier = modifier - .size(ProfileTileSize) - .shadow( - elevation = haloElevation, - shape = shape, - clip = false, - ambientColor = tileTint, - spotColor = tileTint, - ), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(tileTint) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.14f), - shape = shape, - ), - contentAlignment = Alignment.Center, - ) { - if (avatarImage != null) { - ThumbhashImage( - url = avatarImage.url, - thumbhash = null, - contentDescription = profile.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - transparent = true, - cacheKey = avatarImage.cacheKey, - onError = avatarImage.onLoadFailed, - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0f to Color.Transparent, - 1f to Color.Black.copy(alpha = 0.22f), - ), - ), - ) - } else { - Text( - text = avatarText, - fontSize = if (isEmojiAvatar(avatar)) { - 70.sp - } else { - 60.sp - }, - fontWeight = FontWeight.Bold, - color = Color.White.copy(alpha = 0.94f), - ) + .onFocusChanged { focused = it.isFocused } + .clickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .semantics { + contentDescription = profile.name + stateDescription = listOfNotNull( + "PIN protected".takeIf { profile.hasPin }, + "Kids profile".takeIf { profile.isChild }, + "Edit".takeIf { manageMode }, + ).joinToString(", ") } - - // Primary (crown) + child (leaf) + PIN (lock) indicator badges, - // top-trailing — tvOS ProfileTile surfaces these so a household - // reads at a glance. - if (profile.isPrimary || profile.isChild || profile.hasPin) { - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (profile.isPrimary) ProfileBadge(Icons.Filled.WorkspacePremium, "Primary profile") - if (profile.isChild) ProfileBadge(Icons.Filled.ChildCare, "Child profile") - if (profile.hasPin) ProfileBadge(Icons.Filled.Lock, "PIN protected") - } - } - - if (manageMode) { - // Pencil badge signals the card edits instead of selects. - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - .size(26.dp) - .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(13.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = "Edit", - tint = Color.White, - modifier = Modifier.size(18.dp), - ) - } + .graphicsLayer { scaleX = scale; scaleY = scale } + .shadow(if (focused) 18.dp else 0.dp, CircleShape, clip = false) + .focusRing(focused), + ) { + MarqueeProfileAvatar( + profile, + size = ProfileAvatarSize, + baseSize = 92.dp, + modifier = Modifier.alpha(if (manageMode) 0.6f else 1f), + ) + if (manageMode) { + // Pencil: the tile edits instead of selects. + Box(Modifier.size(ProfileAvatarSize), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Edit, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp)) } } } - Spacer(modifier = Modifier.height(12.dp)) Text( text = profile.name, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.88f), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = if (focused) MarqueeColors.Ink else MarqueeColors.InkSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = if (focused) 22.dp else 13.dp), ) // The server never deletes the primary profile (409 // primary_profile_protected), so don't offer it. if (manageMode && !profile.isPrimary) { - Spacer(modifier = Modifier.height(8.dp)) - TvHeroActionPill( - label = "Delete", - icon = Icons.Filled.Delete, - variant = TvPillVariant.Ghost, + TvMarqueeButton( + text = "Delete", onClick = onDelete, + kind = TvMarqueeButtonKind.Plain, + compact = true, + icon = Icons.Filled.Delete, + modifier = Modifier.padding(top = 4.dp), ) } } @@ -547,129 +494,48 @@ private fun TvProfileCard( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvAddProfileCard(onClick: () -> Unit, modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(ProfileTileCornerRadius) - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val surfaceAlpha = if (isFocused) 0.14f else 0.06f - val strokeAlpha = if (isFocused) 0.70f else 0.28f - val plusAlpha = if (isFocused) 1.0f else 0.60f - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - contentColor = Color.White, - focusedContainerColor = Color.Transparent, - focusedContentColor = Color.White, - pressedContainerColor = Color.Transparent, - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f), - border = ClickableSurfaceDefaults.border( - border = Border( - border = BorderStroke(0.dp, Color.Transparent), - shape = shape, - ), - focusedBorder = Border( - border = BorderStroke(0.dp, Color.Transparent), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.Black.copy(alpha = 0.50f), - elevation = 22.dp, - ), - ), - modifier = Modifier.size(ProfileTileSize), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.White.copy(alpha = surfaceAlpha), shape) - .border( - width = if (isFocused) 4.dp else 0.dp, - color = if (isFocused) Color.White else Color.Transparent, - shape = RoundedCornerShape(ProfileTileCornerRadius + 4.dp), - ), - contentAlignment = Alignment.Center, - ) { - val dashColor = Color.White.copy(alpha = strokeAlpha) - Canvas(modifier = Modifier.fillMaxSize()) { - drawRoundRect( - color = dashColor, - cornerRadius = CornerRadius( - ProfileTileCornerRadius.toPx(), - ProfileTileCornerRadius.toPx(), - ), - style = Stroke( - width = 2.dp.toPx(), - pathEffect = PathEffect.dashPathEffect( - floatArrayOf(8.dp.toPx(), 6.dp.toPx()), - ), - ), - ) - } - Canvas(modifier = Modifier.size(AddProfilePlusSize)) { - val color = Color.White.copy(alpha = plusAlpha) - val stroke = AddProfilePlusStrokeWidth.toPx() - val center = size.width / 2f - drawLine( - color = color, - start = Offset(center, 0f), - end = Offset(center, size.height), - strokeWidth = stroke, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(0f, center), - end = Offset(size.width, center), - strokeWidth = stroke, - cap = StrokeCap.Round, +private fun TvAddProfileTile(onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val reduceMotion = rememberReduceMotion() + val scale by animateFloatAsState(if (focused && !reduceMotion) 1.12f else 1f, label = "tvAddTileScale") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = modifier + .onFocusChanged { focused = it.isFocused } + .clickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .semantics { contentDescription = "Add profile" } + .graphicsLayer { scaleX = scale; scaleY = scale } + .focusRing(focused) + .size(ProfileAvatarSize) + .background(Color.White.copy(alpha = if (focused) 0.14f else 0f), CircleShape) + .drawBehind { + val stroke = 2.dp.toPx() + drawCircle( + color = Color.White.copy(alpha = if (focused) 0.7f else 0.25f), + radius = size.minDimension / 2f - stroke / 2f, + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 5.dp.toPx()))), ) - } - } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + tint = MarqueeColors.Ink.copy(alpha = if (focused) 1f else 0.4f), + modifier = Modifier.size(33.dp), + ) } - Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Add Profile", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.55f), - ) - } -} - -@Composable -private fun ProfileBadge(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String) { - Box( - modifier = Modifier - .size(34.dp) - .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(17.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = description, - tint = Color.White, - modifier = Modifier.size(18.dp), + text = "Add profile", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = MarqueeColors.Ink.copy(alpha = if (focused) 1f else 0.62f), + modifier = Modifier.padding(top = if (focused) 22.dp else 13.dp), ) } } - -private fun Profile.tintColor(): Color { - val palette = listOf( - Color(0xFFB56F5F), - Color(0xFF6B7C93), - Color(0xFF8A785E), - Color(0xFF607C6A), - Color(0xFF92656E), - Color(0xFF6F668B), - Color(0xFF577B83), - Color(0xFFA28453), - ) - return palette[kotlin.math.abs(id.hashCode()) % palette.size] -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt index 9dd16f0f5e..67c92fba0d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.siloserver.silo.common.ui.marquee.SignInHandoff data class TvProfileSelectionUiState( val profiles: List = emptyList(), @@ -31,6 +32,10 @@ data class TvProfileSelectionUiState( // Profile pending delete confirmation (manage mode). val deleteCandidate: Profile? = null, val isDeleting: Boolean = false, + /** The signed-in account's username, shown under the title; null until read. */ + val accountName: String? = null, + /** A one-profile household is being opened straight after sign-in; the picker stays hidden. */ + val openingOnlyProfile: Boolean = false, ) /** @@ -41,6 +46,7 @@ data class TvProfileSelectionUiState( class TvProfileSelectionViewModel( private val profileRepository: ProfileRepository, private val authRepository: AuthRepository? = null, + private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, ) : ViewModel() { private val _uiState = MutableStateFlow(TvProfileSelectionUiState()) @@ -52,6 +58,12 @@ class TvProfileSelectionViewModel( /** Monotonic generation for profile-list loads; see [loadProfiles]. */ private var loadAttempt: Int = 0 + /** + * Read once, on the first load: a sign-in that just finished asked to + * skip a one-person picker (see [SignInHandoff]). + */ + private var skipsSingleProfile: Boolean? = null + init { loadProfiles() reloadWhenUpdateRequiredLifts() @@ -95,7 +107,9 @@ class TvProfileSelectionViewModel( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } val scope = profileRepository.captureIdentityScope() - val isAdmin = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data?.role?.equals("admin", ignoreCase = true) == true + val skipSingle = skipsSingleProfile ?: consumeSkipsSingleProfile().also { skipsSingleProfile = it } + val user = (authRepository?.getCurrentUser() as? ApiResult.Success)?.data + val isAdmin = user?.role?.equals("admin", ignoreCase = true) == true val listed = profileRepository.listProfiles() // Two separate reasons to drop this response: a newer load // superseded it, or the identity it was fetched under is gone. @@ -121,15 +135,22 @@ class TvProfileSelectionViewModel( // failed reload under a NEW identity left the OLD grid // qualified by the NEW scope. gridScope = scope + // A household with one profile and no PIN doesn't need a + // picker after signing in: open it, once. + val only = result.data.singleOrNull()?.takeIf { skipSingle && !it.hasPin } + skipsSingleProfile = false _uiState.update { it.copy( isLoading = false, + openingOnlyProfile = only != null, + accountName = user?.username ?: it.accountName, profiles = result.data, canManageProfiles = isAdmin, isManageMode = if (isAdmin) it.isManageMode else false, deleteCandidate = if (isAdmin) it.deleteCandidate else null, ) } + if (only != null) onProfileSelected(only) } is ApiResult.Error -> { _uiState.update { @@ -226,7 +247,7 @@ class TvProfileSelectionViewModel( commitSelection(profile, token, scope) _uiState.update { it.copy(pinProfile = null, isVerifyingPin = false) } } else { - _uiState.update { it.copy(isVerifyingPin = false, pinError = "Incorrect PIN") } + _uiState.update { it.copy(isVerifyingPin = false, pinError = "Wrong PIN. Try again.") } } } is ApiResult.Error -> _uiState.update { @@ -252,6 +273,7 @@ class TvProfileSelectionViewModel( ) { viewModelScope.launch { val result = profileRepository.selectProfile(profile.id, profileToken, expectedScope) + if (result != ProfileCommitResult.Committed) _uiState.update { it.copy(openingOnlyProfile = false) } if (result == ProfileCommitResult.ScopeChanged) { // Identity moved under us — don't route into this profile, and // drop the grid with it. A retained grid keeps D-pad focus on diff --git a/androidTvApp/src/androidMain/res/values/strings.xml b/androidTvApp/src/androidMain/res/values/strings.xml index c6e04608c0..1c930322c0 100644 --- a/androidTvApp/src/androidMain/res/values/strings.xml +++ b/androidTvApp/src/androidMain/res/values/strings.xml @@ -76,8 +76,8 @@ Sign in with a password - USERNAME - PASSWORD + Username + Password Sign in Signing in… Create Account diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt index c2631870ac..bc98e160be 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt @@ -39,8 +39,8 @@ class TvControlWiringCallSiteTest { wiring = "enabled = enabled", ), StructuralControl( - path = "ui/components/TvAuroraChrome.kt", - composable = "AuroraPrimaryButton", + path = "ui/components/marquee/TvMarquee.kt", + composable = "TvMarqueeButton", primitive = ".clickable(", wiring = "enabled = enabled", ), @@ -169,7 +169,7 @@ class TvControlWiringCallSiteTest { fun noControlFakesDisabledStateInsideItsClickHandler() { listOf( "ui/components/TvOptionDialog.kt", - "ui/components/TvAuroraChrome.kt", + "ui/components/marquee/TvMarquee.kt", "ui/components/TvPinEntryDialog.kt", "ui/screens/watchtogether/TvJoinCodeDialog.kt", ).forEach { path -> diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt index 6f01e1ea6f..5381a526db 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/auth/TvLoginScreenNetworkSignInTest.kt @@ -76,14 +76,14 @@ class TvLoginScreenNetworkSignInTest { @Test fun thePasswordFormOffersContinueAsAndFocusesIt() { show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true)) - composeRule.onNodeWithText("USERNAME").assertIsDisplayed() + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(2) composeRule.onNodeWithText(CONTINUE_AS).assertIsDisplayed().assertIsFocused() } @Test fun aServerWithOnlyNetworkSignInStillOffersContinueAs() { show(SignInProviders(listOf(TAILSCALE), passwordLogin = false)) - composeRule.onNodeWithText("USERNAME").assertDoesNotExist() + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(0) composeRule.onNodeWithText(CONTINUE_AS).assertIsDisplayed().assertIsFocused() } @@ -92,11 +92,11 @@ class TvLoginScreenNetworkSignInTest { fun focusFollowsContinueAsIntoTheForm() { val deviceCheck = CompletableDeferred() show(SignInProviders(listOf(LOCAL, TAILSCALE), passwordLogin = true), deviceCheck) - composeRule.onNodeWithText("USERNAME").assertDoesNotExist() + composeRule.onAllNodes(TEXT_FIELD).assertCountEquals(0) composeRule.onNodeWithText(CONTINUE_AS).assertIsFocused() deviceCheck.complete(Unit) - composeRule.waitUntil(5_000) { composeRule.onAllNodesWithText("USERNAME").fetchSemanticsNodes().isNotEmpty() } + composeRule.waitUntil(5_000) { composeRule.onAllNodes(TEXT_FIELD).fetchSemanticsNodes().isNotEmpty() } composeRule.waitForIdle() composeRule.onNodeWithText(CONTINUE_AS).assertIsFocused() } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt index 861ae8eeab..42ab90a343 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt @@ -21,6 +21,10 @@ class TvTypographyReadabilityTest { "org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt|fontSize = 11.5.sp,", "org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt|fontSize = 11.5.sp,", "org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt|fontSize = 11.5.sp,", + // First-run chrome at tvOS half scale: the SECURE/HTTP badge on the + // server card, and the label inside the "Tap Set up" step picture. + "org/siloserver/silo/tv/ui/components/marquee/TvMarquee.kt|fontSize = 9.sp,", + "org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt|Text(\"Set up\", color = Color.Black, fontSize = 7.5.sp, fontWeight = FontWeight.Bold)", ) @Test diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/BrandingApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/BrandingApi.kt index f1e9f0ebd9..e899f0c2d8 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/BrandingApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/BrandingApi.kt @@ -14,6 +14,17 @@ import org.siloserver.silo.network.apiv2.safeApiV2Call data class BrandingStatus( @SerialName("server_name") val serverName: String? = null, + /** White-label line under the sign-in title. */ + @SerialName("login_subtitle") + val loginSubtitle: String? = null, + /** `#RRGGBB` the first-run backdrop tints toward. */ + @SerialName("accent_color") + val accentColor: String? = null, + /** Site-relative paths; resolve them against the server before use. */ + @SerialName("mark_url") + val markUrl: String? = null, + @SerialName("wordmark_url") + val wordmarkUrl: String? = null, ) open class BrandingApi(private val client: HttpClient) { @@ -22,6 +33,13 @@ open class BrandingApi(private val client: HttpClient) { return safeApiV2Call(ApiV2Gate.Unrestricted) { request("/api/v2/theme/branding") } } + /** The same document from the server at [serverUrl], whether or not it is the active one. */ + open suspend fun getBranding(serverUrl: String): ApiResult { + return safeApiV2Call(ApiV2Gate.Unrestricted) { + request("${serverUrl.trimEnd('/')}/api/v2/theme/branding") + } + } + private suspend fun request(path: String) = client.get(path) { // Public identity probe, like checkHealth() which it replaced as // the primary source of a server's display name. Without this it From 7ddec0401dd2ac240d0eefbd324280fb61b33b01 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:13:49 -0400 Subject: [PATCH 18/87] fix(downloads): refresh saved subtitles the server retimed (#444) * fix(downloads): refresh saved subtitles the server retimed A video download saves its subtitle sidecars once, when it completes. When the server later syncs or resets a stored subtitle's timing, the download's manifest lists that subtitle at a new revision, but the saved copy kept the old timing, so offline playback stayed out of sync. Saved sidecars now record the manifest fetch_url and revision. A periodic worker (every 12 hours with a network) re-reads the manifest of each completed download of the active login and profile, re-fetches only the sidecars whose revision changed, replaces each file in place, and records the new revision. Captures saved before this change carry no fetch_url and are left as they are. Refs #442 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): publish refreshed subtitles only while the download is unchanged The refresh worker wrote each re-fetched sidecar over the saved file before it checked that the download was still the one it read. A download replaced or deleted meanwhile could have a fresh capture's subtitle overwritten with the old download's bytes, leaving its recorded revision wrong. Stage the re-fetched sidecars beside the saved files instead. The worker publishes them inside its generation and ownership check, and only over a saved copy that is unchanged since staging; whatever is not published is deleted. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): hold one lock per download while subtitles are captured, refreshed, or deleted A subtitle refresh checked the download's row and its saved file, then replaced the file and rewrote the row in later steps. A track capture for the same download could write new sidecars in between, and the refresh would move the old subtitle's bytes over them. A delete landing between the refresh's read and write let the refresh write the deleted row back. The capture, the refresh, and both delete paths now hold a process-wide lock per (server, profile, file) across their check and writes. Each takes it before the identity barrier or a database transaction. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): record refreshed subtitle revisions only while the row is unchanged The subtitle refresh checked the download's row and wrote its new revisions in separate statements. Re-downloading the same file writes the replacement row without the identity barrier or the slot lock, so a replacement landing between them was overwritten with the old download. The check, the file publish, and the row write now run in one Room transaction (DownloadMetadataStore.updateSidecarIf), like completePendingDeletion. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../common/downloads/DownloadMetadataStore.kt | 20 +++ .../common/downloads/DownloadSlotLocks.kt | 24 +++ .../silo/common/downloads/DownloadWorker.kt | 67 +++---- .../downloads/OfflineSubtitleRefreshWorker.kt | 123 +++++++++++++ .../downloads/OfflineTrackAssetFetcher.kt | 167 ++++++++++++++++-- .../downloads/DownloadMetadataStoreTest.kt | 19 ++ .../common/downloads/DownloadSlotLocksTest.kt | 26 +++ .../downloads/OfflineTrackAssetFetcherTest.kt | 65 +++++++ .../silo/android/SiloApplication.kt | 7 + .../silo/android/di/AndroidModule.kt | 12 ++ .../android/downloads/AppWorkerFactory.kt | 14 ++ .../screens/downloads/DownloadsViewModel.kt | 22 ++- .../silo/model/download/OfflineTracks.kt | 27 +++ .../silo/model/download/OfflineTracksTest.kt | 27 +++ 14 files changed, 564 insertions(+), 56 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocks.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocksTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt index fe8bbd4b6b..dddf3035ff 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStore.kt @@ -30,6 +30,26 @@ class DownloadMetadataStore(private val db: SiloDatabase) { downloadDao.delete(serverId, profileId, fileId) } + /** + * Writes what [update] makes of the slot's row, in one transaction, only + * while the row still [matches]. A row replaced or deleted after the caller + * last read it is left as it is. [update] may return null to write nothing. + */ + suspend fun updateSidecarIf( + serverId: String, + profileId: String, + fileId: Int, + matches: (DownloadSidecar) -> Boolean, + update: (DownloadSidecar) -> DownloadSidecar?, + ): Boolean = db.withTransaction { + val current = downloadDao.get(serverId, profileId, fileId)?.toSidecar() + ?.takeIf(matches) + ?: return@withTransaction false + val updated = update(current) ?: return@withTransaction false + downloadDao.upsert(updated.toEntity(serverId, profileId)) + true + } + /** A file slot may now contain a replacement download; an old tombstone cannot own it. */ suspend fun completePendingDeletion( serverId: String, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocks.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocks.kt new file mode 100644 index 0000000000..efd9db8a0f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocks.kt @@ -0,0 +1,24 @@ +package org.siloserver.silo.common.downloads + +import kotlinx.coroutines.sync.Mutex +import java.util.concurrent.ConcurrentHashMap + +/** + * One lock per download slot (server, profile, file), shared by the whole + * process. The track capture, the subtitle refresh, and deleting a download + * each check the slot's metadata row and then change its subtitle files and + * row in separate steps. Holding the slot's lock across those steps keeps a + * refresh from publishing over a capture in progress or writing a deleted + * download's row back. + * + * Take it before [org.siloserver.silo.network.IdentityTransitionBarrier.withCurrentGeneration] + * or a database transaction, never inside either. + */ +object DownloadSlotLocks { + private data class Slot(val serverId: String, val profileId: String, val fileId: Int) + + private val locks = ConcurrentHashMap() + + fun of(serverId: String, profileId: String, fileId: Int): Mutex = + locks.computeIfAbsent(Slot(serverId, profileId, fileId)) { Mutex() } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt index 2cf835154e..de88b6883d 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt @@ -42,6 +42,7 @@ import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.IOException import java.net.URLDecoder @@ -493,39 +494,43 @@ class DownloadWorker( if (!OfflineTrackAssetFetcher.appliesTo(mediaType)) return try { requireOwner() - val assets = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { - transferAuthority?.let { managedDownloadAuth(it.scope) } - } ?: return - val tracks = assets.tracks - val artwork = assets.artwork - ownedWrite { - val existing = metadataStore.readSidecar(serverId, profileId, fileId) - ?.takeIf { it.record.id == downloadId } - if (existing != null) { - metadataStore.writeSidecar( - serverId, profileId, - existing.copy( - offlineTracks = tracks, - offlinePosterPath = artwork.posterPath, - offlineSeriesPosterPath = artwork.seriesPosterPath, - // A movie queued without a thumbhash takes the - // manifest's. An episode's poster_thumbhash hashes - // its 16:9 still, not the series poster its tile shows. - posterThumbhash = existing.posterThumbhash - ?: artwork.posterThumbhash.takeUnless { existing.isEpisodeDownload() }, - seriesPosterThumbhash = artwork.seriesPosterThumbhash ?: existing.seriesPosterThumbhash, - updatedAtMs = System.currentTimeMillis(), - ), - ) + // Held until the new tracks are on the row, so a subtitle refresh + // never publishes over the sidecars this capture writes. + DownloadSlotLocks.of(serverId, profileId, fileId).withLock { + val assets = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { + transferAuthority?.let { managedDownloadAuth(it.scope) } + } ?: return + val tracks = assets.tracks + val artwork = assets.artwork + ownedWrite { + val existing = metadataStore.readSidecar(serverId, profileId, fileId) + ?.takeIf { it.record.id == downloadId } + if (existing != null) { + metadataStore.writeSidecar( + serverId, profileId, + existing.copy( + offlineTracks = tracks, + offlinePosterPath = artwork.posterPath, + offlineSeriesPosterPath = artwork.seriesPosterPath, + // A movie queued without a thumbhash takes the + // manifest's. An episode's poster_thumbhash hashes + // its 16:9 still, not the series poster its tile shows. + posterThumbhash = existing.posterThumbhash + ?: artwork.posterThumbhash.takeUnless { existing.isEpisodeDownload() }, + seriesPosterThumbhash = artwork.seriesPosterThumbhash ?: existing.seriesPosterThumbhash, + updatedAtMs = System.currentTimeMillis(), + ), + ) + } + true } - true + Log.i( + TAG, + "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} " + + "subtitles=${tracks.subtitles.size} poster=${artwork.posterPath != null} " + + "seriesPoster=${artwork.seriesPosterPath != null}", + ) } - Log.i( - TAG, - "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} " + - "subtitles=${tracks.subtitles.size} poster=${artwork.posterPath != null} " + - "seriesPoster=${artwork.seriesPosterPath != null}", - ) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt new file mode 100644 index 0000000000..0b897a16b8 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt @@ -0,0 +1,123 @@ +package org.siloserver.silo.common.downloads + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.siloserver.silo.model.download.DownloadStatus +import org.siloserver.silo.model.download.statusEnum +import org.siloserver.silo.network.DurableLoginAuthorityProvider +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.managedDownloadAuth +import java.util.concurrent.TimeUnit + +/** + * Keeps saved subtitle sidecars in step with the server's timing corrections. + * + * A completed video download keeps the subtitle sidecars it fetched when it + * finished. When the server later retimes a stored subtitle (an automatic or + * manual sync, or a timing reset), the download's manifest lists that subtitle + * at a new `revision`; this worker re-fetches those sidecars and replaces the + * saved copies, so the next offline playback uses the corrected timing. + * + * It runs periodically while the device has a network, for the downloads of + * the login and profile that are active when it fires, like + * [DownloadSubscriptionWorker]. Every failure is left for the next run. + */ +class OfflineSubtitleRefreshWorker( + appContext: Context, + params: WorkerParameters, + private val metadataStore: DownloadMetadataStore, + storage: DownloadStorage, + httpClient: HttpClient, + private val authorities: DurableLoginAuthorityProvider, + private val transitions: IdentityTransitionBarrier, + private val gate: ApiV2Gate, +) : CoroutineWorker(appContext, params) { + + private val fetcher = OfflineTrackAssetFetcher(httpClient, storage) + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { refresh() } + + private suspend fun refresh(): Result { + // A server this client must not speak v2 to is left alone until the next period. + if (gate.blocked() != null) return Result.success() + val authority = authorities.snapshotDurableLoginAuthority() ?: return Result.success() + val scope = authority.scope + val profileId = scope.profileId ?: return Result.success() + for (sidecar in metadataStore.listSidecars(scope.serverId, profileId)) { + if (sidecar.record.statusEnum() != DownloadStatus.Completed) continue + val tracks = sidecar.offlineTracks ?: continue + val downloadId = sidecar.record.id + val fileId = sidecar.record.mediaFileId + val staged = try { + fetcher.stageSubtitleRefresh(downloadId, tracks) { managedDownloadAuth(scope) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "subtitle refresh failed id=$downloadId", e) + null + } ?: continue + try { + // Replace the saved files and record their revisions only while + // the owner and the download are the ones the refresh read: a + // replaced or deleted download keeps whatever it has now. The + // slot lock keeps a capture or a delete from landing between + // the check and the writes, and the conditional update keeps a + // replacement download enqueued meanwhile from being overwritten. + DownloadSlotLocks.of(scope.serverId, profileId, fileId).withLock { + transitions.withCurrentGeneration(scope.identityGeneration) { + if (authorities.snapshotDurableLoginAuthority() != authority) return@withCurrentGeneration false + metadataStore.updateSidecarIf( + scope.serverId, + profileId, + fileId, + matches = { it.record.id == downloadId && it.offlineTracks == tracks }, + ) { current -> + staged.publish()?.let { refreshed -> + current.copy(offlineTracks = refreshed, updatedAtMs = System.currentTimeMillis()) + } + } + } + } + } finally { + staged.discard() + } + } + return Result.success() + } + + companion object { + private const val TAG = "OfflineSubtitleRefresh" + private const val PERIODIC_UNIQUE_NAME = "offline-subtitle-refresh-periodic" + private const val PERIODIC_INTERVAL_HOURS = 12L + + fun enqueuePeriodic(context: Context) { + val request = PeriodicWorkRequestBuilder( + PERIODIC_INTERVAL_HOURS, TimeUnit.HOURS, + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() + WorkManager.getInstance(context.applicationContext).enqueueUniquePeriodicWork( + PERIODIC_UNIQUE_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt index 3bc006585c..b830e9b2cb 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt @@ -30,6 +30,7 @@ import org.siloserver.silo.model.download.isOfflineArtworkFetchUrl import org.siloserver.silo.model.download.isOfflineSubtitleFetchUrl import org.siloserver.silo.model.download.offlineSubtitleExtension import org.siloserver.silo.model.download.offlineSubtitleFormat +import org.siloserver.silo.model.download.subtitlesWithNewRevision import org.siloserver.silo.model.download.toOfflineTrackInfo import org.siloserver.silo.playback.orNullIfBlank import java.io.File @@ -94,8 +95,9 @@ internal class OfflineTrackAssetFetcher( private suspend fun fetchManifestBody( downloadId: String, configure: HttpRequestBuilder.() -> Unit, + attempts: Int = MANIFEST_ATTEMPTS, ): String? { - repeat(MANIFEST_ATTEMPTS) { attempt -> + repeat(attempts) { attempt -> if (attempt > 0) delay(manifestRetryDelayMs * attempt) try { val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") { @@ -219,10 +221,98 @@ internal class OfflineTrackAssetFetcher( return null } val target = File(directory, "$ordinal.${offlineSubtitleExtension(format)}") - val partial = File(directory, "$ordinal.part") return try { if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory") - httpClient.prepareGet(subtitle.fetchUrl.trim()) { + downloadSubtitle(subtitle.fetchUrl.trim(), target, configure) + OfflineSubtitleFile( + path = target.absolutePath, + format = format, + language = subtitle.language.orNullIfBlank(), + title = subtitle.title.orNullIfBlank(), + forced = subtitle.forced, + hearingImpaired = subtitle.hearingImpaired, + fetchUrl = subtitle.fetchUrl.trim(), + revision = subtitle.revision, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "subtitle fetch failed id=$downloadId ordinal=$ordinal format=$format", e) + target.delete() + null + } + } + + /** + * Re-fetches the saved sidecars whose stored subtitle the server has + * retimed since they were saved (its manifest `revision` changed) into + * staging files beside them. Nothing is replaced until the caller has + * confirmed the download is still the one [tracks] describes and calls + * [StagedSubtitleRefresh.publish]. Returns null when nothing changed or the + * manifest is unavailable. A sidecar that fails to fetch keeps its old file + * and revision, so the next refresh tries it again. + */ + suspend fun stageSubtitleRefresh( + downloadId: String, + tracks: OfflineTrackInfo, + configure: HttpRequestBuilder.() -> Unit, + ): StagedSubtitleRefresh? { + if (tracks.subtitles.none { it.fetchUrl != null }) return null + val manifest = fetchManifestBody(downloadId, configure, attempts = 1) + ?.let(::decodeOfflineManifestTracks) + ?: return null + val staged = mutableListOf() + try { + for ((saved, revision) in tracks.subtitlesWithNewRevision(manifest)) { + stageSidecar(downloadId, saved, revision, configure)?.let(staged::add) + } + } catch (e: CancellationException) { + staged.forEach { it.staging.delete() } + throw e + } + return if (staged.isEmpty()) null else StagedSubtitleRefresh(downloadId, tracks, staged) + } + + /** Fetches one retimed sidecar into a staging file beside [saved], or null when it cannot. */ + private suspend fun stageSidecar( + downloadId: String, + saved: OfflineSubtitleFile, + revision: String, + configure: HttpRequestBuilder.() -> Unit, + ): StagedSubtitleRefresh.Sidecar? { + val url = saved.fetchUrl?.takeIf(::isOfflineSubtitleFetchUrl) ?: return null + val target = File(saved.path) + if (!target.isFile) return null + // The copy this refresh replaces, as it is now: if anything rewrites it + // before publishing, the staged file is dropped. + val sidecar = StagedSubtitleRefresh.Sidecar( + path = saved.path, + revision = revision, + staging = File(target.parentFile, "${target.nameWithoutExtension}.refresh.${target.extension}"), + savedLength = target.length(), + savedModifiedMs = target.lastModified(), + ) + return try { + downloadSubtitle(url, sidecar.staging, configure) + sidecar + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "subtitle refresh failed id=$downloadId", e) + sidecar.staging.delete() + null + } + } + + /** Streams [url] into [target] through a sibling partial file, replacing it only once complete. */ + private suspend fun downloadSubtitle( + url: String, + target: File, + configure: HttpRequestBuilder.() -> Unit, + ) { + val partial = File(target.parentFile, "${target.nameWithoutExtension}.part") + try { + httpClient.prepareGet(url) { configure() // Embedded ASS/PGS sidecars are extracted from the source on // demand, so the first byte can take a while; keep only an idle @@ -239,22 +329,8 @@ internal class OfflineTrackAssetFetcher( if (written == 0L) throw IOException("empty subtitle") } if (!partial.renameTo(target)) throw IOException("could not publish $target") - OfflineSubtitleFile( - path = target.absolutePath, - format = format, - language = subtitle.language.orNullIfBlank(), - title = subtitle.title.orNullIfBlank(), - forced = subtitle.forced, - hearingImpaired = subtitle.hearingImpaired, - ) - } catch (e: CancellationException) { - partial.delete() - throw e - } catch (e: Exception) { - Log.w(TAG, "subtitle fetch failed id=$downloadId ordinal=$ordinal format=$format", e) + } finally { partial.delete() - target.delete() - null } } @@ -280,3 +356,58 @@ internal class OfflineTrackAssetFetcher( } } } + +/** + * Sidecars fetched again into staging files beside their saved copies, not + * yet in place. [publish] moves them over the saved copies; [discard] removes + * whatever was not published. + */ +internal class StagedSubtitleRefresh( + private val downloadId: String, + private val tracks: OfflineTrackInfo, + private val sidecars: List, +) { + class Sidecar( + val path: String, + val revision: String, + val staging: File, + val savedLength: Long, + val savedModifiedMs: Long, + ) + + /** + * Replaces each saved copy that is still the one this refresh was staged + * against, and returns [tracks] with the published revisions, or null when + * none was published. A copy something rewrote or removed meanwhile (a + * new capture for the same file) keeps its new contents. Call it holding + * the download's [DownloadSlotLocks] lock, after checking its row. + */ + fun publish(): OfflineTrackInfo? { + val published = sidecars.mapNotNull { sidecar -> + val target = File(sidecar.path) + val unchanged = target.isFile && + target.length() == sidecar.savedLength && + target.lastModified() == sidecar.savedModifiedMs + if (unchanged && sidecar.staging.renameTo(target)) { + Log.i(TAG, "subtitle refreshed id=$downloadId revision=${sidecar.revision}") + sidecar.path to sidecar.revision + } else { + null + } + }.toMap() + if (published.isEmpty()) return null + return tracks.copy( + subtitles = tracks.subtitles.map { saved -> + published[saved.path]?.let { saved.copy(revision = it) } ?: saved + }, + ) + } + + fun discard() { + sidecars.forEach { it.staging.delete() } + } + + private companion object { + const val TAG = "OfflineTrackAssets" + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt index 34f5bac1eb..7110e87959 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt @@ -206,6 +206,25 @@ class DownloadMetadataStoreTest { assertEquals(replacement, store.readSidecar("server", "profile", 7)) } + @Test fun `a conditional update leaves a replaced or deleted row as it is`() = runTest { + val old = stubSidecar(7) + val replacement = old.copy(record = old.record.copy(id = "replacement")) + store.writeSidecar("server", "profile", replacement) + assertFalse(store.updateSidecarIf("server", "profile", 7, matches = { it.record.id == old.record.id }) { + error("A replaced row must not be updated") + }) + assertEquals(replacement, store.readSidecar("server", "profile", 7)) + + assertTrue(store.updateSidecarIf("server", "profile", 7, matches = { it.record.id == "replacement" }) { + it.copy(updatedAtMs = 42) + }) + assertEquals(42, store.readSidecar("server", "profile", 7)?.updatedAtMs) + + store.deleteSidecar("server", "profile", 7) + assertFalse(store.updateSidecarIf("server", "profile", 7, matches = { true }) { it }) + assertNull(store.readSidecar("server", "profile", 7)) + } + @Test fun `matching cleanup removes metadata only after byte deletion succeeds`() = runTest { val row = stubSidecar(7) store.writeSidecar("server", "profile", row) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocksTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocksTest.kt new file mode 100644 index 0000000000..5b2ae7e403 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSlotLocksTest.kt @@ -0,0 +1,26 @@ +package org.siloserver.silo.common.downloads + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DownloadSlotLocksTest { + + @Test + fun everyCallerSharesOneLockPerSlot() { + val capture = DownloadSlotLocks.of("server", "profile", 7) + assertTrue(capture.tryLock()) + try { + // A worker or view model looking the slot up separately gets the same lock. + assertSame(capture, DownloadSlotLocks.of("server", "profile", 7)) + assertFalse(DownloadSlotLocks.of("server", "profile", 7).tryLock()) + + val otherSlot = DownloadSlotLocks.of("server", "profile", 8) + assertTrue(otherSlot.tryLock()) + otherSlot.unlock() + } finally { + capture.unlock() + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt index 01ce74d27b..5d0e360a40 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -99,6 +99,71 @@ class OfflineTrackAssetFetcherTest { defaultRequest { url("https://silo.example/") } } + /** A server whose stored subtitle 7 is retimed between [revision]s; it serves [cue] for it. */ + private class RetimingServer { + var revision = "r1" + var cue = "00:00:42,148 --> 00:00:44,517" + val client = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + when { + path.endsWith("/manifest") -> respond( + """{"subtitles": [{"language": "en", "format": "srt", + "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:7", "revision": "$revision"}]}""", + HttpStatusCode.OK, + ) + path.endsWith("/subtitles/downloaded:7") -> respond("1\n$cue\nFor a moment, everyone was silent.\n") + else -> respond("", HttpStatusCode.NotFound) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://silo.example/") } + } + } + + @Test + fun aRetimedSidecarIsStagedAndReplacesTheSavedCopyOnlyWhenPublished() = runBlocking { + val server = RetimingServer() + val fetcher = OfflineTrackAssetFetcher(server.client, DownloadStorage(tmp.newFolder("filesDir"))) + val saved = assertNotNull(fetcher.fetch("dl_1", "srv", "prof", 42) {}).tracks + val file = File(saved.subtitles.single().path) + assertEquals("r1", saved.subtitles.single().revision) + assertNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}, "nothing changed on the server") + + server.revision = "r2" + server.cue = "00:00:39,138 --> 00:00:41,507" + val staged = assertNotNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + // Staging leaves the saved copy alone. + assertTrue(file.readText().contains("00:00:42,148")) + + val refreshed = assertNotNull(staged.publish()) + staged.discard() + assertEquals("r2", refreshed.subtitles.single().revision) + assertTrue(file.readText().contains("00:00:39,138")) + assertEquals(listOf(file.name), file.parentFile!!.list()!!.toList()) + server.client.close() + } + + @Test + fun aSavedCopyRewrittenAfterStagingKeepsItsNewContents() = runBlocking { + val server = RetimingServer() + val fetcher = OfflineTrackAssetFetcher(server.client, DownloadStorage(tmp.newFolder("filesDir"))) + val saved = assertNotNull(fetcher.fetch("dl_1", "srv", "prof", 42) {}).tracks + val file = File(saved.subtitles.single().path) + + server.revision = "r2" + val staged = assertNotNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + // A new capture for the same file rewrites the copy before publishing. + file.writeText("1\n00:00:01,000 --> 00:00:02,000\nA new capture.\n") + + assertNull(staged.publish()) + staged.discard() + assertTrue(file.readText().contains("A new capture.")) + assertEquals(listOf(file.name), file.parentFile!!.list()!!.toList()) + server.client.close() + } + @Test fun capturesAudioTracksAndSavesEveryFetchableSidecar() = runBlocking { val storage = DownloadStorage(tmp.newFolder("filesDir")) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt index fda064d955..180336838b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt @@ -150,6 +150,13 @@ class SiloApplication : Application(), Configuration.Provider, SingletonImageLoa }.onFailure { android.util.Log.w("SiloApplication", "Download subscription periodic enqueue failed", it) } + // Re-fetch saved subtitle sidecars the server has retimed since they + // were downloaded. Guarded for the same reason. + runCatching { + org.siloserver.silo.common.downloads.OfflineSubtitleRefreshWorker.enqueuePeriodic(this) + }.onFailure { + android.util.Log.w("SiloApplication", "Offline subtitle refresh periodic enqueue failed", it) + } registerDownloadsNotificationChannel() } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 8334d141ff..3a5a7fb0f7 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -384,6 +384,18 @@ val androidModule = module { profileRepository = get(), ) } + worker { + org.siloserver.silo.common.downloads.OfflineSubtitleRefreshWorker( + appContext = androidContext(), + params = get(), + metadataStore = get(), + storage = get(), + httpClient = get(), + authorities = get(), + transitions = get(), + gate = get(), + ) + } // Kept for consistency, but DEAD AT RUNTIME: Koin's WorkManager factory // returns null on WM 2.10 + Koin 4.1.0, so AppWorkerFactory does the real // injection (see AppWorkerFactory). Update both if SyncWorker's deps change. diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt index 489c875257..d8fcfc75ad 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt @@ -12,6 +12,7 @@ import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory import org.siloserver.silo.common.downloads.DownloadSubscriptionWorker import org.siloserver.silo.common.downloads.DownloadStatusWorker import org.siloserver.silo.common.downloads.DownloadWorker +import org.siloserver.silo.common.downloads.OfflineSubtitleRefreshWorker import org.siloserver.silo.common.diagnostics.DiagnosticsCoordinator import org.siloserver.silo.common.diagnostics.DiagnosticsUploadWorker import org.siloserver.silo.common.diagnostics.HostedDiagnosticsDeletionWorker @@ -76,6 +77,19 @@ class AppWorkerFactory : WorkerFactory() { devices = koin.get(), ) } + OfflineSubtitleRefreshWorker::class.java.name -> { + Log.i(TAG, "Building OfflineSubtitleRefreshWorker via Koin") + OfflineSubtitleRefreshWorker( + appContext = appContext, + params = workerParameters, + metadataStore = koin.get(), + storage = koin.get(), + httpClient = koin.get(), + authorities = koin.get(), + transitions = koin.get(), + gate = koin.get(), + ) + } DownloadSubscriptionWorker::class.java.name -> { Log.i(TAG, "Building DownloadSubscriptionWorker via Koin") DownloadSubscriptionWorker( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt index a9ce6879d1..0a1fa9153d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt @@ -7,6 +7,7 @@ import org.siloserver.silo.common.downloads.DownloadEnqueuer import org.siloserver.silo.common.downloads.DownloadReclaimCandidate import org.siloserver.silo.common.downloads.DownloadReclaimPlan import org.siloserver.silo.common.downloads.DownloadReclaimPlanner +import org.siloserver.silo.common.downloads.DownloadSlotLocks import org.siloserver.silo.common.downloads.DownloadStorage import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory import org.siloserver.silo.common.downloads.tileArtwork @@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** @@ -474,10 +476,12 @@ class DownloadsViewModel( .getOrElse { emptyList() } for (p in pending) { val fileId = p.mediaFileId ?: continue - metadataStore.completePendingDeletion(p.serverId, p.profileId, fileId, p.recordId) { - withContext(Dispatchers.IO) { - storage.delete(p.serverId, p.profileId, fileId) || - !storage.exists(p.serverId, p.profileId, fileId) + DownloadSlotLocks.of(p.serverId, p.profileId, fileId).withLock { + metadataStore.completePendingDeletion(p.serverId, p.profileId, fileId, p.recordId) { + withContext(Dispatchers.IO) { + storage.delete(p.serverId, p.profileId, fileId) || + !storage.exists(p.serverId, p.profileId, fileId) + } } } } @@ -557,10 +561,14 @@ class DownloadsViewModel( try { repository.enqueueDurableDelete(serverId, profileId, id, fileId) if (fileId != null) { - withContext(Dispatchers.IO) { - storage.delete(serverId, profileId, fileId) + // A subtitle refresh rewrites this slot's files and row + // under the same lock, so it cannot write the row back. + DownloadSlotLocks.of(serverId, profileId, fileId).withLock { + withContext(Dispatchers.IO) { + storage.delete(serverId, profileId, fileId) + } + metadataStore.deleteSidecar(serverId, profileId, fileId) } - metadataStore.deleteSidecar(serverId, profileId, fileId) } } finally { // Also covers the fileId == null branch, which never reaches diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt index 46e9faae38..2293840a6b 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt @@ -30,6 +30,12 @@ data class OfflineManifestSubtitle( val forced: Boolean = false, @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, @SerialName("fetch_url") val fetchUrl: String = "", + /** + * Opaque revision of a stored (`downloaded:{id}`) subtitle. It changes when + * the server retimes the subtitle (a sync or a timing reset), which changes + * the bytes [fetchUrl] serves. Absent for embedded and external tracks. + */ + val revision: String? = null, ) /** @@ -75,8 +81,29 @@ data class OfflineSubtitleFile( val title: String? = null, val forced: Boolean = false, val hearingImpaired: Boolean = false, + /** The manifest `fetch_url` the file came from; null on captures older than this field. */ + val fetchUrl: String? = null, + /** The manifest `revision` of the fetched bytes, when the manifest gave one. */ + val revision: String? = null, ) +/** + * Saved sidecars whose stored subtitle the [manifest] lists at a different + * revision, paired with that revision. Captures that predate [OfflineSubtitleFile.fetchUrl] + * cannot be matched to a manifest row and are never refreshed. + */ +fun OfflineTrackInfo.subtitlesWithNewRevision( + manifest: OfflineManifestTracks, +): List> { + val listed = manifest.subtitles + .filter { it.revision != null } + .associateBy { it.fetchUrl.trim() } + return subtitles.mapNotNull { saved -> + val revision = saved.fetchUrl?.let(listed::get)?.revision ?: return@mapNotNull null + if (revision == saved.revision) null else saved to revision + } +} + private val offlineManifestJson = Json { ignoreUnknownKeys = true coerceInputValues = true diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt index 9d4515eb66..f85c42642e 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt @@ -143,4 +143,31 @@ class OfflineTracksTest { assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/artwork/poster")) assertFalse(isOfflineSubtitleFetchUrl("/api/v1/downloads/dl_1/subtitles/external:0")) } + + @Test + fun findsSavedSidecarsWhoseStoredSubtitleWasRetimed() { + val manifest = requireNotNull( + decodeOfflineManifestTracks( + """ + {"subtitles": [ + {"language": "en", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:7", "revision": "3"}, + {"language": "fr", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:8", "revision": "1"}, + {"language": "de", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0"} + ]} + """.trimIndent(), + ), + ) + fun saved(path: String, url: String?, revision: String?) = + OfflineSubtitleFile(path = path, format = "srt", fetchUrl = url, revision = revision) + val retimed = saved("/d/0.srt", "/api/v2/downloads/dl_1/subtitles/downloaded:7", "2") + val current = saved("/d/1.srt", "/api/v2/downloads/dl_1/subtitles/downloaded:8", "1") + val external = saved("/d/2.srt", "/api/v2/downloads/dl_1/subtitles/external:0", null) + val legacy = saved("/d/3.srt", null, null) + val tracks = OfflineTrackInfo(subtitles = listOf(retimed, current, external, legacy)) + + assertEquals(listOf(retimed to "3"), tracks.subtitlesWithNewRevision(manifest)) + // A capture from a server without revisions gets them on its first refresh. + val unversioned = OfflineTrackInfo(subtitles = listOf(current.copy(revision = null))) + assertEquals(listOf(current.copy(revision = null) to "1"), unversioned.subtitlesWithNewRevision(manifest)) + } } From e21a0fa4a1b8b509f952d38c8253d35e3f8edeca Mon Sep 17 00:00:00 2001 From: Tomislav Filipcic Date: Mon, 5 Oct 2026 23:37:52 +0200 Subject: [PATCH 19/87] feat(playback): play embedded MKV text subtitles from the stream (#464) * feat(playback): play embedded MKV text subtitles from the stream Advertise native embedded subtitles for Matroska SubRip and ASS tracks on original-HTTP delivery, identified by container_track_id. Media3's MatroskaExtractor sets Format.id to the Matroska TrackNumber, which the server records as container_track_id, so the existing exact-ID selection works unchanged. The MKV entry reports libass styling and font-attachment support so the planner keeps ASS on the sidecar path when fidelity would be lost. Closes #457 * docs(playback): note MKV stream-path costs and tidy capability entry Import NativeEmbeddedSubtitleCapability, drop the test that duplicated existing exact-ID coverage, document the legacy S_ASS/S_SSA and seek costs, and stop describing the open server PR as shipped. * docs(playback): note that legacy S_ASS/S_SSA tracks stay on sidecars * fix(playback): accept native MKV subtitle plans in the Media3 validator validateForMedia3 still required mov_text in an MP4/MOV/M4V source, so a native embedded plan for an MKV SubRip or ASS track was rejected with subtitle_embedded_failed and replanned to a sidecar. Accept the pairs the client advertises and keep rejecting the rest. * refactor(playback): define native embedded subtitle pairs once The capability detector and validateForMedia3 each hardcoded the container/codec pairs Android selects natively, and their drift is what rejected MKV plans. Both now read NATIVE_EMBEDDED_SUBTITLE_CODECS. The validator also accepts the srt and tx3g aliases the server normalizes and drops the mov/m4v containers the server never reports. * docs(playback): state the container track ID assumption for native pairs --- .../player/PlaybackCapabilityDetector.kt | 23 ++++++++++--- docs/playback/README.md | 34 +++++++++++++++---- .../silo/model/playback/PlaybackProtocolV3.kt | 34 +++++++++++++++++-- .../NativeEmbeddedSubtitleProtocolTest.kt | 14 ++++++++ 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt index 745194645c..03c0f3daf3 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt @@ -32,6 +32,8 @@ import org.siloserver.silo.model.playback.CLIENT_DV7_TO_DV81 import org.siloserver.silo.model.playback.CLIENT_DV7_TO_HDR10 import org.siloserver.silo.model.playback.CLIENT_DV_TRANSFORM_RECIPE_VERSION import org.siloserver.silo.model.playback.NATIVE_HLS_PLAYBACK_V1_FEATURE +import org.siloserver.silo.model.playback.NATIVE_EMBEDDED_SUBTITLE_CODECS +import org.siloserver.silo.model.playback.NativeEmbeddedSubtitleCapability import org.siloserver.silo.model.playback.CLIENT_SELECTED_AUDIO_TRACK_V1_CLAIM import org.siloserver.silo.model.playback.CLIENT_DV8_BASE_LAYER_FALLBACK_V1_CLAIM import org.siloserver.silo.model.playback.HdrCapabilities @@ -528,11 +530,22 @@ class PlaybackCapabilityDetector( maxChannels = passthrough?.maxChannels, hdrDetails = caps.hdrDetails, subtitles = DeliverySubtitleCapabilities( - nativeEmbedded = listOf(org.siloserver.silo.model.playback.NativeEmbeddedSubtitleCapability( - container = "mp4", - codecs = listOf("mov_text"), - trackIdentity = "container_track_id", - )), + // MP4 and MatroskaExtractor both set each track's + // Format.id to the container track ID the server + // records. libass's Matroska extractor subclass + // forwards formats unchanged, so ASS keeps the same + // identity; its flags tell the planner whether the + // stream path keeps styling and attached fonts. + nativeEmbedded = NATIVE_EMBEDDED_SUBTITLE_CODECS.map { (container, codecs) -> + val ass = "ass" in codecs + NativeEmbeddedSubtitleCapability( + container = container, + codecs = codecs, + trackIdentity = "container_track_id", + assStyling = ass && libassDirectFidelity, + fontAttachments = ass && libassEmbeddedFonts, + ) + }, embeddedText = true, sidecarText = true, assStyling = libassDirectFidelity, diff --git a/docs/playback/README.md b/docs/playback/README.md index 2d104ce211..39fc04ec24 100644 --- a/docs/playback/README.md +++ b/docs/playback/README.md @@ -66,18 +66,38 @@ feature token has the same name. ## Native embedded subtitles Android advertises `embedded_subtitles_v1` only when the original-HTTP delivery -includes a `native_embedded` capability. The first supported pair is MP4 with -`mov_text`, using `container_track_id`. Media3 1.11.0 sets a timed-text format's -ID from the MP4 `tkhd` track ID; FFmpeg stream indexes are not Media3 track IDs. -See the pinned [Media3 MP4 parser](https://github.com/androidx/media/blob/1.11.0/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java). +includes a `native_embedded` capability. Both supported entries use +`container_track_id`; FFmpeg stream indexes are not Media3 track IDs. + +- MP4 with `mov_text`: Media3 1.11.0 sets a timed-text format's ID from the MP4 + `tkhd` track ID. See the pinned [Media3 MP4 parser](https://github.com/androidx/media/blob/1.11.0/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/BoxParser.java). +- MKV with `subrip` and `ass`: `MatroskaExtractor` sets each track's ID to its + Matroska `TrackNumber`. See the pinned [Media3 Matroska extractor](https://github.com/androidx/media/blob/1.11.0/libraries/extractor/src/main/java/androidx/media3/extractor/mkv/MatroskaExtractor.java). + Remuxed files keep their original track numbers, so numbers can have gaps + and do not equal stream index + 1. The `ass-media` extractor subclass passes + formats through unchanged, so ASS keeps the same ID. The MKV entry's + `ass_styling` and `font_attachments` report whether libass renders the stream + with its attached fonts; when they are false and the user prefers preserved + styling, the server keeps ASS on its sidecar path. + +The server must record each Matroska subtitle's `TrackNumber` as +`container_track_id` (proposed in Silo-Server/silo-server#1890). Tracks without +a recorded ID keep using sidecars. MKV WebVTT (`S_TEXT/WEBVTT`) is not +advertised because FFmpeg does not recognize that codec, so the server cannot +identify those tracks. Media3 drops tracks with the legacy `S_ASS` and `S_SSA` +codec IDs, which FFmpeg still reports as `ass`; the server records no ID for +them, so they stay on sidecars too. + +Known cost of the stream path: after a seek or resume, a cue that began before +the cluster Media3 resumes from stays hidden until the next cue. A plan's `subtitle.embedded` selects the exact container track. Inventory URLs remain fallback descriptions and are not mounted alongside that selection. Phone and TV commit the preference only after the native track is mounted. Missing, ambiguous, or unsupported identities produce `subtitle_embedded_failed` -and let the server replan with its sidecar path. Matroska and tracks without a -probed container ID keep using sidecars; metadata similarity is not exact -identity evidence. +and let the server replan with its sidecar path. Tracks without a probed +container ID keep using sidecars; metadata similarity is not exact identity +evidence. Sidecar cues use absolute source timestamps. Android subtracts the plan's `timeline_offset_seconds` once, alongside the user's subtitle delay. Embedded diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt index c6f49b6977..0d0aa0d323 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt @@ -498,6 +498,36 @@ sealed interface PlaybackV3Validation { data class ReplanRequired(val reason: String, val plan: PlaybackPlanV3, val sessionId: String) : PlaybackV3Validation } +/** + * Subtitle codecs, by source container, that Android selects natively from an + * original-HTTP stream by `container_track_id`. MP4 `mov_text` maps to the + * `tkhd` track ID and MKV SubRip/ASS to the Matroska TrackNumber, both of which + * Media3 sets as `Format.id`. The capability detector advertises these pairs + * and [validateForMedia3] accepts them, so the two share one container/codec + * list. Both also assume every entry is identified by `container_track_id`: + * only add a pair whose Media3 track ID is the container's own track ID. + */ +val NATIVE_EMBEDDED_SUBTITLE_CODECS: Map> = mapOf( + "mp4" to listOf("mov_text"), + "mkv" to listOf("subrip", "ass"), +) + +/** + * Normalizes a subtitle codec name the way the server does when it matches a + * native capability, so a plan it issues for an alias is not rejected here. + */ +private fun nativeEmbeddedSubtitleCodec(codec: String?): String? = + when (val normalized = codec?.trim()?.lowercase()) { + "srt" -> "subrip" + "tx3g" -> "mov_text" + else -> normalized + } + +private fun isNativeEmbeddedSubtitlePair(container: String?, codec: String?): Boolean { + val codecs = NATIVE_EMBEDDED_SUBTITLE_CODECS[container?.trim()?.lowercase()] ?: return false + return nativeEmbeddedSubtitleCodec(codec) in codecs +} + fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { if (protocolVersion != PLAYBACK_PROTOCOL_V3 || PLAYBACK_PLAN_V3_FEATURE !in serverFeatures || @@ -564,8 +594,8 @@ fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { if (plan.delivery != PlaybackDelivery.ORIGINAL_HTTP || plan.subtitle.mode != PlaybackSubtitleModeV3.RENDER || plan.subtitle.artifact != null || native.streamIndex < 0 || nativeId == null || nativeId !in 1..Int.MAX_VALUE.toLong() || native.containerTrackId != nativeId.toString() || - selectedSubtitle?.source != "embedded" || selectedSubtitle.codec != "mov_text" || - plan.source.container?.lowercase() !in setOf("mp4", "mov", "m4v") || + selectedSubtitle?.source != "embedded" || + !isNativeEmbeddedSubtitlePair(plan.source.container, selectedSubtitle.codec) || plan.subtitle.trackId != plan.selectedTracks.subtitle?.id ) return PlaybackV3Validation.ReplanRequired("subtitle_embedded_failed", plan, resolvedSessionId) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/NativeEmbeddedSubtitleProtocolTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/NativeEmbeddedSubtitleProtocolTest.kt index 47bd3692c1..4921aa1c6b 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/NativeEmbeddedSubtitleProtocolTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/NativeEmbeddedSubtitleProtocolTest.kt @@ -39,6 +39,20 @@ class NativeEmbeddedSubtitleProtocolTest { assertIs(validate(plan.copy(source = PlaybackSourceDescriptorV3(container = "mkv")))) } + @Test fun matroskaTextTracksArePlayableOnlyForAdvertisedCodecs() { + fun mkv(codec: String) = plan.copy( + source = PlaybackSourceDescriptorV3(container = "mkv"), + subtitle = plan.subtitle.copy(inventory = listOf(track.copy(codec = codec))), + ) + assertIs(validate(mkv("subrip"))) + assertIs(validate(mkv("ass"))) + assertIs(validate(mkv("srt"))) + assertIs(validate(mkv("webvtt"))) + assertIs(validate(plan.copy( + subtitle = plan.subtitle.copy(inventory = listOf(track.copy(codec = "subrip"))), + ))) + } + @Test fun nativeCapabilitiesHaveExactWireNames() { val capability = DeliverySubtitleCapabilities(nativeEmbedded = listOf(NativeEmbeddedSubtitleCapability("mp4", listOf("mov_text"), "container_track_id"))) val json = SiloJson.encodeToString(capability) From a8d7aead0399ce3e83e23b7f9a136319b067a524 Mon Sep 17 00:00:00 2001 From: Tomislav Filipcic Date: Mon, 5 Oct 2026 23:38:03 +0200 Subject: [PATCH 20/87] fix(tv): offer the full subtitle-language list in search and AI translate (#465) * fix(tv): offer the full subtitle-language list in search and AI translate Android TV kept its own 28-language list, so a Croatian (hr) preference fell back to English in the AI translation picker. Move LanguageNames to android-shared and derive the TV pickers from it, matching the phone app. Fixes #271 * fix(tv): match audio and target languages across 2- and 3-letter codes --- .../silo/common/ui}/LanguageNames.kt | 2 +- .../silo/common/ui}/LanguageNamesTest.kt | 2 +- .../ui/screens/player/AiTranslateSheet.kt | 2 +- .../ui/screens/player/PlayerOverlay.kt | 2 +- .../ui/screens/player/SubtitleSearchSheet.kt | 2 +- .../ui/screens/player/TvAiTranslateDialog.kt | 9 +-- .../tv/ui/screens/player/TvPlayerViewModel.kt | 4 +- .../screens/player/TvSubtitleSearchDialog.kt | 46 ++++++++++----- .../player/TvSubtitleLanguageOptionsTest.kt | 57 +++++++++++++++++++ 9 files changed, 98 insertions(+), 28 deletions(-) rename {androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/util => android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui}/LanguageNames.kt (98%) rename {androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/util => android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui}/LanguageNamesTest.kt (97%) create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleLanguageOptionsTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/util/LanguageNames.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/LanguageNames.kt similarity index 98% rename from androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/util/LanguageNames.kt rename to android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/LanguageNames.kt index c773594da4..08f645375d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/util/LanguageNames.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/LanguageNames.kt @@ -1,4 +1,4 @@ -package org.siloserver.silo.android.ui.util +package org.siloserver.silo.common.ui /** * ISO 639 language code → English display name, mirroring the web player's diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/util/LanguageNamesTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/LanguageNamesTest.kt similarity index 97% rename from androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/util/LanguageNamesTest.kt rename to android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/LanguageNamesTest.kt index 2da8ad2a25..9b36f6bb56 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/util/LanguageNamesTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/LanguageNamesTest.kt @@ -1,4 +1,4 @@ -package org.siloserver.silo.android.ui.util +package org.siloserver.silo.common.ui import kotlin.test.Test import kotlin.test.assertEquals diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt index 3893ea1935..cad7582659 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt @@ -44,7 +44,7 @@ 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.siloserver.silo.android.ui.util.LanguageNames +import org.siloserver.silo.common.ui.LanguageNames import org.siloserver.silo.model.catalog.AudioTrack import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.model.subtitles.SubtitleAiJobKind diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index ae8dcd5e86..784e59505c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import org.siloserver.silo.android.ui.util.LanguageNames +import org.siloserver.silo.common.ui.LanguageNames import org.siloserver.silo.common.player.SessionState import org.siloserver.silo.common.player.SleepTimerState import org.siloserver.silo.model.watchtogether.MemberRole diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt index 8abb4e7cb3..ffb7af72af 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt @@ -41,7 +41,7 @@ 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.siloserver.silo.android.ui.util.LanguageNames +import org.siloserver.silo.common.ui.LanguageNames import org.siloserver.silo.model.subtitles.SubtitleResult // Web-parity palette (SubtitleSearchModal.tsx providerInfo / scoreColor). diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAiTranslateDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAiTranslateDialog.kt index e772c71943..046272b05f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAiTranslateDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAiTranslateDialog.kt @@ -77,10 +77,7 @@ fun TvAiTranslateDialog( mutableIntStateOf(audioSources.indexOfFirst { it.isSelected }.coerceAtLeast(0)) } var targetPos by remember { - mutableIntStateOf( - TvSubtitleLanguageOptions.indexOf(defaultTargetLanguage.take(2).lowercase()) - .takeIf { it >= 0 } ?: 0, - ) + mutableIntStateOf(tvSubtitleLanguageIndex(defaultTargetLanguage)) } val firstRowFocus = remember { FocusRequester() } val initialNonce = remember { aiState.completedNonce } @@ -345,9 +342,7 @@ fun TvAiTranslateDialog( val src = audioSources[ audioSourcePos.coerceIn(0, audioSources.lastIndex), ] - val sameLanguage = src.language - ?.take(2) - ?.equals(targetLanguage.take(2), ignoreCase = true) == true + val sameLanguage = tvIsSameLanguage(src.language, targetLanguage) onSubmit( if (sameLanguage) "transcribe" else "transcribe_translate", src.index, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 09baece9dc..23848bd806 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -39,6 +39,7 @@ import org.siloserver.silo.common.player.SessionState import org.siloserver.silo.common.player.SleepTimerController import org.siloserver.silo.common.player.SleepTimerState import org.siloserver.silo.common.player.StartParams +import org.siloserver.silo.common.ui.LanguageNames import org.siloserver.silo.common.player.MountedSubtitleTrack import org.siloserver.silo.common.player.resolveMountedSubtitle import org.siloserver.silo.common.player.backend.VideoBackendCapabilities @@ -4843,8 +4844,7 @@ class TvPlayerViewModel( } fun openSubtitleSearchDialog() { - val defaultLang = _uiState.value.preferredTextLanguage - ?.takeIf { it.isNotBlank() }?.take(2)?.lowercase() ?: "en" + val defaultLang = LanguageNames.searchCode(_uiState.value.preferredTextLanguage) _subtitleSearch.update { // Keep prior results/language when reopening mid-session. if (it.hasSearched) it else it.copy(language = defaultLang) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSearchDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSearchDialog.kt index 18a4655264..f23de50915 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSearchDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSearchDialog.kt @@ -48,27 +48,46 @@ import androidx.tv.material3.Glow import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.LanguageNames import org.siloserver.silo.model.subtitles.SubtitleResult import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus import org.siloserver.silo.tv.ui.theme.DarkBackground import org.siloserver.silo.tv.ui.theme.FocusedContainer import org.siloserver.silo.tv.ui.theme.FocusedContent -import java.util.Locale /** - * Language codes offered by the search/translate pickers — matches the web's - * common-language set. Cycled left/right on a [TvDialogCyclerRow]; no text - * input anywhere (TV constraint that put this feature in scope). + * Language codes offered by the search/translate pickers — the shared + * [LanguageNames] vocabulary the phone app uses, in the same display-name + * order. Cycled left/right on a [TvDialogCyclerRow]; no text input anywhere + * (TV constraint that put this feature in scope). */ -internal val TvSubtitleLanguageOptions: List = listOf( - "en", "es", "fr", "de", "it", "pt", "nl", "pl", "ru", "ja", "ko", "zh", - "ar", "tr", "sv", "no", "da", "fi", "cs", "el", "he", "hi", "hu", "id", - "ro", "th", "uk", "vi", -) +internal val TvSubtitleLanguageOptions: List = + LanguageNames.dropdownOptions.map { it.first } -/** ISO 639-1 → English display name, fallback uppercased code (spec LanguageNames behavior). */ -internal fun tvLanguageDisplayName(code: String): String = - Locale(code).getDisplayLanguage(Locale.ENGLISH).ifBlank { code.uppercase() } +/** + * Picker position for a profile/track language code (2- or 3-letter). + * Normalizes through [LanguageNames.searchCode], so every supported language + * lands on itself and only unsupported codes fall back to English. + */ +internal fun tvSubtitleLanguageIndex(code: String?): Int = + TvSubtitleLanguageOptions.indexOf(LanguageNames.searchCode(code)) + .takeIf { it >= 0 } ?: TvSubtitleLanguageOptions.indexOf("en") + +/** + * Whether a track's 2- or 3-letter code names the same language as a picker + * code ("bul" vs "bg"). Unknown or missing source codes never match, since + * [LanguageNames.searchCode] would otherwise map them to English. + */ +internal fun tvIsSameLanguage(source: String?, target: String): Boolean { + val code = source?.trim()?.lowercase().orEmpty() + if (code.isEmpty()) return false + val normalized = LanguageNames.searchCode(code) + if (normalized == "en" && LanguageNames.displayName(code) != "English") return false + return normalized == LanguageNames.searchCode(target) +} + +/** Display name for a 2- or 3-letter code, fallback uppercased code. */ +internal fun tvLanguageDisplayName(code: String): String = LanguageNames.displayName(code) /** * Score bucket colors — web/mobile parity (SubtitleScoreBadge): >=70 green @@ -174,8 +193,7 @@ fun TvSubtitleSearchDialog( modifier = Modifier.padding(horizontal = 8.dp), ) - val langIndex = TvSubtitleLanguageOptions.indexOf(state.language) - .takeIf { it >= 0 } ?: 0 + val langIndex = tvSubtitleLanguageIndex(state.language) TvDialogCyclerRow( title = "Language", value = tvLanguageDisplayName(TvSubtitleLanguageOptions[langIndex]), diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleLanguageOptionsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleLanguageOptionsTest.kt new file mode 100644 index 0000000000..7066b80671 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleLanguageOptionsTest.kt @@ -0,0 +1,57 @@ +package org.siloserver.silo.tv.ui.screens.player + +import org.siloserver.silo.common.ui.LanguageNames +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvSubtitleLanguageOptionsTest { + + @Test + fun tvOffersTheSharedVocabulary() { + assertEquals(LanguageNames.dropdownOptions.map { it.first }, TvSubtitleLanguageOptions) + } + + @Test + fun everySupportedLanguageSelectsItself() { + for ((code, _) in LanguageNames.dropdownOptions) { + assertEquals(code, TvSubtitleLanguageOptions[tvSubtitleLanguageIndex(code)]) + } + } + + @Test + fun croatianPreferenceSelectsCroatian() { + val index = tvSubtitleLanguageIndex("hr") + assertEquals("hr", TvSubtitleLanguageOptions[index]) + assertEquals("Croatian", tvLanguageDisplayName(TvSubtitleLanguageOptions[index])) + assertEquals("hr", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex("hrv")]) + } + + @Test + fun threeLetterCodesNormalize() { + assertEquals("de", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex("ger")]) + } + + @Test + fun unsupportedOrMissingCodesFallBackToEnglish() { + assertEquals("en", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex("zz")]) + assertEquals("en", TvSubtitleLanguageOptions[tvSubtitleLanguageIndex(null)]) + } + + @Test + fun sameLanguageMatchesAcrossCodeLengths() { + assertTrue(tvIsSameLanguage("bul", "bg")) + assertTrue(tvIsSameLanguage("hrv", "hr")) + assertTrue(tvIsSameLanguage("eng", "en")) + assertTrue(tvIsSameLanguage("DE", "de")) + assertFalse(tvIsSameLanguage("ger", "fr")) + } + + @Test + fun unknownSourceIsNotEnglish() { + assertFalse(tvIsSameLanguage("xyz", "en")) + assertFalse(tvIsSameLanguage(null, "en")) + assertFalse(tvIsSameLanguage(" ", "en")) + } +} From b0e3fc353d4906a71d9587e93586680cbb7202d9 Mon Sep 17 00:00:00 2001 From: Suspense Date: Mon, 5 Oct 2026 23:38:19 +0200 Subject: [PATCH 21/87] fix(playback): stop claiming m2ts/mts direct play (#452) * fix(playback): stop claiming m2ts/mts direct play Media3's TsExtractor reads only 188-byte transport-stream packets. Blu-ray and AVCHD .m2ts/.mts files use 192-byte BDAV packets, so no Media3 extractor recognizes them and direct play fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED before recovering through a failure replan to an HLS remux. Drop m2ts and mts from the advertised direct-play containers. The same list feeds the top-level client capabilities, the original_http delivery capability and the download capabilities, so the server now remuxes these files from the start and a download no longer fetches original bytes the player cannot open. ts, mpegts and mpeg-ts stay. The video/mp2t MIME mapping for m2ts/mts in SiloPlayerFactory stays: it is only a hint used when the server still serves original bytes (for example an older server that ignores the capability list), it is the correct MIME type for the format, and it does not change the outcome. Related to #449 * docs(playback): scope the m2ts note to what the server remuxes The container policy comment said dropping m2ts/mts keeps every download from fetching the original bytes. Whole-series downloads at Original quality still do, because the server does not check device capabilities for that batch. Say so, and split the M2TS row in the playback validation gates so M2TS/MTS is expected to use server adaptation rather than direct play. --- .../common/player/video/PlaybackContainerPolicy.kt | 12 +++++++++++- .../player/video/PlaybackContainerPolicyTest.kt | 13 ++++++++++++- .../02-migration-compatibility-validation.md | 3 ++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicy.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicy.kt index e821f4b87b..cadef7b0a0 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicy.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicy.kt @@ -2,12 +2,22 @@ package org.siloserver.silo.common.player.video /** * Containers Media3 opens directly for video sources. + * + * `m2ts` and `mts` are deliberately absent. Blu-ray and AVCHD files carry + * 192-byte BDAV packets, and Media3's `TsExtractor` reads only 188-byte + * transport-stream packets, so no extractor recognizes them and direct play + * fails with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. Leaving them out + * makes the server remux these files for playback and for single-title and + * season downloads. A whole-series download at Original quality still fetches + * the original bytes, because the server does not check device capabilities + * for that batch. Add them back only once Media3 supports 192-byte packets + * and a device test passes. */ val media3OriginalVideoContainers: List = listOf( "mp4", "m4v", "mov", "qt", "webm", "mkv", "matroska", "avi", - "ts", "mpegts", "mpeg-ts", "m2ts", "mts", + "ts", "mpegts", "mpeg-ts", ) /** diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicyTest.kt index d875303c9a..7de2a3e43b 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicyTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/PlaybackContainerPolicyTest.kt @@ -1,16 +1,27 @@ package org.siloserver.silo.common.player.video import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class PlaybackContainerPolicyTest { @Test fun media3ContainersIncludeValidatedOriginalFormats() { listOf( - "mkv", "matroska", "mp4", "m4v", "webm", "avi", "mov", "qt", "ts", "mpegts", "mpeg-ts", "m2ts", "mts", + "mkv", "matroska", "mp4", "m4v", "webm", "avi", "mov", "qt", "ts", "mpegts", "mpeg-ts", ) .forEach { container -> assertTrue(normalizedPlaybackContainer(container) in media3OriginalPlaybackContainers, "container=$container") } } + + @Test + fun media3ContainersExcludeBdavTransportStreams() { + // Media3's TsExtractor reads 188-byte packets only; Blu-ray/AVCHD + // m2ts/mts use 192-byte packets and must be remuxed by the server. + listOf("m2ts", "mts", ".M2TS", " MTS ") + .forEach { container -> + assertFalse(normalizedPlaybackContainer(container) in media3OriginalPlaybackContainers, "container=$container") + } + } } diff --git a/docs/playback/02-migration-compatibility-validation.md b/docs/playback/02-migration-compatibility-validation.md index b73fd3bc0a..d77fb3f2dd 100644 --- a/docs/playback/02-migration-compatibility-validation.md +++ b/docs/playback/02-migration-compatibility-validation.md @@ -210,7 +210,8 @@ least: | Fixture | Required observation | | --- | --- | | H.264/HEVC SDR in MP4, MKV, and AVI | Direct play only for extractor/codec/seek combinations that pass; otherwise declared adaptation. | -| MOV/QT and TS/M2TS | Test direct, seeking, tracks, subtitles, and recovery; do not force remux solely from the extension. | +| MOV/QT and TS | Test direct, seeking, tracks, subtitles, and recovery; do not force remux solely from the extension. | +| M2TS/MTS (BDAV, 192-byte packets) | Expect server adaptation (remux), not direct play: Media3 cannot read 192-byte packets, so the client does not claim these containers. Test seeking, tracks, subtitles, and recovery on the adapted stream. | | HDR10, HDR10+, and HLG | A claimed HDR output requires correct planned delivery plus display/TV evidence, including after server adaptation. Without external evidence, record only planned/effective range. | | DV P5, P7 MEL, P7 FEL, and every P8 variant advertised by the server | The fixture manifest enumerates each claimed variant; initially include P8.1, P8.2, and P8.4 if advertised. Test decoder, plan, display mode, and visible output separately. Unsupported cases use a validated server transformation or `adaptation_unavailable`. | | E-AC-3/JOC, TrueHD/Atmos, DTS-HD, AC-3, and AAC | Pass when an advertised encoding/layout produces AVR-confirmed bitstream; otherwise the plan must declare PCM or server adaptation with no unclassified failure. | From dc8f0b52566f61f1575b8f72b05cb84b5d0de041 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:23:02 -0400 Subject: [PATCH 22/87] fix(player): reload a subtitle the server retimed during playback (#443) When the server syncs or resets a stored subtitle's timing, it sends subtitle_timing_changed to the file's playback sessions and serves the new timing from the same sidecar URL. Media3 keeps the cues it already parsed, so a subtitle selected before its sync finished stayed off by the old offset until playback restarted. Handle the event on phone and TV: when the retimed subtitle is the mounted sidecar, remount the same item at the same position so the cues are fetched again. Stored subtitles are matched by download ID or by the downloaded_subtitle_id the server pins on a v3 sidecar URL. Refs #442 Co-authored-by: Claude Opus 5.5 (1M context) --- .../player/PlaybackRealtimeController.kt | 3 ++ .../ui/screens/player/PlayerViewModel.kt | 22 ++++++++ .../player/TvPlaybackRealtimeController.kt | 3 ++ .../tv/ui/screens/player/TvPlayerViewModel.kt | 22 ++++++++ .../player/TvSubtitleTransactionAdapter.kt | 11 ++++ .../playback/PlaybackSubtitleTimingChanged.kt | 46 +++++++++++++++++ .../PlaybackSubtitleTimingChangedTest.kt | 51 +++++++++++++++++++ 7 files changed, 158 insertions(+) create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChangedTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt index 6035ad2921..776fd905bb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt @@ -5,6 +5,7 @@ import org.siloserver.silo.network.PlaybackRealtimeEvent import org.siloserver.silo.playback.PlaybackAction import org.siloserver.silo.playback.decodeMarkersUpdate import org.siloserver.silo.playback.decodePlaybackSubtitleReady +import org.siloserver.silo.playback.decodePlaybackSubtitleTimingChanged import org.siloserver.silo.playback.decidePlaybackAction import org.siloserver.silo.playback.isTransport import kotlinx.coroutines.CancellationException @@ -91,6 +92,8 @@ class PlaybackRealtimeController( private fun handleServerEvent(event: PlaybackRealtimeEvent.ServerEvent) { when (event.name) { "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) + "subtitle_timing_changed" -> + viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) "markers_updated" -> { val markers = decodeMarkersUpdate(event) viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 8f6191ceb8..1fc7201781 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -23,6 +23,7 @@ import org.siloserver.silo.common.player.PlaybackSessionLifecycle import org.siloserver.silo.common.player.PlaybackSessionManager import org.siloserver.silo.common.player.PlaybackTeardownGate import org.siloserver.silo.common.player.VideoSessionStartV3 +import org.siloserver.silo.common.player.subtitlesForVideoMediaMount import org.siloserver.silo.common.player.cast.CastMediaSpec import org.siloserver.silo.common.player.cast.CastPrepareRequest import org.siloserver.silo.common.player.PlayerNotice @@ -75,6 +76,8 @@ import org.siloserver.silo.model.playback.rebaseDownloadedSubtitleUrl import org.siloserver.silo.model.playback.resolvedSelectedSubtitleIndex import org.siloserver.silo.model.playback.resolvePlaybackStartPosition import org.siloserver.silo.playback.PlaybackSubtitleReady +import org.siloserver.silo.playback.PlaybackSubtitleTimingChanged +import org.siloserver.silo.playback.affects import org.siloserver.silo.playback.applyAuthoritativeSubtitleReadyTrack import org.siloserver.silo.model.subtitles.SubtitleAiJob import org.siloserver.silo.model.subtitles.SubtitleAiQuota @@ -3799,6 +3802,25 @@ class PlayerViewModel( } } + /** + * The server retimed a stored subtitle (sync or timing reset). Media3 + * keeps the cues it already parsed, so when that subtitle is the mounted + * sidecar, remount the same item at the same position to fetch it again. + */ + fun applySubtitleTimingChanged(update: PlaybackSubtitleTimingChanged) { + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + val mounted = subtitlesForVideoMediaMount( + subtitles = state.subtitleTracks, + playbackPlan = state.playbackPlan, + subtitleIdentity = state.localSubtitleMountIdentity ?: state.committedSubtitleIdentity, + ) + if (!update.affects(mounted)) return + _uiState.update { it.copy(subtitleRefreshNonce = it.subtitleRefreshNonce + 1) } + } + private suspend fun doRefreshSubtitles(autoSelectSubtitleId: Int?) { val state = _uiState.value val mediaFileId = state.mediaFileId ?: return diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt index 824e6030d3..01b5101544 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt @@ -5,6 +5,7 @@ import org.siloserver.silo.network.PlaybackRealtimeEvent import org.siloserver.silo.playback.PlaybackAction import org.siloserver.silo.playback.decodeMarkersUpdate import org.siloserver.silo.playback.decodePlaybackSubtitleReady +import org.siloserver.silo.playback.decodePlaybackSubtitleTimingChanged import org.siloserver.silo.playback.decidePlaybackAction import org.siloserver.silo.playback.isTransport import kotlinx.coroutines.CancellationException @@ -89,6 +90,8 @@ class TvPlaybackRealtimeController( private suspend fun handleServerEvent(event: PlaybackRealtimeEvent.ServerEvent) { when (event.name) { "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) + "subtitle_timing_changed" -> + viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) "markers_updated" -> { val markers = decodeMarkersUpdate(event) viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 23848bd806..62cbd30cd0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -12,6 +12,7 @@ import org.siloserver.silo.tv.BuildConfig import android.os.SystemClock import android.util.Log import org.siloserver.silo.common.player.SubDiag +import org.siloserver.silo.common.player.subtitlesForVideoMediaMount import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.siloserver.silo.tv.data.preferences.PlaybackQuality @@ -103,6 +104,8 @@ import org.siloserver.silo.model.playback.enrichAuthoritativePlaybackSubtitleCho import org.siloserver.silo.model.playback.resolvedSelectedSubtitleIndex import org.siloserver.silo.model.playback.mergeDownloadedSubtitles import org.siloserver.silo.playback.PlaybackSubtitleReady +import org.siloserver.silo.playback.PlaybackSubtitleTimingChanged +import org.siloserver.silo.playback.affects import org.siloserver.silo.playback.applyAuthoritativeSubtitleReadyTrack import org.siloserver.silo.model.subtitles.SubtitleAiQuota import org.siloserver.silo.model.subtitles.SubtitleAiStatus @@ -5061,6 +5064,25 @@ class TvPlayerViewModel( return applied } + /** + * The server retimed a stored subtitle (sync or timing reset). Media3 + * keeps the cues it already parsed, so when that subtitle is the mounted + * sidecar, remount the same item at the same position to fetch it again. + */ + internal fun applySubtitleTimingChanged(update: PlaybackSubtitleTimingChanged) { + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + val mounted = subtitlesForVideoMediaMount( + subtitles = state.subtitleUrls, + playbackPlan = state.playbackPlan, + subtitleIdentity = state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity, + preferMuxedTracks = true, + ) + if (update.affects(mounted)) subtitleTransactions.remountSubtitles() + } + // ---- Subtitle suite: AI translate / transcribe ------------------------------- fun refreshAiQuota() { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt index db7055d788..0f045c911a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt @@ -922,6 +922,17 @@ internal class TvSubtitleTransactionAdapter( return true } + /** + * Remounts the unchanged subtitle list so a sidecar whose content changed + * behind the same URL (a server timing change) is fetched again. It does + * not start a refresh, so an in-flight download refresh keeps ownership. + */ + fun remountSubtitles() { + if (context == null) return + subtitleRefreshNonce += 1 + publish() + } + fun completeRefreshFailure(owner: TvSubtitleRefreshOwner, message: String): Boolean { if (!ownsRefresh(owner)) return false refreshGeneration += 1 diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt new file mode 100644 index 0000000000..b0889ca5cf --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt @@ -0,0 +1,46 @@ +package org.siloserver.silo.playback + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.network.PlaybackRealtimeEvent + +/** + * `subtitle_timing_changed`: the server retimed a stored subtitle of the file + * (a sync result or a timing reset). The track's sidecar URL is unchanged but + * now serves the new timing, so a player showing it must fetch it again. + */ +data class PlaybackSubtitleTimingChanged( + val sessionId: String?, + val mediaFileId: Int?, + val subtitleId: Int?, +) + +fun decodePlaybackSubtitleTimingChanged(event: PlaybackRealtimeEvent.ServerEvent): PlaybackSubtitleTimingChanged = + decodePlaybackSubtitleTimingChanged(event.payload) + +fun decodePlaybackSubtitleTimingChanged(payload: JsonObject): PlaybackSubtitleTimingChanged = + PlaybackSubtitleTimingChanged( + sessionId = (payload["session_id"] as? JsonPrimitive)?.contentOrNull, + mediaFileId = (payload["file_id"] as? JsonPrimitive)?.intOrNull, + subtitleId = (payload["subtitle_id"] as? JsonPrimitive)?.intOrNull, + ) + +private val storedSubtitleIdParam = Regex("[?&]downloaded_subtitle_id=([1-9][0-9]*)(?:[&#]|$)") + +/** + * The stored (downloaded or uploaded) subtitle behind this row, or null for + * embedded, external, and generated-live tracks. Rows merged from the stored + * list carry it as [PlayerSubtitleInfo.downloadId]; v3 inventory rows carry it + * only as the `downloaded_subtitle_id` the server pins on their sidecar URL. + */ +fun PlayerSubtitleInfo.storedSubtitleId(): Int? = + downloadId ?: storedSubtitleIdParam.find(url)?.groupValues?.get(1)?.toIntOrNull() + +/** True when [mounted] includes the stored subtitle this event retimed. */ +fun PlaybackSubtitleTimingChanged.affects(mounted: List): Boolean { + val id = subtitleId ?: return false + return mounted.any { it.storedSubtitleId() == id } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChangedTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChangedTest.kt new file mode 100644 index 0000000000..415ddaeaca --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChangedTest.kt @@ -0,0 +1,51 @@ +package org.siloserver.silo.playback + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlaybackSubtitleTimingChangedTest { + private fun sidecar(url: String, downloadId: Int? = null) = + PlayerSubtitleInfo(index = 3, source = "downloaded", url = url, downloadId = downloadId) + + @Test + fun decodesTheServerPayload() { + val update = decodePlaybackSubtitleTimingChanged( + buildJsonObject { + put("session_id", "s") + put("file_id", 42) + put("subtitle_id", 9) + }, + ) + assertEquals(PlaybackSubtitleTimingChanged("s", 42, 9), update) + } + + @Test + fun readsTheStoredIdPinnedOnAV3SidecarUrl() { + assertEquals( + 9, + sidecar("/stream/s/subtitles/3.vtt?file_id=42&downloaded_subtitle_id=9").storedSubtitleId(), + ) + assertEquals( + 9, + sidecar("/stream/s/subtitles/3.vtt?downloaded_subtitle_id=9&file_id=42").storedSubtitleId(), + ) + assertEquals(17, sidecar("/stream/s/subtitles/3.vtt", downloadId = 17).storedSubtitleId()) + assertNull(sidecar("/stream/s/subtitles/3.vtt?file_id=42").storedSubtitleId()) + assertEquals(90, sidecar("/stream/s/subtitles/3.vtt?downloaded_subtitle_id=90").storedSubtitleId()) + } + + @Test + fun affectsOnlyTheMountedStoredSubtitle() { + val update = PlaybackSubtitleTimingChanged("s", 42, 9) + assertTrue(update.affects(listOf(sidecar("/stream/s/subtitles/3.vtt?file_id=42&downloaded_subtitle_id=9")))) + assertFalse(update.affects(listOf(sidecar("/stream/s/subtitles/3.vtt?file_id=42&downloaded_subtitle_id=90")))) + assertFalse(update.affects(emptyList())) + assertFalse(update.copy(subtitleId = null).affects(listOf(sidecar("/x?downloaded_subtitle_id=9")))) + } +} From 98b8cea5f08b432eda046d3d0dcd7036703799d9 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:27:47 -0400 Subject: [PATCH 23/87] feat(subtitles): show sync status and sync/reset actions for stored subtitles (#446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(player): reload a subtitle the server retimed during playback When the server syncs or resets a stored subtitle's timing, it sends subtitle_timing_changed to the file's playback sessions and serves the new timing from the same sidecar URL. Media3 keeps the cues it already parsed, so a subtitle selected before its sync finished stayed off by the old offset until playback restarted. Handle the event on phone and TV: when the retimed subtitle is the mounted sidecar, remount the same item at the same position so the cues are fetched again. Stored subtitles are matched by download ID or by the downloaded_subtitle_id the server pins on a v3 sidecar URL. Refs #442 Co-Authored-By: Claude Opus 5.5 (1M context) * feat(subtitles): show sync status and sync/reset actions for stored subtitles silo-server#1783 aligns stored subtitles to the file's audio and lets the account that added a subtitle, or an admin, start a sync or reset its timing. The Android players showed none of it. Phone and TV now read each stored subtitle's timing and latest sync job and show it in the web player's words ("Syncing…", "Synced −3.2 s", "Synced −23.7 s · ×1.0008 speed", "Already in sync", "Doesn't match this video", "Sync failed"). For the selected stored subtitle they offer "Sync subtitle" and "Reset timing": in the phone's Audio & Subtitles sheet, and as a Timing row in the TV HUD's Subtitles pane. A running job is polled until it ends. A refusal (403) replaces the actions with a short explanation; unsupported formats (422) and stale versions (412) say so. A shared StoredSubtitleSyncController holds the state for both apps; SubtitleSyncV2Api covers the capability probe, sync, sync status, and the If-Match timing reset. The TV picker no longer marks an option selected when none is current, so an action menu shows no checkmark. Refs #442 Co-Authored-By: Claude Opus 5.5 (1M context) * feat(subtitles): sync subtitle files next to the media, with live progress, on phone and TV (#463) * fix(downloads): refresh saved subtitles the server retimed A video download saves its subtitle sidecars once, when it completes. When the server later syncs or resets a stored subtitle's timing, the download's manifest lists that subtitle at a new revision, but the saved copy kept the old timing, so offline playback stayed out of sync. Saved sidecars now record the manifest fetch_url and revision. A periodic worker (every 12 hours with a network) re-reads the manifest of each completed download of the active login and profile, re-fetches only the sidecars whose revision changed, replaces each file in place, and records the new revision. Captures saved before this change carry no fetch_url and are left as they are. Refs #442 Co-Authored-By: Claude Opus 5.5 (1M context) * feat(subtitles): sync subtitle files next to the media, with live progress, on phone and TV The server now syncs any playable subtitle to the file's audio, stored or a sidecar, keyed by the inventory's sync_key, and reports each step over the playback socket. Follow that on phone and TV: - Shared: sync API by sync key (list, read, start, set timing with If-Match), sync_key on inventory rows, subtitle_sync_updated and keyed subtitle_timing_changed decoding, SubtitleSyncController (realtime with a 3 s polling fallback, jobs this viewer started, one cue reload per timing change), and a port of the web player's feedback state machine. - Phone: per-track status lines, a Timing section with progress, phase, result, reset and the sidecar note, and a non-blocking status card. - TV: status lines in the track picker, a Timing row with progress and result, and the same card while the HUD is closed. The HUD sync state is built in the view model to keep TvPlayerScreen within ART's JIT limit. - Offline: saved sidecars refresh on a new manifest revision too, but not when their position now names another file. - Player: a sidecar remount right after a track switch no longer crashes in NonGatingSidecarPeriod (continueLoading without a pending reset). Refs #442 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(subtitles): name merged stored subtitles for sync Rows merged from the stored-subtitle list (sessions without an inventory, the phone's fresh-subtitle restore, and TV refreshes) carried no sync_key, so they lost the stored-subtitle status and timing actions. Name each one stored-{id}, which the server treats as equivalent, for the formats it can retime. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): show sync status for merged stored subtitles in the track picker A row merged from the stored-subtitle list maps to a Downloaded identity, which the HUD's status lookup ignored, so the picker showed no status for it. Look those rows up by stored subtitle ID. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(subtitles): follow the selected track and wait for its cues in sync feedback - The track on screen is the selected subtitle when the player has it mounted. A legacy session mounts every row, and taking the first mounted key could point the feedback at the wrong track. - A mount's cue revisions count as loaded once Media3 reports a subtitle track selected on the live player, not as soon as the mount returns, so "Subtitles synced" waits for the reloaded track. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): refresh a positional sidecar only while its row still describes it An external:{index} ref names a sidecar by its position, which can come to name another file when sidecars are added or removed. Refresh such a row only while it lists the language, title, format, and forced and SDH flags the saved file was captured with. Stored downloaded:{id} refs are stable and skip the check. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): publish refreshed subtitles only while the download is unchanged The refresh worker wrote each re-fetched sidecar over the saved file before it checked that the download was still the one it read. A download replaced or deleted meanwhile could have a fresh capture's subtitle overwritten with the old download's bytes, leaving its recorded revision wrong. Stage the re-fetched sidecars beside the saved files instead. The worker publishes them inside its generation and ownership check, and only over a saved copy that is unchanged since staging; whatever is not published is deleted. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(subtitles): keep the newer sync state when responses and updates cross The server doesn't sequence its HTTP responses and realtime updates. A start response that left before a fast job finished could arrive after the outcome and rewind it to "pending": the status went back to Syncing, the track reloaded twice, and the card could stay on its progress. Ignore a snapshot of an older job, or of the same job further back, while still following the job if this viewer started it. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): hold one lock per download while subtitles are captured, refreshed, or deleted A subtitle refresh checked the download's row and its saved file, then replaced the file and rewrote the row in later steps. A track capture for the same download could write new sidecars in between, and the refresh would move the old subtitle's bytes over them. A delete landing between the refresh's read and write let the refresh write the deleted row back. The capture, the refresh, and both delete paths now hold a process-wide lock per (server, profile, file) across their check and writes. Each takes it before the identity barrier or a database transaction. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): record refreshed subtitle revisions only while the row is unchanged The subtitle refresh checked the download's row and wrote its new revisions in separate statements. Re-downloading the same file writes the replacement row without the identity barrier or the slot lock, so a replacement landing between them was overwritten with the old download. The check, the file publish, and the row write now run in one Room transaction (DownloadMetadataStore.updateSidecarIf), like completePendingDeletion. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(subtitles): ignore a read sent before the subtitle state already shown Only job progress decided whether a response was stale, so a response with the same job, or none, always replaced the entry. Two quick timing changes, such as an adjustment and then a reset, start two reads; if the first one answered last, its old timing came back and the track reloaded again. The controller now numbers each read as it is sent and each realtime update as it arrives. A response from a read sent before the view on screen is ignored, unless it reports a job that view has not reached: the server can only have produced that later, and it may be a job this viewer just started. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- FEATURES.md | 1 + .../downloads/OfflineSubtitleRefreshWorker.kt | 9 +- .../downloads/OfflineTrackAssetFetcher.kt | 8 +- .../silo/common/player/PlaybackV3Session.kt | 1 + .../silo/common/player/SubtitleSyncNames.kt | 9 + .../subtitle/SidecarSubtitleMediaSource.kt | 25 +- .../downloads/OfflineTrackAssetFetcherTest.kt | 49 ++ .../SidecarSubtitleMediaSourceTest.kt | 29 ++ .../player/PlaybackRealtimeController.kt | 3 + .../ui/screens/player/PlayerOverlay.kt | 23 + .../android/ui/screens/player/PlayerScreen.kt | 3 + .../ui/screens/player/PlayerViewModel.kt | 137 +++++- .../ui/screens/player/SubtitleSyncCard.kt | 151 ++++++ .../android/ui/screens/player/TracksSheet.kt | 96 +++- .../player/TvPlaybackRealtimeController.kt | 3 + .../silo/tv/ui/screens/player/TvPlayerHud.kt | 146 +++++- .../tv/ui/screens/player/TvPlayerScreen.kt | 31 ++ .../tv/ui/screens/player/TvPlayerViewModel.kt | 147 +++++- .../ui/screens/player/TvSubtitleHudState.kt | 19 + .../ui/screens/player/TvSubtitleSyncCard.kt | 124 +++++ .../screens/player/TvSubtitleHudStateTest.kt | 12 + .../org/siloserver/silo/di/NetworkModule.kt | 1 + .../siloserver/silo/di/RepositoryModule.kt | 2 +- .../silo/model/download/OfflineTracks.kt | 35 +- .../silo/model/playback/PlaybackModels.kt | 2 + .../silo/model/playback/PlaybackProtocolV3.kt | 5 + .../silo/model/playback/SubtitleTrackMerge.kt | 8 + .../silo/model/subtitles/SubtitleModels.kt | 3 + .../silo/model/subtitles/SubtitleSync.kt | 218 +++++++++ .../network/apiv2/SubtitleDownloadV2Api.kt | 7 +- .../silo/network/apiv2/SubtitleSyncV2Api.kt | 104 ++++ .../silo/playback/PlaybackSubtitleReady.kt | 1 + .../playback/PlaybackSubtitleTimingChanged.kt | 60 ++- .../silo/playback/SubtitleSyncController.kt | 458 ++++++++++++++++++ .../silo/playback/SubtitleSyncFeedback.kt | 309 ++++++++++++ .../silo/repository/SubtitlesRepository.kt | 37 +- .../silo/model/download/OfflineTracksTest.kt | 33 ++ .../model/playback/SubtitleTrackMergeTest.kt | 16 + .../model/subtitles/SubtitleSyncLabelTest.kt | 62 +++ .../model/subtitles/SubtitleSyncStateTest.kt | 117 +++++ .../silo/network/apiv2/SubtitleSyncV2Test.kt | 146 ++++++ .../PlaybackSubtitleSyncEventsTest.kt | 99 ++++ .../playback/SubtitleSyncControllerTest.kt | 325 +++++++++++++ .../silo/playback/SubtitleSyncFeedbackTest.kt | 143 ++++++ 44 files changed, 3148 insertions(+), 69 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleSyncNames.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSyncCard.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSyncCard.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleSync.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Api.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncController.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedback.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncLabelTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncStateTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Test.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleSyncEventsTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncControllerTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedbackTest.kt diff --git a/FEATURES.md b/FEATURES.md index fc6270559d..98f4fbc841 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -33,6 +33,7 @@ File pointers are repository-relative. | HDMI EDID-driven display mode | ➖ | ✅ | `HdrDisplayController` | | Subtitle selection + styling (font/bg/position) | ✅ | ✅ | Media3 `SubtitleManager`; server plans render, convert, or burn-in fidelity | | Subtitle sync offset (±10s) / audio sync (±5s) | ✅ | ✅ | Per-profile | +| Server subtitle sync to audio (stored and sidecar subtitles) | ✅ | ✅ | Server-gated; per-track status, progress card, reset; retimed tracks reload in place; phone offline copies refresh. `SubtitleSyncController` | | Subtitle provider search + download | ✅ | ✅ | | | AI subtitle transcription / translation (quota-tracked) | ✅ | ✅ | TV: `TvAiTranslateDialog` | | AI description translation (on-view, server-gated) | ✅ | ✅ | `DescriptionTranslationController`; gated by `/api/v1/metadata/ai/status`; metadata-language setting in Settings | diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt index 0b897a16b8..33a054b3a8 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineSubtitleRefreshWorker.kt @@ -26,10 +26,11 @@ import java.util.concurrent.TimeUnit * Keeps saved subtitle sidecars in step with the server's timing corrections. * * A completed video download keeps the subtitle sidecars it fetched when it - * finished. When the server later retimes a stored subtitle (an automatic or - * manual sync, or a timing reset), the download's manifest lists that subtitle - * at a new `revision`; this worker re-fetches those sidecars and replaces the - * saved copies, so the next offline playback uses the corrected timing. + * finished. When the server later retimes one of its subtitles, stored or a + * file next to the media (an automatic or manual sync, or a timing set or + * reset), the download's manifest lists that subtitle at a new `revision`; + * this worker re-fetches those sidecars and replaces the saved copies, so the + * next offline playback uses the corrected timing. * * It runs periodically while the device has a network, for the downloads of * the login and profile that are active when it fires, like diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt index b830e9b2cb..bbf4f042ff 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt @@ -244,10 +244,10 @@ internal class OfflineTrackAssetFetcher( } /** - * Re-fetches the saved sidecars whose stored subtitle the server has - * retimed since they were saved (its manifest `revision` changed) into - * staging files beside them. Nothing is replaced until the caller has - * confirmed the download is still the one [tracks] describes and calls + * Re-fetches the saved sidecars whose subtitle the server has retimed + * since they were saved (its manifest `revision` changed) into staging + * files beside them. Nothing is replaced until the caller has confirmed + * the download is still the one [tracks] describes and calls * [StagedSubtitleRefresh.publish]. Returns null when nothing changed or the * manifest is unavailable. A sidecar that fails to fetch keeps its old file * and revision, so the next refresh tries it again. diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt index a883a856ac..3f2c9a608c 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt @@ -114,6 +114,7 @@ internal fun PlaybackPlanV3.toSessionResponse( it == SUBTITLE_DELIVERY_SIDECAR || it == SUBTITLE_DELIVERY_BURN_IN_ONLY }, + syncKey = item.syncKey, ) } .distinctBy(PlayerSubtitleInfo::index) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleSyncNames.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleSyncNames.kt new file mode 100644 index 0000000000..6e53280ef5 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleSyncNames.kt @@ -0,0 +1,9 @@ +package org.siloserver.silo.common.player + +import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.player.trackLanguageDisplayName + +/** The subtitle's language name for sync feedback ("English"), or null when it has none. */ +fun subtitleSyncName(tracks: List, subtitle: SubtitleSyncState): String? = + trackLanguageDisplayName(tracks.firstOrNull { it.syncKey == subtitle.key }?.language ?: subtitle.language) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt index 3e468d518f..83ed3148d9 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt @@ -113,13 +113,24 @@ internal class NonGatingSidecarPeriod( */ private fun kickDelegate(reason: String) { if (delegate.isLoading) return - val continued = delegate.continueLoading( - LoadingInfo.Builder() - .setPlaybackPositionUs(floor.get()) - .setPlaybackSpeed(1f) - .setLastRebufferRealtimeMs(C.TIME_UNSET) - .build(), - ) + val loadingInfo = LoadingInfo.Builder() + .setPlaybackPositionUs(floor.get()) + .setPlaybackSpeed(1f) + .setLastRebufferRealtimeMs(C.TIME_UNSET) + .build() + val continued = try { + delegate.continueLoading(loadingInfo) + } catch (_: IllegalStateException) { + // A prepared ProgressiveMediaPeriod only starts a new load from a + // pending reset. A load cancelled because its track was disabled + // has none, and when the track is enabled again before that + // cancel lands (a subtitle remount reselecting the same track), + // the cancel's continue request reaches here without one. Restart + // the load from the playhead, as a seek would. + org.siloserver.silo.common.player.SubDiag.log("sidecar restart($reason) floor=${floor.get() / 1000}ms") + delegate.seekToUs(floor.get()) + delegate.continueLoading(loadingInfo) + } org.siloserver.silo.common.player.SubDiag.log( "sidecar kick($reason) continued=$continued floor=${floor.get() / 1000}ms", ) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt index 5d0e360a40..ec9f6bdc00 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -122,6 +122,55 @@ class OfflineTrackAssetFetcherTest { } } + /** + * A sidecar next to the media is served with its timing correction + * applied. When the server retimes it after the download, the manifest + * lists it at a new revision and a refresh replaces the saved file. + */ + @Test + fun refreshesASavedExternalSidecarTheServerRetimed() = runBlocking { + var revision = "r1" + var cue = "00:00:42,148 --> 00:00:44,517" + val subtitleFetches = mutableListOf() + val http = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + when { + path.endsWith("/manifest") -> respond( + """{"subtitles": [{"language": "en", "format": "srt", "external": true, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0", "revision": "$revision"}]}""", + HttpStatusCode.OK, + ) + path.endsWith("/subtitles/external:0") -> { + subtitleFetches += revision + respond("1\n$cue\nFor a moment, everyone was silent.\n") + } + else -> respond("", HttpStatusCode.NotFound) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://silo.example/") } + } + val fetcher = OfflineTrackAssetFetcher(http, DownloadStorage(tmp.newFolder("filesDir"))) + val saved = assertNotNull(fetcher.fetch("dl_1", "srv", "prof", 42) {}).tracks + assertEquals("r1", saved.subtitles.single().revision) + + // Nothing changed on the server: nothing is fetched. + assertNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + assertEquals(listOf("r1"), subtitleFetches) + + revision = "r2" + cue = "00:00:39,138 --> 00:00:41,507" + val staged = assertNotNull(fetcher.stageSubtitleRefresh("dl_1", saved) {}) + val refreshed = assertNotNull(staged.publish()) + staged.discard() + assertEquals("r2", refreshed.subtitles.single().revision) + assertEquals(saved.subtitles.single().path, refreshed.subtitles.single().path) + assertTrue(File(refreshed.subtitles.single().path).readText().contains(cue)) + http.close() + } + @Test fun aRetimedSidecarIsStagedAndReplacesTheSavedCopyOnlyWhenPublished() = runBlocking { val server = RetimingServer() diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt index 8fc255d5ea..ba34420eeb 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt @@ -97,6 +97,29 @@ class SidecarSubtitleMediaSourceTest { assertEquals(3_000_000L, delegate.continueLoadingCalls.last().playbackPositionUs) } + /** + * A remount that reselects the same subtitle disables the sidecar's track + * and enables it again before the cancelled load lands. Its continue + * request then finds no pending reset, which a prepared + * ProgressiveMediaPeriod asserts on (a player crash). The wrapper restarts + * the load from the playhead instead. + */ + @Test + fun restartsFromThePlayheadWhenACancelledLoadHasNoPendingReset() { + val delegate = FakePeriod().apply { requirePendingReset = true } + val floor = SidecarPlaybackFloor() + val period = NonGatingSidecarPeriod(delegate, floor) + val upstream = RecordingCallback() + period.prepare(upstream, 0L) + floor.set(42_000_000L) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(42_000_000L, delegate.lastSeekUs) + assertEquals(2, delegate.continueLoadingCalls.size) + assertSame(period, upstream.continueLoadingRequestedFrom.single()) + } + @Test fun publishesTheLivePositionAndSeeksAsTheFloor() { val delegate = FakePeriod() @@ -145,6 +168,9 @@ class SidecarSubtitleMediaSourceTest { var loading = false var lastSeekUs = C.TIME_UNSET val continueLoadingCalls = mutableListOf() + /** Models a prepared ProgressiveMediaPeriod: a new load needs a pending reset. */ + var requirePendingReset = false + private var pendingReset = false override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) { this.callback = callback @@ -164,6 +190,7 @@ class SidecarSubtitleMediaSourceTest { override fun readDiscontinuity(): Long = C.TIME_UNSET override fun seekToUs(positionUs: Long): Long { lastSeekUs = positionUs + pendingReset = true return positionUs } @@ -174,6 +201,8 @@ class SidecarSubtitleMediaSourceTest { override fun getNextLoadPositionUs(): Long = 0L override fun continueLoading(loadingInfo: LoadingInfo): Boolean { continueLoadingCalls += loadingInfo + check(!requirePendingReset || pendingReset) + pendingReset = false return true } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt index 776fd905bb..c87e54c623 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt @@ -5,6 +5,7 @@ import org.siloserver.silo.network.PlaybackRealtimeEvent import org.siloserver.silo.playback.PlaybackAction import org.siloserver.silo.playback.decodeMarkersUpdate import org.siloserver.silo.playback.decodePlaybackSubtitleReady +import org.siloserver.silo.playback.decodePlaybackSubtitleSyncUpdated import org.siloserver.silo.playback.decodePlaybackSubtitleTimingChanged import org.siloserver.silo.playback.decidePlaybackAction import org.siloserver.silo.playback.isTransport @@ -94,6 +95,8 @@ class PlaybackRealtimeController( "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) "subtitle_timing_changed" -> viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) + "subtitle_sync_updated" -> + viewModel.applySubtitleSyncUpdated(decodePlaybackSubtitleSyncUpdated(event)) "markers_updated" -> { val markers = decodeMarkersUpdate(event) viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index 784e59505c..47a727006c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -52,6 +52,8 @@ import org.siloserver.silo.model.watchtogether.RoomPlaybackState import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.watchtogether.RoomTransportIntent import org.siloserver.silo.watchtogether.roomTransportAuthorized +import org.siloserver.silo.playback.statusLabelFor +import org.siloserver.silo.playback.timingActionsFor /** * Full-screen overlay composable that layers gesture handling, transport controls, @@ -164,6 +166,8 @@ fun PlayerOverlay( val notice by viewModel.notice.collectAsState() val sessionState by viewModel.sessionState.collectAsState() val subtitleTools by viewModel.subtitleTools.collectAsState() + val subtitleSync by viewModel.subtitleSyncState.collectAsState() + val subtitleSyncNotice by viewModel.subtitleSyncNotice.collectAsState() // Pinch-to-scale (iOS parity): pinch-out steps Fit -> Fill -> Stretch, // pinch-in steps back, clamped at both ends. No-op steps (already at an // end) skip the toast so a clamped pinch stays quiet. @@ -270,6 +274,19 @@ fun PlayerOverlay( PlayerNoticeOverlay(notice = notice) } + // Subtitle sync card (top-right, below the top bar's actions): follows + // a sync this viewer started, whether or not the controls show. + Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(top = 72.dp, end = 16.dp) + .zIndex(9f), + contentAlignment = Alignment.TopEnd, + ) { + SubtitleSyncCard(notice = subtitleSyncNotice, onDismiss = viewModel::dismissSubtitleSyncNotice) + } + // Remote-control "display_message" toast (top-center), shown for a few // seconds regardless of controls visibility. zIndex above the controls // layer + WT badge so it's never obscured. @@ -536,6 +553,12 @@ fun PlayerOverlay( tracksSheetVisible = false aiTranslateVisible = true }, + subtitleStatus = subtitleSync::statusLabelFor, + timingActions = subtitleSync.timingActionsFor( + state.subtitleTracks.getOrNull(state.selectedSubtitleIndex)?.syncKey, + ), + onSyncSubtitle = viewModel::requestSubtitleSync, + onResetTiming = viewModel::resetSubtitleTiming, tabletopPaneHeight = tabletopPaneHeight, ) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt index ba382e7367..4bee4be2f6 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt @@ -777,6 +777,7 @@ fun PlayerScreen( viewModel.onSubtitleMediaMountChanging() backend.mount(mediaSpec, playWhenReady = !viewModel.uiState.value.isPaused) viewModel.onSubtitleMediaMountApplied(MobileSubtitleMount(uiState.mediaMountGeneration, uiState.subtitleRefreshNonce)) + viewModel.onSubtitleCuesMounted(uiState.subtitleCueRevisions) viewModel.onMediaMountApplied(uiState.mediaMountGeneration) } @@ -845,6 +846,7 @@ fun PlayerScreen( viewModel.onSubtitleMediaMountChanging() backend.refresh(mediaSpec) viewModel.onSubtitleMediaMountApplied(MobileSubtitleMount(uiState.mediaMountGeneration, uiState.subtitleRefreshNonce)) + viewModel.onSubtitleCuesMounted(uiState.subtitleCueRevisions) } // Sync play/pause from ViewModel to player without reclassifying this @@ -1053,6 +1055,7 @@ fun PlayerScreen( val targetIdentity = pendingIdentity ?: liveState.committedSubtitleIdentity val accepted = backend.selectMountedSubtitle(identity = targetIdentity) val selected = isSubtitleSelected(currentTracks, targetIdentity) + if (selected) viewModel.onMountedSubtitleSelected() if (pendingIdentity != null) { viewModel.onPendingSubtitleMountResult( mount = mount, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 1fc7201781..c1616d6e60 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -77,7 +77,16 @@ import org.siloserver.silo.model.playback.resolvedSelectedSubtitleIndex import org.siloserver.silo.model.playback.resolvePlaybackStartPosition import org.siloserver.silo.playback.PlaybackSubtitleReady import org.siloserver.silo.playback.PlaybackSubtitleTimingChanged -import org.siloserver.silo.playback.affects +import org.siloserver.silo.playback.PlaybackSubtitleSyncUpdated +import org.siloserver.silo.playback.SubtitleSyncController +import org.siloserver.silo.playback.SubtitleSyncFeedbackTracker +import org.siloserver.silo.playback.SubtitleSyncNotice +import org.siloserver.silo.playback.SubtitleSyncUiState +import org.siloserver.silo.playback.feedbackInput +import org.siloserver.silo.playback.includesSyncKey +import org.siloserver.silo.playback.activeSyncKey +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.common.player.subtitleSyncName import org.siloserver.silo.playback.applyAuthoritativeSubtitleReadyTrack import org.siloserver.silo.model.subtitles.SubtitleAiJob import org.siloserver.silo.model.subtitles.SubtitleAiQuota @@ -131,6 +140,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map @@ -478,6 +488,12 @@ class PlayerViewModel( * current position. */ val subtitleRefreshNonce: Int = 0, + /** + * Grows per sync key each time a syncable subtitle's timing changed + * and its cues must be fetched again. The mount that carries a + * revision reports it back, so sync feedback knows the new cues show. + */ + val subtitleCueRevisions: Map = emptyMap(), /** * True when [audioTracks] come from a download's offline manifest and * list the local file's own audio tracks in file order, so an audio @@ -821,6 +837,44 @@ class PlayerViewModel( private val _subtitleTools = MutableStateFlow(SubtitleToolsUiState()) val subtitleTools: StateFlow = _subtitleTools.asStateFlow() + // Timing and sync state of the playing file's syncable subtitles, and + // the card that follows a sync this viewer started. + private val subtitleSync = SubtitleSyncController( + subtitlesRepository, + viewModelScope, + onTimingChanged = ::onSubtitleSyncTimingChanged, + ) + val subtitleSyncState: StateFlow = subtitleSync.state + private val subtitleSyncFeedback = SubtitleSyncFeedbackTracker(viewModelScope) + val subtitleSyncNotice: StateFlow = subtitleSyncFeedback.notice + /** The cue revision of each sync key whose cues the player has in use. */ + private val loadedSubtitleCueRevisions = MutableStateFlow>(emptyMap()) + /** Cue revisions of the last subtitle mount, until Media3 selects its subtitle track. */ + private var mountedSubtitleCueRevisions: Map = emptyMap() + + fun requestSubtitleSync(key: String) = subtitleSync.requestSync(key) + + fun resetSubtitleTiming(key: String) = subtitleSync.resetTiming(key) + + fun dismissSubtitleSyncNotice() = subtitleSyncFeedback.dismiss() + + /** + * The player mounted subtitles built from a state carrying [revisions]. + * Media3 fetches them after the mount returns, so they count as loaded + * only once [onMountedSubtitleSelected] reports the track in use. + */ + internal fun onSubtitleCuesMounted(revisions: Map) { + mountedSubtitleCueRevisions = revisions + } + + /** Media3 selected the mounted subtitle track on the live player: its cues are in use. */ + internal fun onMountedSubtitleSelected() { + val revisions = mountedSubtitleCueRevisions + if (revisions.isEmpty()) return + mountedSubtitleCueRevisions = emptyMap() + loadedSubtitleCueRevisions.update { it + revisions } + } + private var aiStatusFetched = false private var searchJob: Job? = null private var aiJobHandle: Job? = null @@ -872,6 +926,38 @@ class PlayerViewModel( .distinctUntilChanged() .collect { org.siloserver.silo.common.player.ActivePlaybackFile.set(it) } } + // Subtitle sync applies to server playback only; offline and local + // files have no inventory sync keys. + viewModelScope.launch { + _uiState + .map { state -> + state.mediaFileId?.takeIf { state.sessionId != null } to + state.subtitleTracks.mapNotNullTo(mutableSetOf()) { it.syncKey } + } + .distinctUntilChanged() + .collect { (mediaFileId, syncKeys) -> subtitleSync.bind(mediaFileId, syncKeys) } + } + viewModelScope.launch { + val screen = _uiState + .map { state -> + val activeKey = state.sessionId?.let { + val selected = state.localSubtitleMountIdentity ?: state.committedSubtitleIdentity + state.subtitleTracks.activeSyncKey( + selected, + mounted = subtitlesForVideoMediaMount( + subtitles = state.subtitleTracks, + playbackPlan = state.playbackPlan, + subtitleIdentity = selected, + ), + ) + } + Triple(activeKey, state.subtitleCueRevisions, state.subtitleTracks) + } + .distinctUntilChanged() + combine(subtitleSync.state, screen, loadedSubtitleCueRevisions) { sync, (activeKey, revisions, tracks), loaded -> + sync.feedbackInput(activeKey, revisions, loaded) { subtitleSyncName(tracks, it) } + }.collect(subtitleSyncFeedback::update) + } // Mirror the screen error into the adb test hook — screen-level // failures (terminal server plans) never reach the Media3 player, so // scripted tests can't see them through player state alone. @@ -3671,6 +3757,8 @@ class PlayerViewModel( * "Translate with AI…" row is hidden (no error surfaced). */ fun onTracksSheetOpened() { + // Opening re-reads sync state so a job that finished meanwhile shows. + subtitleSync.reload() if (aiStatusFetched) return aiStatusFetched = true viewModelScope.launch { @@ -3739,6 +3827,8 @@ class PlayerViewModel( if (generation != subtitleDownloadGeneration || _uiState.value.mediaFileId != mediaFileId || _uiState.value.sessionId != sessionId) return@launch when (r) { is ApiResult.Success -> { + // Follows the automatic sync the server starts for a new download. + subtitleSync.remember(r.data.subtitle) doRefreshSubtitles(autoSelectSubtitleId = r.data.subtitle.id) if (generation != subtitleDownloadGeneration || _uiState.value.mediaFileId != mediaFileId || _uiState.value.sessionId != sessionId) return@launch _subtitleTools.update { it.copy(downloadingKey = null, downloadCompleted = true) } @@ -3803,22 +3893,47 @@ class PlayerViewModel( } /** - * The server retimed a stored subtitle (sync or timing reset). Media3 - * keeps the cues it already parsed, so when that subtitle is the mounted - * sidecar, remount the same item at the same position to fetch it again. + * The server retimed a subtitle of the file, stored or a sidecar (a sync, + * or a timing set or reset). The sync controller re-reads it and reports + * the change through [onSubtitleSyncTimingChanged]. */ fun applySubtitleTimingChanged(update: PlaybackSubtitleTimingChanged) { val state = _uiState.value val sessionId = state.sessionId ?: return if (update.sessionId != null && update.sessionId != sessionId) return if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return - val mounted = subtitlesForVideoMediaMount( - subtitles = state.subtitleTracks, - playbackPlan = state.playbackPlan, - subtitleIdentity = state.localSubtitleMountIdentity ?: state.committedSubtitleIdentity, - ) - if (!update.affects(mounted)) return - _uiState.update { it.copy(subtitleRefreshNonce = it.subtitleRefreshNonce + 1) } + val key = update.syncKey ?: update.subtitleId?.let(SubtitleSyncState::storedKey) ?: return + subtitleSync.timingChanged(key) + } + + /** A step of a sync job of the file (realtime): progress, then the outcome. */ + fun applySubtitleSyncUpdated(update: PlaybackSubtitleSyncUpdated?) { + update ?: return + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + subtitleSync.syncUpdated(update) + } + + /** + * A syncable subtitle's timing changed. Media3 keeps the cues it already + * parsed, so when that subtitle is the mounted sidecar, remount the same + * item at the same position to fetch it again. Its cue revision tells the + * sync feedback when the new cues show. + */ + private fun onSubtitleSyncTimingChanged(key: String) { + _uiState.update { state -> + val mounted = state.sessionId != null && subtitlesForVideoMediaMount( + subtitles = state.subtitleTracks, + playbackPlan = state.playbackPlan, + subtitleIdentity = state.localSubtitleMountIdentity ?: state.committedSubtitleIdentity, + ).includesSyncKey(key) + state.copy( + subtitleCueRevisions = state.subtitleCueRevisions + (key to (state.subtitleCueRevisions[key] ?: 0) + 1), + subtitleRefreshNonce = if (mounted) state.subtitleRefreshNonce + 1 else state.subtitleRefreshNonce, + ) + } } private suspend fun doRefreshSubtitles(autoSelectSubtitleId: Int?) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSyncCard.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSyncCard.kt new file mode 100644 index 0000000000..362902af1b --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSyncCard.kt @@ -0,0 +1,151 @@ +package org.siloserver.silo.android.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.siloserver.silo.playback.SubtitleSyncNotice + +private val SyncSuccessColor = Color(0xFF6EE7B7) +private val SyncInfoColor = Color(0xFF7DD3FC) +private val SyncWarningColor = Color(0xFFFCD34D) + +/** + * A small card that follows a subtitle sync this viewer started: its progress + * while it runs, then how it ended. It does not block the player; a finished + * card leaves on its own, or on a tap. + */ +@Composable +fun SubtitleSyncCard( + notice: SubtitleSyncNotice?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + // Keyed by the notice id, so one job's progress updates the card in place + // and a finished card fades out with its last content. + AnimatedContent( + targetState = notice, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentKey = { it?.id }, + label = "SubtitleSyncCard", + modifier = modifier, + ) { current -> + if (current != null) SubtitleSyncCardContent(current, onDismiss) + } +} + +@Composable +private fun SubtitleSyncCardContent(notice: SubtitleSyncNotice, onDismiss: () -> Unit) { + val running = notice.tone == SubtitleSyncNotice.Tone.Progress + val accent = when (notice.tone) { + SubtitleSyncNotice.Tone.Progress -> Color.White.copy(alpha = 0.15f) + SubtitleSyncNotice.Tone.Success -> SyncSuccessColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Info -> SyncInfoColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Warning -> SyncWarningColor.copy(alpha = 0.55f) + } + Surface( + color = Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, accent), + modifier = Modifier + .width(300.dp) + .semantics { liveRegion = LiveRegionMode.Polite } + .then(if (running) Modifier else Modifier.clickable(onClick = onDismiss)), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SyncToneIcon(notice.tone, Modifier.padding(top = 2.dp).size(18.dp)) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = notice.title, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + notice.detail?.let { + Text(text = it, color = Color.White.copy(alpha = 0.70f), fontSize = 12.sp) + } + if (running && notice.percent != null) { + SyncProgressRow(percent = notice.percent ?: 0, modifier = Modifier.padding(top = 4.dp)) + } + } + } + } +} + +@Composable +private fun SyncToneIcon(tone: SubtitleSyncNotice.Tone, modifier: Modifier) { + when (tone) { + SubtitleSyncNotice.Tone.Progress -> CircularProgressIndicator( + color = Color.White, + trackColor = Color.White.copy(alpha = 0.25f), + strokeWidth = 2.dp, + modifier = modifier, + ) + SubtitleSyncNotice.Tone.Success -> + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = SyncSuccessColor, modifier = modifier) + SubtitleSyncNotice.Tone.Info -> + Icon(Icons.Outlined.Info, contentDescription = null, tint = SyncInfoColor, modifier = modifier) + SubtitleSyncNotice.Tone.Warning -> + Icon(Icons.Outlined.Warning, contentDescription = null, tint = SyncWarningColor, modifier = modifier) + } +} + +/** A thin bar and its percentage, for a running sync. */ +@Composable +fun SyncProgressRow(percent: Int, modifier: Modifier = Modifier, label: String? = null) { + val progress by animateFloatAsState(targetValue = percent.coerceIn(0, 100) / 100f, label = "SubtitleSyncProgress") + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LinearProgressIndicator( + progress = { progress }, + color = Color.White.copy(alpha = 0.85f), + trackColor = Color.White.copy(alpha = 0.15f), + strokeCap = StrokeCap.Round, + modifier = Modifier.weight(1f), + ) + if (label == null) { + Text(text = "$percent%", color = Color.White.copy(alpha = 0.60f), fontSize = 11.sp) + } + } + if (label != null) { + Row(horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = label, color = Color.White.copy(alpha = 0.55f), fontSize = 12.sp, modifier = Modifier.weight(1f)) + Text(text = "$percent%", color = Color.White.copy(alpha = 0.55f), fontSize = 12.sp) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt index cdacc1708f..6d9c25d528 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt @@ -19,8 +19,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -33,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -44,6 +47,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.siloserver.silo.model.catalog.AudioTrack import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.playback.SubtitleTimingActions import org.siloserver.silo.player.formatSubtitleTrackDisplayLabel /** Combined audio and subtitle picker with adaptive phone and foldable layouts. */ @@ -62,6 +66,10 @@ fun TracksSheet( showTranslateAction: Boolean = false, onSearchSubtitles: () -> Unit = {}, onTranslateWithAi: () -> Unit = {}, + subtitleStatus: (PlayerSubtitleInfo) -> String? = { null }, + timingActions: SubtitleTimingActions? = null, + onSyncSubtitle: (String) -> Unit = {}, + onResetTiming: (String) -> Unit = {}, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return @@ -130,6 +138,10 @@ fun TracksSheet( showTranslateAction = showTranslateAction, onSearchSubtitles = openSubtitleSearch, onTranslateWithAi = openAiTranslate, + subtitleStatus = subtitleStatus, + timingActions = timingActions, + onSyncSubtitle = onSyncSubtitle, + onResetTiming = onResetTiming, scrollContent = true, modifier = Modifier .weight(0.56f) @@ -167,6 +179,10 @@ fun TracksSheet( showTranslateAction = showTranslateAction, onSearchSubtitles = openSubtitleSearch, onTranslateWithAi = openAiTranslate, + subtitleStatus = subtitleStatus, + timingActions = timingActions, + onSyncSubtitle = onSyncSubtitle, + onResetTiming = onResetTiming, scrollContent = false, modifier = Modifier.fillMaxWidth(), ) @@ -211,6 +227,10 @@ private fun SubtitleTrackCard( showTranslateAction: Boolean, onSearchSubtitles: () -> Unit, onTranslateWithAi: () -> Unit, + subtitleStatus: (PlayerSubtitleInfo) -> String?, + timingActions: SubtitleTimingActions?, + onSyncSubtitle: (String) -> Unit, + onResetTiming: (String) -> Unit, scrollContent: Boolean, modifier: Modifier = Modifier, ) { @@ -230,10 +250,19 @@ private fun SubtitleTrackCard( subtitles.forEachIndexed { index, subtitle -> TrackRow( label = subtitleTrackLabel(subtitle, index), + attributes = subtitleStatus(subtitle), isSelected = index == selectedSubtitleIndex, onClick = { onSelect(index) }, ) } + if (timingActions != null) { + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) + SubtitleTimingSection( + actions = timingActions, + onSync = { onSyncSubtitle(timingActions.key) }, + onReset = { onResetTiming(timingActions.key) }, + ) + } if (showSearchAction || showTranslateAction) { PlayerSheetDivider(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) } @@ -254,6 +283,69 @@ private fun SubtitleTrackCard( } } +/** + * "Sync to audio" and "Reset timing" for the selected subtitle, stored or a + * file next to the media, with a running sync's progress and the last result. + * Anyone who can play the file may retime it; after a refusal (demo mode) the + * actions give way to a short explanation. + */ +@Composable +private fun SubtitleTimingSection( + actions: SubtitleTimingActions, + onSync: () -> Unit, + onReset: () -> Unit, +) { + Text( + text = "TIMING", + color = Color.White.copy(alpha = 0.48f), + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + if (actions.forbidden) { + TimingMessage(SubtitleTimingActions.FORBIDDEN_MESSAGE) + return + } + if (actions.canSync) { + ActionRow( + icon = Icons.Filled.Sync, + label = if (actions.inProgress) "Syncing…" else "Sync to audio", + onClick = onSync, + enabled = actions.actionsEnabled, + ) + } + if (actions.inProgress) { + SyncProgressRow( + percent = actions.percent ?: 0, + label = actions.phaseLabel, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + } + if (actions.canReset) { + ActionRow( + icon = Icons.Filled.Restore, + label = "Reset timing", + onClick = onReset, + enabled = actions.actionsEnabled, + ) + } + actions.result?.let { TimingMessage(it.text, color = if (it.warning) TimingWarningColor else null) } + actions.note?.let { TimingMessage(it, color = Color.White.copy(alpha = 0.40f)) } + actions.error?.let { TimingMessage(it, color = MaterialTheme.colorScheme.error) } +} + +private val TimingWarningColor = Color(0xFFFDE68A).copy(alpha = 0.85f) + +@Composable +private fun TimingMessage(text: String, color: Color? = null) { + Text( + text = text, + color = color ?: Color.White.copy(alpha = 0.50f), + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) +} + @Composable private fun TrackSectionCard( title: String, @@ -376,13 +468,15 @@ private fun ActionRow( icon: ImageVector, label: String, onClick: () -> Unit, + enabled: Boolean = true, ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp) .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f) .heightIn(min = 50.dp) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt index 01b5101544..622d36fae7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt @@ -5,6 +5,7 @@ import org.siloserver.silo.network.PlaybackRealtimeEvent import org.siloserver.silo.playback.PlaybackAction import org.siloserver.silo.playback.decodeMarkersUpdate import org.siloserver.silo.playback.decodePlaybackSubtitleReady +import org.siloserver.silo.playback.decodePlaybackSubtitleSyncUpdated import org.siloserver.silo.playback.decodePlaybackSubtitleTimingChanged import org.siloserver.silo.playback.decidePlaybackAction import org.siloserver.silo.playback.isTransport @@ -92,6 +93,8 @@ class TvPlaybackRealtimeController( "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) "subtitle_timing_changed" -> viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) + "subtitle_sync_updated" -> + viewModel.applySubtitleSyncUpdated(decodePlaybackSubtitleSyncUpdated(event)) "markers_updated" -> { val markers = decodeMarkersUpdate(event) viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt index 665de99c67..44379eb604 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.screens.player +import org.siloserver.silo.playback.SubtitleTimingActions import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.animation.animateContentSize @@ -91,6 +92,7 @@ import org.siloserver.silo.common.player.SleepTimerState import org.siloserver.silo.model.catalog.VersionChapter import org.siloserver.silo.model.playback.PlaybackExecutionPlan import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.model.playback.SubtitleIdentity import org.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.model.settings.SubtitleBackgroundStylePreset import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts @@ -209,6 +211,9 @@ internal fun TvPlayerHud( onSubtitlesPaneShown: () -> Unit, onSearchSubtitles: (() -> Unit)?, onTranslateWithAi: (() -> Unit)?, + subtitleSync: TvHudSubtitleSync = TvHudSubtitleSync(), + onSyncSubtitle: (String) -> Unit = {}, + onResetSubtitleTiming: (String) -> Unit = {}, hdrEnabled: Boolean, onHdrEnabledChanged: (Boolean) -> Unit, dolbyVisionEnabled: Boolean, @@ -317,17 +322,20 @@ internal fun TvPlayerHud( } // Keep an open subtitle picker synchronized with reducer state while - // retaining the same stable focused row through Applying -> committed. - LaunchedEffect(subtitlePresentation, activePicker?.title) { + // retaining the same stable focused row through Applying -> committed, + // and with each track's sync status as a sync runs. + val subtitleSyncStatuses = subtitlePresentation.rows.map { row -> subtitleSync.statusFor(row.identity) } + LaunchedEffect(subtitlePresentation, subtitleSyncStatuses, activePicker?.title) { val current = activePicker if (current?.title == "Subtitle Track") { val checkedRow = subtitlePresentation.rows.firstOrNull { it.checked } val focusedRow = subtitlePresentation.rows.firstOrNull { it.focused } activePicker = current.copy( - options = subtitlePresentation.rows.map { row -> + options = subtitlePresentation.rows.mapIndexed { index, row -> HudPickerOption( id = row.stableId, label = if (row.applying) "${row.label} · Applying…" else row.label, + detail = subtitleSyncStatuses.getOrNull(index), ) }, selectedId = checkedRow?.stableId @@ -552,6 +560,10 @@ internal fun TvPlayerHud( onPaneShown = onSubtitlesPaneShown, onSearchSubtitles = onSearchSubtitles, onTranslateWithAi = onTranslateWithAi, + timing = subtitleSync.timing, + syncStatus = subtitleSync::statusFor, + onSyncSubtitle = onSyncSubtitle, + onResetTiming = onResetSubtitleTiming, entryFocusRequester = paneEntryFocus, enabled = activePicker == null, onPresentPicker = presentPicker, @@ -1489,6 +1501,10 @@ private fun HudSubtitlesPane( onPaneShown: () -> Unit, onSearchSubtitles: (() -> Unit)?, onTranslateWithAi: (() -> Unit)?, + timing: SubtitleTimingActions?, + syncStatus: (SubtitleIdentity) -> String?, + onSyncSubtitle: (String) -> Unit, + onResetTiming: (String) -> Unit, entryFocusRequester: FocusRequester, enabled: Boolean, onPresentPicker: (HudPickerPresentation) -> Unit, @@ -1545,6 +1561,7 @@ private fun HudSubtitlesPane( } else { row.label }, + detail = syncStatus(row.identity), ) }, selectedId = checkedRow?.stableId @@ -1587,6 +1604,41 @@ private fun HudSubtitlesPane( }, ) + if (timing != null) { + HudFocusedSettingRow( + label = "Timing", + value = subtitleTimingValue(timing), + // Swallow, not disable: the picker hands focus back to + // this row, and a disabled row is not focusable, so a + // running sync or a refusal would strand focus. + enabled = enabled, + rightFocusRequester = subtitleTextColorFocus, + onActivate = activate@{ + if (timing.forbidden || !timing.actionsEnabled || !(timing.canSync || timing.canReset)) { + return@activate + } + onPresentPicker( + HudPickerPresentation( + title = "Subtitle Timing", + options = listOfNotNull( + HudPickerOption(TIMING_SYNC, "Sync to audio").takeIf { timing.canSync }, + HudPickerOption(TIMING_RESET, "Reset timing").takeIf { timing.canReset }, + ), + selectedId = "", + focusedId = if (timing.canSync) TIMING_SYNC else TIMING_RESET, + onSelect = { id -> + when (id) { + TIMING_SYNC -> onSyncSubtitle(timing.key) + TIMING_RESET -> onResetTiming(timing.key) + } + }, + ), + ) + }, + ) + HudSubtitleTimingDetail(timing) + } + HudFocusedSettingRow( label = "Size", value = FONT_SIZES.firstOrNull { it.first == appearance.fontSize }?.second @@ -2228,6 +2280,8 @@ internal data class HudPickerOption( val id: String, val label: String, val colorHex: String? = null, + /** A short second line under the label, such as a subtitle's sync status. */ + val detail: String? = null, ) /** @@ -2244,6 +2298,54 @@ internal data class HudPickerPresentation( val onSelect: (String) -> Unit, ) +private const val TIMING_SYNC = "sync" +private const val TIMING_RESET = "reset" + +/** + * The Timing row's value: the refusal or failure when there is one, else the + * sync state. The refusal is shortened to fit the row; the line under it says + * it in full. + */ +private fun subtitleTimingValue(timing: SubtitleTimingActions): String = when { + timing.forbidden -> "Not allowed" + timing.busy -> "Working…" + else -> timing.error ?: timing.statusLabel ?: "Not synced" +} + +/** + * What the Timing row cannot fit: a running sync's progress and phase, the + * last result, what a sync does, or why the actions are missing. It never + * takes focus. + */ +@Composable +private fun HudSubtitleTimingDetail(timing: SubtitleTimingActions) { + val modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp) + val percent = timing.percent + if (timing.inProgress && percent != null) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + TvSyncProgressBar(percent = percent) + timing.phaseLabel?.let { HudTimingText(it) } + } + } + val message = when { + timing.forbidden -> SubtitleTimingActions.FORBIDDEN_MESSAGE to null + timing.error != null -> timing.error.orEmpty() to Color(0xFFFCA5A5) + timing.result != null -> timing.result?.let { it.text to if (it.warning) Color(0xFFFDE68A) else null } + else -> timing.note?.let { it to null } + } + message?.let { (text, color) -> HudTimingText(text, color, modifier) } +} + +@Composable +private fun HudTimingText(text: String, color: Color? = null, modifier: Modifier = Modifier) { + Text( + text = text, + color = color ?: Color.White.copy(alpha = 0.55f), + style = MaterialTheme.typography.bodyMedium.copy(fontSize = HudMetaTextSize), + modifier = modifier, + ) +} + private fun delayPicker( title: String, current: Int, @@ -2406,11 +2508,11 @@ internal fun HudPickerDialog( modifier: Modifier = Modifier, ) { val options = presentation.options + // An action menu has no current choice, so nothing is marked selected. val selectedIndex = options.indexOfFirst { it.id == presentation.selectedId } - .coerceAtLeast(0) val focusedIndex = options.indexOfFirst { it.id == presentation.focusedId } .takeIf { it >= 0 } - ?: selectedIndex + ?: selectedIndex.coerceAtLeast(0) val focusRequester = remember { FocusRequester() } // Auto-focus the selected option on appear. Because every option is in the @@ -2522,18 +2624,28 @@ private fun HudPickerOptionRow( .border(0.5.dp, Color.White.copy(alpha = 0.45f), CircleShape), ) } - Text( - text = option.label, - color = fg, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = option.label, + color = fg, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = HudBodyTextSize, + lineHeight = HudBodyLineHeight, + fontWeight = FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + option.detail?.let { detail -> + Text( + text = detail, + color = fg.copy(alpha = 0.62f), + style = MaterialTheme.typography.bodyMedium.copy(fontSize = HudMetaTextSize), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } if (isSelected) { Icon( imageVector = Icons.Filled.Check, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index 9c6966039a..bf8d1d06ee 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -2,6 +2,7 @@ package org.siloserver.silo.tv.ui.screens.player +import org.siloserver.silo.playback.SubtitleSyncNotice import android.app.Activity import android.content.ComponentName import android.graphics.Rect @@ -89,6 +90,7 @@ import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -322,6 +324,7 @@ fun TvPlayerScreen( // swap. Mirrors phone PlayerScreen. The MediaController is kept for transport. val sessionPlayer by activePlayerHolder.player.collectAsState() val notice by viewModel.notice.collectAsState() + val hudSubtitleSync by viewModel.hudSubtitleSync.collectAsState() val remoteMessage by viewModel.remoteMessage.collectAsState() LaunchedEffect(remoteMessage?.id) { if (remoteMessage != null) { @@ -1567,6 +1570,11 @@ fun TvPlayerScreen( // Quality is a server-transcode ladder built by the VM at // session load (tvOS parity), not the adaptive variants. viewModel.onTracksChanged(audio, subtitle, video) + // Read the live tracks, not the event's: a callback queued + // before a remount still describes the outgoing item. + if (controller.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }) { + viewModel.onMountedSubtitleSelected() + } } override fun onVideoSizeChanged(videoSize: VideoSize) { // MediaController doesn't expose ExoPlayer's `videoFormat` @@ -1831,6 +1839,7 @@ fun TvPlayerScreen( backend.mount(mediaSpec, playWhenReady = !viewModel.uiState.value.isPaused) mountedTransportNonce = state.transportMountNonce viewModel.onTransportMountApplied(state.transportMountNonce) + viewModel.onSubtitleCuesMounted(state.subtitleCueRevisions) } // Subtitle refresh (search download / AI completion): Media3 cannot add @@ -1883,6 +1892,7 @@ fun TvPlayerScreen( activeClaims = plan?.activeOriginalHttpClaims().orEmpty(), ) backend.refresh(mediaSpec) + viewModel.onSubtitleCuesMounted(state.subtitleCueRevisions) } // The single path from the subtitle transaction adapter to the player. @@ -2307,6 +2317,9 @@ fun TvPlayerScreen( onSubtitleAppearanceChanged = viewModel::onSetSubtitleAppearance, subtitleTextOpacitySupported = subtitleTextOpacitySupported, onSubtitlesPaneShown = viewModel::onSubtitlesPaneShown, + subtitleSync = hudSubtitleSync, + onSyncSubtitle = viewModel::requestSubtitleSync, + onResetSubtitleTiming = viewModel::resetSubtitleTiming, onSearchSubtitles = if (state.mediaFileId != null) { { viewModel.closeHUD() @@ -2471,6 +2484,7 @@ fun TvPlayerScreen( TvPlayerOverlays( isInPictureInPictureMode = isInPictureInPictureMode, notice = notice, + subtitleSyncNotice = viewModel.subtitleSyncNotice, remoteMessage = remoteMessage, roomSnapshot = roomSnapshot, roomActive = roomController != null, @@ -3557,6 +3571,7 @@ internal fun selectVideoQuality(player: Player, id: String): Boolean { private fun TvPlayerOverlays( isInPictureInPictureMode: Boolean, notice: PlayerNotice?, + subtitleSyncNotice: StateFlow, remoteMessage: RemoteMessage?, roomSnapshot: RoomSnapshot?, roomActive: Boolean, @@ -3604,6 +3619,22 @@ private fun TvPlayerOverlays( } } + // Subtitle sync card (top-end): follows a sync this viewer started. + // Never focusable. The HUD's Timing row shows the same state and its + // tabs sit where the card would, so the card waits for the HUD to close. + if (!isInPictureInPictureMode) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, end = 32.dp) + .zIndex(9f), + contentAlignment = Alignment.TopEnd, + ) { + val syncNotice by subtitleSyncNotice.collectAsState() + TvSubtitleSyncCard(notice = syncNotice.takeUnless { hudOpen }) + } + } + // Remote-control "display_message" toast (top-center), shown a few // seconds regardless of controls visibility. if (!isInPictureInPictureMode) remoteMessage?.let { message -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 62cbd30cd0..5e7bc619d6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -105,7 +105,18 @@ import org.siloserver.silo.model.playback.resolvedSelectedSubtitleIndex import org.siloserver.silo.model.playback.mergeDownloadedSubtitles import org.siloserver.silo.playback.PlaybackSubtitleReady import org.siloserver.silo.playback.PlaybackSubtitleTimingChanged -import org.siloserver.silo.playback.affects +import org.siloserver.silo.playback.PlaybackSubtitleSyncUpdated +import org.siloserver.silo.playback.SubtitleSyncController +import org.siloserver.silo.playback.SubtitleSyncFeedbackTracker +import org.siloserver.silo.playback.SubtitleSyncNotice +import org.siloserver.silo.playback.feedbackInput +import org.siloserver.silo.playback.includesSyncKey +import org.siloserver.silo.playback.activeSyncKey +import org.siloserver.silo.playback.statusLabelFor +import org.siloserver.silo.playback.syncKeyFor +import org.siloserver.silo.playback.timingActionsFor +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.common.player.subtitleSyncName import org.siloserver.silo.playback.applyAuthoritativeSubtitleReadyTrack import org.siloserver.silo.model.subtitles.SubtitleAiQuota import org.siloserver.silo.model.subtitles.SubtitleAiStatus @@ -139,6 +150,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -1054,6 +1066,10 @@ class TvPlayerViewModel( // subtitleUrls, so the initial prepare effect stays the only path // for session start / stream-URL changes. val subtitleRefreshNonce: Int = 0, + // Grows per sync key each time a syncable subtitle's timing changed + // and its cues must be fetched again. The mount that carries a + // revision reports it back, so sync feedback knows the new cues show. + val subtitleCueRevisions: Map = emptyMap(), val committedSubtitleIdentity: SubtitleIdentity = SubtitleIdentity.Off, val pendingSubtitleIdentity: SubtitleIdentity? = null, val subtitleApplying: Boolean = false, @@ -1351,6 +1367,55 @@ class TvPlayerViewModel( private val _aiTranslate = MutableStateFlow(AiTranslateUiState()) val aiTranslate: StateFlow = _aiTranslate.asStateFlow() + // Timing and sync state of the playing file's stored subtitles. + private val subtitleSync = SubtitleSyncController( + subtitlesRepository, + viewModelScope, + onTimingChanged = ::onSubtitleSyncTimingChanged, + ) + /** The Subtitles pane's sync state: the selected track's timing actions and each track's status. */ + internal val hudSubtitleSync: StateFlow = combine( + subtitleSync.state, + _uiState + .map { state -> state.subtitleUrls to (state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity) } + .distinctUntilChanged(), + ) { sync, (rows, selected) -> + val statuses = rows.mapNotNull { row -> sync.statusLabelFor(row)?.let { row to it } } + TvHudSubtitleSync( + timing = sync.timingActionsFor(rows.syncKeyFor(selected)), + statusByServerIndex = statuses.associate { (row, status) -> row.index to status }, + statusByDownloadId = statuses.mapNotNull { (row, status) -> row.downloadId?.let { it to status } }.toMap(), + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, TvHudSubtitleSync()) + // The card that follows a sync this viewer started. + private val subtitleSyncFeedback = SubtitleSyncFeedbackTracker(viewModelScope) + val subtitleSyncNotice: StateFlow = subtitleSyncFeedback.notice + /** The cue revision of each sync key whose cues the player has in use. */ + private val loadedSubtitleCueRevisions = MutableStateFlow>(emptyMap()) + /** Cue revisions of the last subtitle mount, until Media3 selects its subtitle track. */ + private var mountedSubtitleCueRevisions: Map = emptyMap() + + fun requestSubtitleSync(key: String) = subtitleSync.requestSync(key) + + fun resetSubtitleTiming(key: String) = subtitleSync.resetTiming(key) + + /** + * The player mounted subtitles built from a state carrying [revisions]. + * Media3 fetches them after the mount returns, so they count as loaded + * only once [onMountedSubtitleSelected] reports the track in use. + */ + internal fun onSubtitleCuesMounted(revisions: Map) { + mountedSubtitleCueRevisions = revisions + } + + /** Media3 selected the mounted subtitle track on the live player: its cues are in use. */ + internal fun onMountedSubtitleSelected() { + val revisions = mountedSubtitleCueRevisions + if (revisions.isEmpty()) return + mountedSubtitleCueRevisions = emptyMap() + loadedSubtitleCueRevisions.update { it + revisions } + } + /** * Mounts the subtitle transaction adapter has asked for, each carrying the * owner that must be told how it went. Mirrors the seekRequests idiom: the @@ -1455,6 +1520,26 @@ class TvPlayerViewModel( .distinctUntilChanged() .collect { org.siloserver.silo.common.player.ActivePlaybackFile.set(it) } } + // Subtitle sync applies to server playback only. + viewModelScope.launch { + _uiState + .map { state -> + state.mediaFileId?.takeIf { state.sessionId != null } to + state.subtitleUrls.mapNotNullTo(mutableSetOf()) { it.syncKey } + } + .distinctUntilChanged() + .collect { (mediaFileId, syncKeys) -> subtitleSync.bind(mediaFileId, syncKeys) } + } + viewModelScope.launch { + val screen = _uiState + .map { state -> + Triple(activeSubtitleSyncKey(state), state.subtitleCueRevisions, state.subtitleUrls) + } + .distinctUntilChanged() + combine(subtitleSync.state, screen, loadedSubtitleCueRevisions) { sync, (activeKey, revisions, tracks), loaded -> + sync.feedbackInput(activeKey, revisions, loaded) { subtitleSyncName(tracks, it) } + }.collect(subtitleSyncFeedback::update) + } // Mirror the screen error into the adb test hook — screen-level // failures (terminal server plans) never reach the Media3 player, so // scripted tests can't see them through player state alone. @@ -4835,6 +4920,8 @@ class TvPlayerViewModel( * error surfaced). */ fun onSubtitlesPaneShown() { + // Opening re-reads sync state so a job that finished meanwhile shows. + subtitleSync.reload() if (aiStatusRequested) return aiStatusRequested = true viewModelScope.launch { @@ -4923,6 +5010,8 @@ class TvPlayerViewModel( if (generation != subtitleDownloadGeneration || _uiState.value.mediaFileId != mediaFileId || _uiState.value.sessionId != sessionId) return@launch when (r) { is ApiResult.Success -> { + // Follows the automatic sync the server starts for a new download. + subtitleSync.remember(r.data.subtitle) val merged = refreshSubtitles( autoSelectSubtitleId = r.data.subtitle.id, source = TvSubtitleRefreshSource.Download, @@ -5065,22 +5154,60 @@ class TvPlayerViewModel( } /** - * The server retimed a stored subtitle (sync or timing reset). Media3 - * keeps the cues it already parsed, so when that subtitle is the mounted - * sidecar, remount the same item at the same position to fetch it again. + * The server retimed a subtitle of the file, stored or a sidecar (a sync, + * or a timing set or reset). The sync controller re-reads it and reports + * the change through [onSubtitleSyncTimingChanged]. */ internal fun applySubtitleTimingChanged(update: PlaybackSubtitleTimingChanged) { val state = _uiState.value val sessionId = state.sessionId ?: return if (update.sessionId != null && update.sessionId != sessionId) return if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return - val mounted = subtitlesForVideoMediaMount( - subtitles = state.subtitleUrls, - playbackPlan = state.playbackPlan, - subtitleIdentity = state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity, - preferMuxedTracks = true, + val key = update.syncKey ?: update.subtitleId?.let(SubtitleSyncState::storedKey) ?: return + subtitleSync.timingChanged(key) + } + + /** A step of a sync job of the file (realtime): progress, then the outcome. */ + internal fun applySubtitleSyncUpdated(update: PlaybackSubtitleSyncUpdated?) { + update ?: return + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + subtitleSync.syncUpdated(update) + } + + fun dismissSubtitleSyncNotice() = subtitleSyncFeedback.dismiss() + + private fun mountedSubtitles(state: UiState): List = + if (state.sessionId == null) { + emptyList() + } else { + subtitlesForVideoMediaMount( + subtitles = state.subtitleUrls, + playbackPlan = state.playbackPlan, + subtitleIdentity = state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity, + preferMuxedTracks = true, + ) + } + + private fun activeSubtitleSyncKey(state: UiState): String? = + state.subtitleUrls.activeSyncKey( + state.pendingSubtitleIdentity ?: state.committedSubtitleIdentity, + mounted = mountedSubtitles(state), ) - if (update.affects(mounted)) subtitleTransactions.remountSubtitles() + + /** + * A syncable subtitle's timing changed. Media3 keeps the cues it already + * parsed, so when that subtitle is the mounted sidecar, remount the same + * item at the same position to fetch it again. Its cue revision tells the + * sync feedback when the new cues show. + */ + private fun onSubtitleSyncTimingChanged(key: String) { + _uiState.update { + it.copy(subtitleCueRevisions = it.subtitleCueRevisions + (key to (it.subtitleCueRevisions[key] ?: 0) + 1)) + } + if (mountedSubtitles(_uiState.value).includesSyncKey(key)) subtitleTransactions.remountSubtitles() } // ---- Subtitle suite: AI translate / transcribe ------------------------------- diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudState.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudState.kt index 5ff442d5b9..239e1f727b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudState.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudState.kt @@ -5,6 +5,7 @@ import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.playback.encodeSubtitleIdentityPreference import org.siloserver.silo.playback.isBitmapSubtitleCodecFamily import org.siloserver.silo.playback.subtitleMediaIdentityOrNull +import org.siloserver.silo.playback.SubtitleTimingActions internal data class TvSubtitleHudOption( val stableId: String, @@ -24,6 +25,24 @@ internal data class TvSubtitleHudRow( get() = if (applying) "Applying…" else null } +/** + * What the HUD's Subtitles pane shows about subtitle sync: the selected + * track's timing actions, and each track's status line, by ordinal for + * server tracks and by stored ID for rows merged from the stored list. + * Built by the view model, so the player screen passes one value through. + */ +internal data class TvHudSubtitleSync( + val timing: SubtitleTimingActions? = null, + val statusByServerIndex: Map = emptyMap(), + val statusByDownloadId: Map = emptyMap(), +) { + fun statusFor(identity: SubtitleIdentity): String? = when (identity) { + is SubtitleIdentity.ServerSidecar -> statusByServerIndex[identity.serverIndex] + is SubtitleIdentity.Downloaded -> statusByDownloadId[identity.downloadId] + else -> null + } +} + internal data class TvSubtitleHudPresentation( val rows: List, val hudOpen: Boolean, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSyncCard.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSyncCard.kt new file mode 100644 index 0000000000..a385b17ba3 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSyncCard.kt @@ -0,0 +1,124 @@ +package org.siloserver.silo.tv.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.siloserver.silo.playback.SubtitleSyncNotice + +private val SyncSuccessColor = Color(0xFF6EE7B7) +private val SyncInfoColor = Color(0xFF7DD3FC) +private val SyncWarningColor = Color(0xFFFCD34D) + +/** + * A card that follows a subtitle sync this viewer started: its progress while + * it runs, then how it ended. It never takes D-pad focus; a finished card + * leaves on its own. Sized for TV viewing distance. + */ +@Composable +internal fun TvSubtitleSyncCard(notice: SubtitleSyncNotice?, modifier: Modifier = Modifier) { + // Keyed by the notice id, so one job's progress updates the card in place + // and a finished card fades out with its last content. + AnimatedContent( + targetState = notice, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentKey = { it?.id }, + label = "TvSubtitleSyncCard", + modifier = modifier, + ) { current -> + if (current != null) TvSubtitleSyncCardContent(current) + } +} + +@Composable +private fun TvSubtitleSyncCardContent(notice: SubtitleSyncNotice) { + val running = notice.tone == SubtitleSyncNotice.Tone.Progress + val accent = when (notice.tone) { + SubtitleSyncNotice.Tone.Progress -> Color.White.copy(alpha = 0.15f) + SubtitleSyncNotice.Tone.Success -> SyncSuccessColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Info -> SyncInfoColor.copy(alpha = 0.45f) + SubtitleSyncNotice.Tone.Warning -> SyncWarningColor.copy(alpha = 0.55f) + } + Surface( + color = Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, accent), + modifier = Modifier.width(360.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val iconModifier = Modifier.padding(top = 2.dp).size(22.dp) + when (notice.tone) { + SubtitleSyncNotice.Tone.Progress -> CircularProgressIndicator( + color = Color.White, + trackColor = Color.White.copy(alpha = 0.25f), + strokeWidth = 2.5.dp, + modifier = iconModifier, + ) + SubtitleSyncNotice.Tone.Success -> + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = SyncSuccessColor, modifier = iconModifier) + SubtitleSyncNotice.Tone.Info -> + Icon(Icons.Outlined.Info, contentDescription = null, tint = SyncInfoColor, modifier = iconModifier) + SubtitleSyncNotice.Tone.Warning -> + Icon(Icons.Outlined.Warning, contentDescription = null, tint = SyncWarningColor, modifier = iconModifier) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = notice.title, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + notice.detail?.let { Text(text = it, color = Color.White.copy(alpha = 0.72f), fontSize = 15.sp) } + val percent = notice.percent + if (running && percent != null) { + TvSyncProgressBar(percent = percent, modifier = Modifier.padding(top = 6.dp)) + } + } + } + } +} + +/** A thin bar and its percentage, for a running sync. */ +@Composable +internal fun TvSyncProgressBar(percent: Int, modifier: Modifier = Modifier) { + val progress by animateFloatAsState(targetValue = percent.coerceIn(0, 100) / 100f, label = "TvSubtitleSyncProgress") + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + LinearProgressIndicator( + progress = { progress }, + color = Color.White.copy(alpha = 0.85f), + trackColor = Color.White.copy(alpha = 0.15f), + strokeCap = StrokeCap.Round, + modifier = Modifier.weight(1f), + ) + Text(text = "$percent%", color = Color.White.copy(alpha = 0.62f), fontSize = 14.sp) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudStateTest.kt index fa4c7ebf17..3a4ba798ea 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudStateTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleHudStateTest.kt @@ -9,6 +9,18 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue class TvSubtitleHudStateTest { + @Test + fun `sync status resolves server tracks by ordinal and merged stored rows by download id`() { + val sync = TvHudSubtitleSync( + statusByServerIndex = mapOf(0 to "Synced −3.0 s"), + statusByDownloadId = mapOf(9 to "Syncing… 40%"), + ) + assertEquals("Synced −3.0 s", sync.statusFor(SubtitleIdentity.ServerSidecar(0))) + assertEquals("Syncing… 40%", sync.statusFor(SubtitleIdentity.Downloaded(9, SubtitleMediaIdentity()))) + assertEquals(null, sync.statusFor(SubtitleIdentity.ServerSidecar(1))) + assertEquals(null, sync.statusFor(SubtitleIdentity.Off)) + } + @Test fun `pending HUD state keeps committed row checked and labels pending row Applying`() { val presentation = present( diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt index ef5fe93cc3..e4fb61cec5 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt @@ -60,6 +60,7 @@ val networkModule = module { single { SubtitleDownloadV2Api(get(), get(), get()) } single { SubtitleReadsV2Api(get(), get(), get()) } single { SubtitleAiCreateV2Api(get(), get(), get()) } + single { SubtitleSyncV2Api(get(), get(), get()) } single { DefaultSubtitlesApi(get(), get(), get(), get()) } single { NotificationsV2Api(get(), get(), get()) } single { DefaultPushRegistrationApi(get(), get(), get()) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index bfdde51bab..5e37e8e333 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -119,7 +119,7 @@ val repositoryModule = module { single { LibraryPlaybackPrefsRepository(get()) } single { DownloadsRepository(get(), getOrNull() ?: org.siloserver.silo.repository.port.NoOpDownloadDeletionPort, get(), get(), get()) } single { EbookReaderRepository(get(), get()) } - single { SubtitlesRepository(get(), get()) } + single { SubtitlesRepository(get(), get(), get()) } single { PushRegistrationRepository(get()) } // REST-backed inbox state plus a realtime factory that builds the default diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt index 2293840a6b..d844ffcf39 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.siloserver.silo.model.catalog.AudioTrack +import org.siloserver.silo.playback.orNullIfBlank /** * The part of `GET /api/v2/downloads/{id}/manifest` the offline player needs: @@ -31,9 +32,11 @@ data class OfflineManifestSubtitle( @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, @SerialName("fetch_url") val fetchUrl: String = "", /** - * Opaque revision of a stored (`downloaded:{id}`) subtitle. It changes when - * the server retimes the subtitle (a sync or a timing reset), which changes - * the bytes [fetchUrl] serves. Absent for embedded and external tracks. + * Opaque revision of a stored (`downloaded:{id}`) or external + * (`external:{index}`) subtitle. It changes whenever the bytes [fetchUrl] + * serves can change: the server retimed the subtitle (a sync, or a timing + * set or reset), or an external file was edited on disk. Absent for + * embedded tracks and for an external file the server cannot read. */ val revision: String? = null, ) @@ -88,9 +91,14 @@ data class OfflineSubtitleFile( ) /** - * Saved sidecars whose stored subtitle the [manifest] lists at a different - * revision, paired with that revision. Captures that predate [OfflineSubtitleFile.fetchUrl] - * cannot be matched to a manifest row and are never refreshed. + * Saved sidecars whose subtitle the [manifest] lists at a different revision, + * paired with that revision. Captures that predate [OfflineSubtitleFile.fetchUrl] + * cannot be matched to a manifest row and are never refreshed. An + * `external:{index}` ref names a sidecar by its position, which shifts when the + * files next to the media change, so such a row is refreshed only while it + * still describes the saved file. Two sidecars alike in every field the + * manifest lists can still trade places unnoticed: it has no stable identity + * for them. */ fun OfflineTrackInfo.subtitlesWithNewRevision( manifest: OfflineManifestTracks, @@ -99,11 +107,24 @@ fun OfflineTrackInfo.subtitlesWithNewRevision( .filter { it.revision != null } .associateBy { it.fetchUrl.trim() } return subtitles.mapNotNull { saved -> - val revision = saved.fetchUrl?.let(listed::get)?.revision ?: return@mapNotNull null + val row = saved.fetchUrl?.let(listed::get) ?: return@mapNotNull null + val revision = row.revision ?: return@mapNotNull null + if (row.isPositionalRef() && !row.describes(saved)) return@mapNotNull null if (revision == saved.revision) null else saved to revision } } +private fun OfflineManifestSubtitle.isPositionalRef(): Boolean = + fetchUrl.trim().substringAfterLast('/').startsWith("external:") + +/** Whether this row lists the same language, title, format, and flags [saved] was captured with. */ +private fun OfflineManifestSubtitle.describes(saved: OfflineSubtitleFile): Boolean = + language.orNullIfBlank().equals(saved.language, ignoreCase = true) && + title.orNullIfBlank() == saved.title && + offlineSubtitleFormat(format) == saved.format && + forced == saved.forced && + hearingImpaired == saved.hearingImpaired + private val offlineManifestJson = Json { ignoreUnknownKeys = true coerceInputValues = true diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt index 24bddd4503..12a9c9925f 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt @@ -72,6 +72,8 @@ data class PlayerSubtitleInfo( @SerialName("server_delivery") val serverDelivery: String? = null, /** Exact original-container track ID selected by a v3 embedded decision. */ @SerialName("native_container_track_id") val nativeContainerTrackId: String? = null, + /** The inventory's `sync_key`: present when the server can sync this track to the audio. */ + @SerialName("sync_key") val syncKey: String? = null, ) /** diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt index 0d0aa0d323..6ee0ab0b39 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt @@ -378,6 +378,11 @@ data class PlaybackSubtitleInventoryItemV3( val delivery: String = "", val url: String? = null, @SerialName("font_bundle_url") val fontBundleUrl: String? = null, + /** + * Opaque name of this track for the subtitle sync operations, present on + * external and downloaded tracks whose timing the server can correct. + */ + @SerialName("sync_key") val syncKey: String? = null, ) /** Resolves the optional ordinal from the stable server track identity. */ diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMerge.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMerge.kt index d4292938bd..100a8e4315 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMerge.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMerge.kt @@ -2,6 +2,7 @@ package org.siloserver.silo.model.playback import org.siloserver.silo.model.subtitles.DownloadedSubtitle +import org.siloserver.silo.model.subtitles.SubtitleSyncState /** `source` value the server (and web client) use for provider-downloaded tracks. */ const val SUBTITLE_SOURCE_DOWNLOADED = "downloaded" @@ -53,12 +54,19 @@ fun mergeDownloadedSubtitles( forced = null, url = "/stream/$sessionId/subtitles/$index${subtitleUrlExtension(dl.format)}", downloadId = dl.id, + // The server names a stored subtitle `stored-{id}` for sync, and + // syncs only the text formats it can retime. + syncKey = SubtitleSyncState.storedKey(dl.id).takeIf { isRetimableSubtitleFormat(dl.format) }, ) } return base + newTracks } +private val retimableSubtitleFormats = setOf("srt", "subrip", "vtt", "webvtt", "ass", "ssa") + +private fun isRetimableSubtitleFormat(format: String): Boolean = format.trim().lowercase() in retimableSubtitleFormats + private fun subtitleUrlExtension(format: String): String { return when (format.trim().lowercase()) { "ass", "ssa" -> ".ass" diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleModels.kt index 9182b05d99..94fef2f4fc 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleModels.kt @@ -80,6 +80,9 @@ data class DownloadedSubtitle( val score: Double = 0.0, @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, @SerialName("created_at") val createdAt: String = "", + val timing: SubtitleTiming = SubtitleTiming(), + /** The latest sync job; null when the subtitle was never synced. */ + val sync: SubtitleSyncJob? = null, ) /** Envelope for POST /api/v2/subtitles/download. */ diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleSync.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleSync.kt new file mode 100644 index 0000000000..e34abdcf82 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/subtitles/SubtitleSync.kt @@ -0,0 +1,218 @@ +package org.siloserver.silo.model.subtitles + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +/** + * A subtitle's timing correction: original time `t` plays at + * `t * scale + offsetMs`. The server applies it to every delivery path. + */ +@Serializable +data class SubtitleTiming( + @SerialName("offset_ms") val offsetMs: Long = 0, + val scale: Double = 1.0, +) { + val isIdentity: Boolean get() = offsetMs == 0L && scale == 1.0 +} + +/** A subtitle's latest sync job (`SubtitleSyncJobState`). */ +@Serializable +data class SubtitleSyncJob( + val id: String = "", + val status: String, + /** `auto` for the sync of a new download or upload, `manual` for one someone asked for. */ + val trigger: String? = null, + /** What an active job is doing: `queued`, `analyzing`, or `matching`. */ + val phase: String? = null, + /** 0..1 while the job is active. */ + val progress: Double? = null, + /** Why a failed job failed: `subtitle_changed`, `no_audio`, `unavailable`, or `error`. */ + val failure: String? = null, + val confidence: Double? = null, + /** The correction the job found, once it finished. */ + val result: SubtitleTiming? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("finished_at") val finishedAt: String? = null, +) { + val inProgress: Boolean get() = status == PENDING || status == RUNNING + + companion object { + const val PENDING = "pending" + const val RUNNING = "running" + const val SYNCED = "synced" + const val ALREADY_SYNCED = "already_synced" + const val NO_MATCH = "no_match" + const val FAILED = "failed" + + const val PHASE_ANALYZING = "analyzing" + const val PHASE_MATCHING = "matching" + + const val FAILURE_SUBTITLE_CHANGED = "subtitle_changed" + const val FAILURE_NO_AUDIO = "no_audio" + const val FAILURE_UNAVAILABLE = "unavailable" + } +} + +/** + * One syncable subtitle of a media file (`SubtitleSyncState`): a stored + * subtitle or a subtitle file next to the media (a sidecar), named by its + * opaque sync [key], with its timing and latest job. + */ +@Serializable +data class SubtitleSyncState( + val key: String, + @SerialName("media_file_id") val mediaFileId: String = "", + /** `downloaded` for a stored subtitle, `external` for a sidecar. */ + val source: String = "", + @SerialName("stored_subtitle_id") val storedSubtitleId: String? = null, + val language: String = "", + val format: String = "", + /** The release name, or the sidecar's file name. */ + val label: String = "", + val timing: SubtitleTiming = SubtitleTiming(), + val sync: SubtitleSyncJob? = null, +) { + val isSidecar: Boolean get() = source == SOURCE_EXTERNAL + + companion object { + const val SOURCE_EXTERNAL = "external" + const val SOURCE_DOWNLOADED = "downloaded" + + /** The sync key of a stored subtitle; the server documents it as equivalent to the stored operations. */ + fun storedKey(subtitleId: Int): String = "stored-$subtitleId" + } +} + +/** The sync state a stored subtitle returned by a download or upload carries. */ +fun DownloadedSubtitle.syncState(): SubtitleSyncState = SubtitleSyncState( + key = SubtitleSyncState.storedKey(id), + mediaFileId = mediaFileId.toString(), + source = SubtitleSyncState.SOURCE_DOWNLOADED, + storedSubtitleId = id.toString(), + language = language, + format = format, + label = releaseName.ifBlank { provider }, + timing = timing, + sync = sync, +) + +/** GET /api/v2/subtitles/sync/status: whether this server can align subtitles to audio. */ +@Serializable +data class SubtitleSyncCapability( + val state: String = "", + @SerialName("auto_sync") val autoSync: Boolean = false, + /** Whether subtitle files next to the media (sidecars) can be synced too. */ + val external: Boolean = false, + val allowed: Boolean = false, +) { + val available: Boolean get() = state == "available" && allowed +} + +/** "+2.3 s" / "−0.4 s"; whole seconds keep one decimal so a list scans evenly. */ +fun formatSyncOffset(offsetMs: Long): String { + val tenths = (abs(offsetMs) + 50) / 100 + val sign = if (offsetMs < 0) "−" else "+" + return "$sign${tenths / 10}.${tenths % 10} s" +} + +private val frameRates = listOf( + 23.976 to "23.976", 24.0 to "24", 25.0 to "25", 29.97 to "29.97", + 30.0 to "30", 50.0 to "50", 59.94 to "59.94", 60.0 to "60", +) + +// The server reports a frame-rate conversion as its exact ratio; anything else +// is drift between two cuts, which only looks like a nearby ratio. +private const val FRAME_RATE_TOLERANCE = 1e-6 + +/** + * Names a scale correction as the frame-rate conversion it most likely is + * ("25→23.976 fps"), or as a plain speed factor ("×1.0008 speed"). Null for + * no scaling. A subtitle cut for source rate `from` stretched onto video rate + * `to` has scale = from / to. + */ +fun describeSyncScale(scale: Double): String? { + if (!scale.isFinite() || scale == 1.0) return null + for ((from, fromLabel) in frameRates) { + for ((to, toLabel) in frameRates) { + if (from != to && abs(from / to - scale) <= FRAME_RATE_TOLERANCE) return "$fromLabel→$toLabel fps" + } + } + // Up to four decimals without trailing zeros, like the web player's Number(scale.toFixed(4)). + val tenThousandths = (scale * 10_000).roundToLong() + val whole = tenThousandths / 10_000 + val fraction = (tenThousandths % 10_000).toString().padStart(4, '0').trimEnd('0') + return if (fraction.isEmpty()) "×$whole speed" else "×$whole.$fraction speed" +} + +/** "+2.3 s · 25→23.976 fps": a correction in a few characters. */ +fun describeSyncTiming(timing: SubtitleTiming): String { + val scale = describeSyncScale(timing.scale) + val offset = if (timing.offsetMs != 0L || scale == null) formatSyncOffset(timing.offsetMs) else null + return listOfNotNull(offset, scale).joinToString(" · ") +} + +/** An active job's progress as a whole percentage, or null once it finished. */ +fun syncProgressPercent(job: SubtitleSyncJob?): Int? { + if (job == null || !job.inProgress) return null + return ((job.progress ?: 0.0).coerceIn(0.0, 1.0) * 100).roundToInt() +} + +/** What an active job is doing, in a few words. */ +fun syncPhaseLabel(job: SubtitleSyncJob?): String? { + if (job == null || !job.inProgress) return null + return when (job.phase) { + SubtitleSyncJob.PHASE_MATCHING -> "Matching lines to speech…" + SubtitleSyncJob.PHASE_ANALYZING -> "Listening to the audio…" + else -> "Waiting to start…" + } +} + +/** Why a failed job failed, in plain words. */ +fun syncFailureMessage(failure: String?): String = when (failure) { + SubtitleSyncJob.FAILURE_SUBTITLE_CHANGED -> "The subtitle changed while it was syncing. Try again." + SubtitleSyncJob.FAILURE_NO_AUDIO -> "This video has no audio Silo can read." + SubtitleSyncJob.FAILURE_UNAVAILABLE -> "The server is busy. Try again in a few minutes." + else -> "Sync failed. Try again." +} + +/** + * One short line describing a subtitle's timing, or null when there is + * nothing to say (never synced and never adjusted). Matches the web player. + */ +fun subtitleSyncStatusLabel(timing: SubtitleTiming, sync: SubtitleSyncJob?): String? { + when (sync?.status) { + SubtitleSyncJob.PENDING, SubtitleSyncJob.RUNNING -> { + val percent = syncProgressPercent(sync) + return if (percent != null && percent > 0) "Syncing… $percent%" else "Syncing…" + } + SubtitleSyncJob.NO_MATCH -> return "Doesn't match this video" + SubtitleSyncJob.FAILED -> return "Sync failed" + SubtitleSyncJob.ALREADY_SYNCED -> if (timing.isIdentity) return "Already in sync" + SubtitleSyncJob.SYNCED -> { + if (timing.isIdentity) return "Original timing" + if (sync.result == timing) return "Synced ${describeSyncTiming(timing)}" + } + } + return if (timing.isIdentity) null else "Timing adjusted ${describeSyncTiming(timing)}" +} + +/** + * The last finished job's result for the timing section, or null when there + * is none to show; [warning] marks an outcome that left the timing unchanged + * because the sync could not help. + */ +data class SubtitleSyncResultLine(val text: String, val warning: Boolean = false) + +fun subtitleSyncResultLine(timing: SubtitleTiming, sync: SubtitleSyncJob?): SubtitleSyncResultLine? = + when (sync?.status) { + SubtitleSyncJob.SYNCED -> + if (timing.isIdentity) null else SubtitleSyncResultLine("Synced to the audio: ${describeSyncTiming(timing)}") + SubtitleSyncJob.ALREADY_SYNCED -> SubtitleSyncResultLine("Already matches the audio.") + SubtitleSyncJob.NO_MATCH -> + SubtitleSyncResultLine("Doesn't match this video's audio; probably for another release.", warning = true) + SubtitleSyncJob.FAILED -> SubtitleSyncResultLine(syncFailureMessage(sync.failure), warning = true) + else -> null + } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleDownloadV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleDownloadV2Api.kt index 81a30c7096..9d77874fb6 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleDownloadV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleDownloadV2Api.kt @@ -27,10 +27,15 @@ internal data class StoredSubtitleV2( val score: Double, @SerialName("hearing_impaired") val hearingImpaired: Boolean, @SerialName("created_at") val createdAt: String, + val timing: SubtitleTiming = SubtitleTiming(), + val sync: SubtitleSyncJob? = null, ) { fun project(expectedFile: Int): DownloadedSubtitle { require(mediaFileId == expectedFile.toString()) - return DownloadedSubtitle(checkedPositiveId(id), expectedFile, provider, language, format, releaseName, score, hearingImpaired, createdAt) + return DownloadedSubtitle( + checkedPositiveId(id), expectedFile, provider, language, format, releaseName, score, hearingImpaired, createdAt, + timing, sync, + ) } } @Serializable private data class DownloadEnvelopeV2(val subtitle: StoredSubtitleV2) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Api.kt new file mode 100644 index 0000000000..164c848146 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Api.kt @@ -0,0 +1,104 @@ +package org.siloserver.silo.network.apiv2 + +import io.ktor.client.HttpClient +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.serialization.Serializable +import org.siloserver.silo.model.subtitles.* +import org.siloserver.silo.network.* + +@Serializable private data class SyncListEnvelopeV2(val subtitles: List) +@Serializable private data class SyncStateEnvelopeV2(val subtitle: SubtitleSyncState) + +/** The server's sync key grammar; anything else is refused before it reaches a URL. */ +private val syncKeyPattern = Regex("^(stored-[1-9][0-9]*|external-[0-9a-f]{64})$") + +fun isSubtitleSyncKey(key: String): Boolean = syncKeyPattern.matches(key) + +/** + * Subtitle sync (silo-server "Subtitle sync"): the capability probe, and the + * operations on one media file's syncable subtitles, each named by the sync + * key the playback inventory publishes. Anyone who can play the file may + * start a sync or set the timing; demo mode answers 403. + */ +class SubtitleSyncV2Api(private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate) { + + /** GET /api/v2/subtitles/sync/status */ + suspend fun status(): ApiResult { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + return ownedV2Call(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.get("/api/v2/subtitles/sync/status") { authScope(owner!!); requireSiloAuth() } + }) { it } + } + + /** GET /api/v2/subtitles/{media_file_id}/sync: every syncable subtitle of the file. */ + suspend fun list(mediaFileId: Int): ApiResult> { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + if (mediaFileId <= 0) return invalidFile() + return ownedV2Call>(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.get("/api/v2/subtitles/$mediaFileId/sync") { authScope(owner!!); requireSiloAuth() } + }) { body -> body.subtitles.onEach { requireFile(it, mediaFileId) } } + } + + /** GET /api/v2/subtitles/{media_file_id}/sync/{key}: one subtitle with its timing and latest job. */ + suspend fun read(mediaFileId: Int, key: String): ApiResult = + readWithValidator(mediaFileId, key).map { it.first } + + /** POST /api/v2/subtitles/{media_file_id}/sync/{key}: 202 with the subtitle and its new or active job. */ + suspend fun start(mediaFileId: Int, key: String): ApiResult { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + invalidTarget(mediaFileId, key)?.let { return it } + return ownedV2Call(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.Accepted, { owner -> + client.post(syncPath(mediaFileId, key)) { authScope(owner!!); requireSiloAuth(); singleAttempt() } + }) { requireFile(it.subtitle, mediaFileId, key) } + } + + /** + * PUT /api/v2/subtitles/{media_file_id}/sync/{key}/timing. The PUT needs + * the validator a read returns; a stale one answers 412. `{0, 1}` restores + * the original timing. + */ + suspend fun setTiming(mediaFileId: Int, key: String, timing: SubtitleTiming): ApiResult { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + val validator = when (val read = readWithValidator(mediaFileId, key)) { + is ApiResult.Success -> read.data.second + ?: return ApiResult.Error(0, "missing_etag", "Couldn't read the subtitle's current version.") + is ApiResult.Error -> return read + is ApiResult.NetworkError -> return read + } + return ownedV2Call(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.put("${syncPath(mediaFileId, key)}/timing") { + authScope(owner!!); requireSiloAuth(); singleAttempt() + header(HttpHeaders.IfMatch, validator) + contentType(ContentType.Application.Json) + setBody(timing) + } + }) { requireFile(it.subtitle, mediaFileId, key) } + } + + private suspend fun readWithValidator(mediaFileId: Int, key: String): ApiResult> { + val scope = tokens.snapshotCurrentScope() ?: return identityChanged() + invalidTarget(mediaFileId, key)?.let { return it } + var etag: String? = null + return ownedV2Call>(gate, tokens, scope, OwnerPolicy.IDENTITY, HttpStatusCode.OK, { owner -> + client.get(syncPath(mediaFileId, key)) { authScope(owner!!); requireSiloAuth() } + .also { etag = it.headers[HttpHeaders.ETag]?.takeIf(String::isNotBlank) } + }) { requireFile(it.subtitle, mediaFileId, key) to etag } + } + + private fun syncPath(mediaFileId: Int, key: String) = "/api/v2/subtitles/$mediaFileId/sync/$key" + + private fun invalidTarget(mediaFileId: Int, key: String): ApiResult.Error? = when { + mediaFileId <= 0 -> invalidFile() + !isSubtitleSyncKey(key) -> ApiResult.Error(0, "invalid_sync_key", "This subtitle can't be synced.") + else -> null + } + + private fun invalidFile() = ApiResult.Error(0, "invalid_media_file", "A media file is required.") + + private fun requireFile(state: SubtitleSyncState, mediaFileId: Int, key: String? = null): SubtitleSyncState { + require(state.mediaFileId == mediaFileId.toString()) { "The subtitle belongs to another file." } + require(key == null || state.key == key) { "The server answered for another subtitle." } + return state + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleReady.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleReady.kt index 1257f2f146..e08c740716 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleReady.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleReady.kt @@ -72,6 +72,7 @@ fun applyAuthoritativeSubtitleReadyTrack( downloadId = update.subtitleId.takeIf { item.source == SUBTITLE_SOURCE_DOWNLOADED }, serverTrackId = item.trackId, serverDelivery = item.delivery, + syncKey = item.syncKey, ) val rows = (existing.filterNot { it.serverTrackId == item.trackId || it.index == item.combinedIndex diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt index b0889ca5cf..32ac901b28 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackSubtitleTimingChanged.kt @@ -5,17 +5,24 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.model.subtitles.SubtitleSyncJob +import org.siloserver.silo.model.subtitles.SubtitleTiming import org.siloserver.silo.network.PlaybackRealtimeEvent +import org.siloserver.silo.network.SiloJson /** - * `subtitle_timing_changed`: the server retimed a stored subtitle of the file - * (a sync result or a timing reset). The track's sidecar URL is unchanged but - * now serves the new timing, so a player showing it must fetch it again. + * `subtitle_timing_changed`: the server retimed a subtitle of the file, a + * stored one or a sidecar (a sync result, or a timing set or reset). The + * track's URL is unchanged but now serves the new timing, so a player showing + * it must fetch it again. */ data class PlaybackSubtitleTimingChanged( val sessionId: String?, val mediaFileId: Int?, + /** The stored subtitle's ID; absent for a sidecar. */ val subtitleId: Int?, + /** The track's sync key, as the inventory's `sync_key`; absent from servers that predate sidecar sync. */ + val syncKey: String? = null, ) fun decodePlaybackSubtitleTimingChanged(event: PlaybackRealtimeEvent.ServerEvent): PlaybackSubtitleTimingChanged = @@ -23,11 +30,50 @@ fun decodePlaybackSubtitleTimingChanged(event: PlaybackRealtimeEvent.ServerEvent fun decodePlaybackSubtitleTimingChanged(payload: JsonObject): PlaybackSubtitleTimingChanged = PlaybackSubtitleTimingChanged( - sessionId = (payload["session_id"] as? JsonPrimitive)?.contentOrNull, + sessionId = payload.string("session_id"), mediaFileId = (payload["file_id"] as? JsonPrimitive)?.intOrNull, subtitleId = (payload["subtitle_id"] as? JsonPrimitive)?.intOrNull, + syncKey = payload.string("sync_key"), ) +/** + * `subtitle_sync_updated`: one step of a sync job of the file (queued, each + * progress update, and the outcome), with the subtitle's timing after it. + * Best effort; the sync operations stay authoritative. + */ +data class PlaybackSubtitleSyncUpdated( + val sessionId: String?, + val mediaFileId: Int?, + val syncKey: String, + val subtitleId: Int?, + val timing: SubtitleTiming, + val job: SubtitleSyncJob, +) + +/** Null when the payload lacks the sync key, the timing, or a readable job. */ +fun decodePlaybackSubtitleSyncUpdated(event: PlaybackRealtimeEvent.ServerEvent): PlaybackSubtitleSyncUpdated? = + decodePlaybackSubtitleSyncUpdated(event.payload) + +fun decodePlaybackSubtitleSyncUpdated(payload: JsonObject): PlaybackSubtitleSyncUpdated? { + val syncKey = payload.string("sync_key")?.takeIf(String::isNotBlank) ?: return null + val timing = payload["timing"] as? JsonObject ?: return null + val job = payload["job"] as? JsonObject ?: return null + return try { + PlaybackSubtitleSyncUpdated( + sessionId = payload.string("session_id"), + mediaFileId = (payload["file_id"] as? JsonPrimitive)?.intOrNull, + syncKey = syncKey, + subtitleId = (payload["subtitle_id"] as? JsonPrimitive)?.intOrNull, + timing = SiloJson.decodeFromJsonElement(SubtitleTiming.serializer(), timing), + job = SiloJson.decodeFromJsonElement(SubtitleSyncJob.serializer(), job), + ) + } catch (_: IllegalArgumentException) { + null + } +} + +private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull + private val storedSubtitleIdParam = Regex("[?&]downloaded_subtitle_id=([1-9][0-9]*)(?:[&#]|$)") /** @@ -39,8 +85,12 @@ private val storedSubtitleIdParam = Regex("[?&]downloaded_subtitle_id=([1-9][0-9 fun PlayerSubtitleInfo.storedSubtitleId(): Int? = downloadId ?: storedSubtitleIdParam.find(url)?.groupValues?.get(1)?.toIntOrNull() -/** True when [mounted] includes the stored subtitle this event retimed. */ +/** + * True when [mounted] includes the subtitle this event retimed: by sync key, + * or by stored subtitle ID from a server that sends no key. + */ fun PlaybackSubtitleTimingChanged.affects(mounted: List): Boolean { + syncKey?.takeIf(String::isNotBlank)?.let { key -> return mounted.any { it.syncKey == key } } val id = subtitleId ?: return false return mounted.any { it.storedSubtitleId() == id } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncController.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncController.kt new file mode 100644 index 0000000000..b66598c773 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncController.kt @@ -0,0 +1,458 @@ +package org.siloserver.silo.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +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.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.model.playback.SubtitleIdentity +import org.siloserver.silo.model.subtitles.DownloadedSubtitle +import org.siloserver.silo.model.subtitles.SubtitleSyncJob +import org.siloserver.silo.model.subtitles.SubtitleSyncResultLine +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.model.subtitles.SubtitleTiming +import org.siloserver.silo.model.subtitles.subtitleSyncResultLine +import org.siloserver.silo.model.subtitles.subtitleSyncStatusLabel +import org.siloserver.silo.model.subtitles.syncPhaseLabel +import org.siloserver.silo.model.subtitles.syncProgressPercent +import org.siloserver.silo.model.subtitles.syncState +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.SubtitleSyncSource + +/** What the player knows about one syncable subtitle's timing and sync. */ +data class SubtitleSyncEntry( + val state: SubtitleSyncState, + /** A sync or timing request is on the wire. */ + val busy: Boolean = false, + /** The server refused to let this viewer retime the subtitle (403, as in demo mode). */ + val forbidden: Boolean = false, + /** The subtitle's format cannot be synced (422). */ + val unsupported: Boolean = false, + /** The last action's failure, in plain words. */ + val error: String? = null, + /** Polling gave up; reopening the subtitle menu re-arms it. */ + val pollExpired: Boolean = false, + /** + * A job this viewer started: a sync they asked for, or the automatic sync + * of a subtitle they just downloaded or uploaded. The player reports its + * progress and outcome to them. + */ + val watchedJobId: String? = null, +) { + val statusLabel: String? get() = subtitleSyncStatusLabel(state.timing, state.sync) + val inProgress: Boolean get() = state.sync?.inProgress == true +} + +data class SubtitleSyncUiState( + /** The server can align subtitles to audio for this viewer. */ + val available: Boolean = false, + /** Subtitle files next to the media can be synced too. */ + val externalAvailable: Boolean = false, + /** Entries by sync key. */ + val entries: Map = emptyMap(), +) { + fun canSync(entry: SubtitleSyncEntry): Boolean = + available && !entry.unsupported && (!entry.state.isSidecar || externalAvailable) +} + +/** The sync status line for a row of the subtitle menu, or null when it has none. */ +fun SubtitleSyncUiState.statusLabelFor(track: PlayerSubtitleInfo?): String? = + track?.syncKey?.let(entries::get)?.statusLabel + +/** + * The timing section the player shows for its selected subtitle: "Sync to + * audio" with a running job's progress, the last result, and "Reset timing". + * Anyone who can play the file may retime it; a refusal (demo mode) replaces + * the actions with a short explanation. + */ +data class SubtitleTimingActions( + val key: String, + val statusLabel: String?, + val canSync: Boolean, + val canReset: Boolean, + val inProgress: Boolean, + /** 0..100 while a job runs. */ + val percent: Int?, + /** What a running job is doing. */ + val phaseLabel: String?, + /** The last finished job's outcome. */ + val result: SubtitleSyncResultLine?, + /** What a sync does, shown before the first one. */ + val note: String?, + val busy: Boolean, + val forbidden: Boolean, + val error: String?, +) { + val actionsEnabled: Boolean get() = !busy && !inProgress + + companion object { + const val FORBIDDEN_MESSAGE = "This server doesn't allow changing subtitle timing." + const val SIDECAR_NOTE = "Matches the timing to the audio for everyone. The file itself isn't changed." + const val STORED_NOTE = "Matches the timing to the audio for everyone watching." + } +} + +fun SubtitleSyncUiState.timingActionsFor(key: String?): SubtitleTimingActions? { + val entry = key?.let(entries::get) ?: return null + val canSync = canSync(entry) + val canReset = !entry.state.timing.isIdentity + if (!entry.forbidden && !canSync && !canReset && entry.error == null) return null + val job = entry.state.sync + val inProgress = entry.inProgress + val result = if (inProgress || entry.error != null) null else subtitleSyncResultLine(entry.state.timing, job) + return SubtitleTimingActions( + key = entry.state.key, + statusLabel = entry.statusLabel, + canSync = canSync, + canReset = canReset, + inProgress = inProgress, + percent = syncProgressPercent(job), + phaseLabel = syncPhaseLabel(job), + result = result, + note = when { + entry.forbidden || !canSync || inProgress || result != null -> null + entry.state.isSidecar -> SubtitleTimingActions.SIDECAR_NOTE + else -> SubtitleTimingActions.STORED_NOTE + }, + busy = entry.busy, + forbidden = entry.forbidden, + error = entry.error, + ) +} + +/** The sync key of the subtitle a selection plays, or null when it has none. */ +fun List.syncKeyFor(identity: SubtitleIdentity?): String? = when (identity) { + is SubtitleIdentity.ServerSidecar -> firstOrNull { it.index == identity.serverIndex }?.syncKey + is SubtitleIdentity.Downloaded -> firstOrNull { it.downloadId == identity.downloadId }?.syncKey + else -> null +} + +/** + * The sync key of the subtitle on screen: the [selected] one, when the player + * has it mounted. A legacy session mounts every row, so the first mounted key + * is not necessarily the one showing. + */ +fun List.activeSyncKey(selected: SubtitleIdentity?, mounted: List): String? = + syncKeyFor(selected)?.takeIf(mounted::includesSyncKey) + +/** + * True when [rows] include the subtitle named by [key]: by its inventory sync + * key, or, for a row from a server that publishes none, by its stored ID. + */ +fun List.includesSyncKey(key: String): Boolean = any { row -> + row.syncKey == key || + (row.syncKey == null && row.storedSubtitleId()?.let(SubtitleSyncState::storedKey) == key) +} + +/** + * Tracks the timing and latest sync job of the playing file's syncable + * subtitles (stored ones and subtitle files next to the media), keyed by the + * sync key the playback inventory publishes. It loads them, follows running + * jobs through realtime updates with polling as the fallback, and performs + * "Sync to audio" and "Reset timing". + * + * [onTimingChanged] fires once for each timing change it observes after a + * subtitle's first read, so the player can fetch that track's cues again. + * Results that land after the file changed are dropped; the repository drops + * them after an account or profile change. + */ +class SubtitleSyncController( + private val repository: SubtitleSyncSource, + private val scope: CoroutineScope, + private val onTimingChanged: (String) -> Unit = {}, + private val pollIntervalMs: Long = POLL_INTERVAL_MS, + private val pollLimitMs: Long = POLL_LIMIT_MS, +) { + private val _state = MutableStateFlow(SubtitleSyncUiState()) + val state: StateFlow = _state.asStateFlow() + + private var mediaFileId: Int? = null + private var syncKeys: Set = emptySet() + private var generation = 0L + private var loadJob: Job? = null + private var pollJob: Job? = null + + /** The timing each subtitle's cues were last loaded with, as far as this controller knows. */ + private val loadedTiming = mutableMapOf() + /** Subtitles a realtime event said were retimed, whose next read must reload cues. */ + private val reloadOnNextRead = mutableSetOf() + /** Subtitles a realtime update reported on since the last poll; the next poll skips them. */ + private val pushedSincePoll = mutableSetOf() + /** How long each running job has been polled. */ + private val polledMs = mutableMapOf() + /** Numbers every read as it is sent and every realtime update as it arrives. */ + private var sequence = 0L + /** The number of the read or update each subtitle's entry shows. */ + private val shownSequence = mutableMapOf() + + /** + * Follows the playing file and its inventory's sync keys. A new file (or + * none) drops everything in flight; a key the inventory gains re-reads the + * file's subtitles. + */ + fun bind(mediaFileId: Int?, syncKeys: Set) { + if (mediaFileId != this.mediaFileId) { + this.mediaFileId = mediaFileId + this.syncKeys = syncKeys + generation++ + loadJob?.cancel() + pollJob?.cancel() + loadedTiming.clear() + reloadOnNextRead.clear() + pushedSincePoll.clear() + polledMs.clear() + shownSequence.clear() + _state.value = SubtitleSyncUiState() + if (mediaFileId != null && syncKeys.isNotEmpty()) reload() + return + } + val gained = syncKeys.any { it !in this.syncKeys && it !in _state.value.entries } + this.syncKeys = syncKeys + if (gained) reload() + } + + /** Re-reads the capability and the file's syncable subtitles, re-arming expired polls. */ + fun reload() { + val fileId = mediaFileId ?: return + if (syncKeys.isEmpty() && _state.value.entries.isEmpty()) return + val owner = generation + polledMs.clear() + _state.update { current -> + current.copy(entries = current.entries.mapValues { (_, entry) -> entry.copy(pollExpired = false) }) + } + loadJob?.cancel() + loadJob = scope.launch { + val capability = (repository.syncCapability() as? ApiResult.Success)?.data + val sent = ++sequence + val listed = (repository.listSync(fileId) as? ApiResult.Success)?.data + if (owner != generation) return@launch + // A probe that failed keeps what the last one said. + if (capability != null) { + _state.update { + it.copy(available = capability.available, externalAvailable = capability.available && capability.external) + } + } + listed?.forEach { observe(it, sent) } + startPolling() + } + } + + /** Records a subtitle returned by a download or upload, following its automatic sync. */ + fun remember(subtitle: DownloadedSubtitle) { + if (subtitle.mediaFileId != mediaFileId) return + val state = subtitle.syncState() + polledMs.remove(state.key) + observe(state, ++sequence, watch = state.sync?.takeIf { it.inProgress }?.id) + startPolling() + } + + /** + * The server announced new timing for [key] (realtime): re-read it, and + * reload its cues unless a realtime update already brought that timing. + */ + fun timingChanged(key: String) { + val fileId = mediaFileId ?: return + val owner = generation + reloadOnNextRead += key + scope.launch { + val sent = ++sequence + val read = repository.readSync(fileId, key) + if (owner != generation) return@launch + when (read) { + is ApiResult.Success -> { + observe(read.data, sent) + startPolling() + } + // The event is authoritative enough to reload on its own. + else -> if (reloadOnNextRead.remove(key)) onTimingChanged(key) + } + } + } + + /** The server reported a step of a sync job (realtime). */ + fun syncUpdated(update: PlaybackSubtitleSyncUpdated) { + if (update.mediaFileId != null && update.mediaFileId != mediaFileId) return + val entry = _state.value.entries[update.syncKey] + if (entry == null) { + // Not loaded yet (a track the inventory just gained): read them all. + reload() + return + } + pushedSincePoll += update.syncKey + observe(entry.state.copy(timing = update.timing, sync = update.job), ++sequence) + startPolling() + } + + fun requestSync(key: String) { + val fileId = mediaFileId ?: return + if (_state.value.entries[key]?.busy != false) return + val owner = generation + polledMs.remove(key) + patch(key) { it.copy(busy = true, error = null, pollExpired = false) } + scope.launch { + val sent = ++sequence + val result = repository.startSync(fileId, key) + if (owner != generation) return@launch + when (result) { + is ApiResult.Success -> { + observe(result.data, sent, watch = result.data.sync?.id) + patch(key) { it.copy(busy = false) } + startPolling() + } + else -> fail(key, result, "Sync failed") + } + } + } + + fun resetTiming(key: String) { + val fileId = mediaFileId ?: return + if (_state.value.entries[key]?.busy != false) return + val owner = generation + patch(key) { it.copy(busy = true, error = null) } + scope.launch { + val sent = ++sequence + val result = repository.resetTiming(fileId, key) + if (owner != generation) return@launch + when (result) { + is ApiResult.Success -> { + observe(result.data, sent) + patch(key) { it.copy(busy = false) } + } + else -> fail(key, result, "Couldn't reset timing") + } + } + } + + private fun fail(key: String, result: ApiResult<*>, fallback: String) { + val error = result as? ApiResult.Error + patch(key) { entry -> + when (error?.code) { + 403 -> entry.copy(busy = false, forbidden = true, error = null) + 422 -> entry.copy(busy = false, unsupported = true, error = "This format can't be synced.") + 412 -> entry.copy(busy = false, error = "The subtitle changed. Try again.") + else -> entry.copy(busy = false, error = error?.message?.takeIf { it.isNotBlank() } ?: fallback) + } + } + } + + /** + * Records a server view of a subtitle from the read sent, or the update + * received, as number [sent]; [watch] marks a job this viewer started. + */ + private fun observe(state: SubtitleSyncState, sent: Long, watch: String? = null) { + val key = state.key + val known = _state.value.entries[key] + if (known != null && isStale(state.sync, sent, known)) { + // A response that arrived after a newer one: keep what the newer + // one said, but still follow the job if this viewer started it. + if (watch != null && watch == known.state.sync?.id) patch(key) { it.copy(watchedJobId = watch) } + return + } + shownSequence[key] = sent + val previous = loadedTiming.put(key, state.timing) + val forced = reloadOnNextRead.remove(key) + val inProgress = state.sync?.inProgress == true + if (!inProgress) polledMs.remove(key) + _state.update { current -> + val entry = current.entries[key] ?: SubtitleSyncEntry(state) + current.copy( + entries = current.entries + (key to entry.copy( + state = state, + pollExpired = inProgress && entry.pollExpired, + watchedJobId = watch ?: entry.watchedJobId, + )), + ) + } + // A realtime event about a subtitle never read before reloads it; a + // known one reloads only when its timing really moved, so the event + // that follows a realtime update carrying the new timing is a no-op. + if (if (previous == null) forced else previous != state.timing) onTimingChanged(key) + } + + /** + * True when a view reporting [incoming], from read or update number [sent], + * is older than [known]. The server does not sequence its responses and + * realtime updates, so a response can arrive after a newer one. A view of + * an older job is stale. So is one from a read sent before [known] arrived, + * unless it reports a job [known] has not reached, which the server can only + * have produced later. + */ + private fun isStale(incoming: SubtitleSyncJob?, sent: Long, known: SubtitleSyncEntry): Boolean { + val current = known.state.sync + if (isOlderJob(incoming, current)) return true + val sentBefore = sent < (shownSequence[known.state.key] ?: 0L) + val laterJob = incoming != null && (current == null || isOlderJob(current, incoming)) + return sentBefore && !laterJob + } + + /** + * True when [incoming] describes an earlier point than [current]: an older + * job, or the same job further back (running after it finished, or at + * less progress). + */ + private fun isOlderJob(incoming: SubtitleSyncJob?, current: SubtitleSyncJob?): Boolean { + if (incoming == null || current == null) return false + if (incoming.id != current.id) { + return incoming.createdAt.isNotEmpty() && current.createdAt.isNotEmpty() && incoming.createdAt < current.createdAt + } + return jobStep(incoming) < jobStep(current) + } + + /** How far a job has got: its progress while active, past every progress once finished. */ + private fun jobStep(job: SubtitleSyncJob): Double = if (job.inProgress) job.progress ?: 0.0 else 2.0 + + private fun patch(key: String, change: (SubtitleSyncEntry) -> SubtitleSyncEntry) { + _state.update { current -> + val entry = current.entries[key] ?: return@update current + current.copy(entries = current.entries + (key to change(entry))) + } + } + + /** + * Polls every running job until it ends, its limit passes, or the file + * changes. Realtime updates usually arrive first; a subtitle that had one + * since the last poll skips it. + */ + private fun startPolling() { + if (pollJob?.isActive == true) return + val fileId = mediaFileId ?: return + val owner = generation + pollJob = scope.launch { + while (owner == generation) { + if (pollable().isEmpty()) return@launch + delay(pollIntervalMs) + if (owner != generation) return@launch + for (key in pollable()) { + val polled = (polledMs[key] ?: 0L) + pollIntervalMs + polledMs[key] = polled + if (polled > pollLimitMs) { + polledMs.remove(key) + patch(key) { it.copy(pollExpired = true) } + continue + } + if (pushedSincePoll.remove(key)) continue + val sent = ++sequence + when (val read = repository.readSync(fileId, key)) { + is ApiResult.Success -> if (owner == generation) observe(read.data, sent) + // A subtitle the server stops answering for (deleted, + // access lost) is not polled again until a reload. + is ApiResult.Error -> if (owner == generation) patch(key) { it.copy(pollExpired = true) } + is ApiResult.NetworkError -> Unit + } + } + } + } + } + + private fun pollable(): List = + _state.value.entries.values.filter { it.inProgress && !it.pollExpired }.map { it.state.key } + + companion object { + const val POLL_INTERVAL_MS = 3_000L + const val POLL_LIMIT_MS = 5 * 60_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedback.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedback.kt new file mode 100644 index 0000000000..31c3eeb783 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedback.kt @@ -0,0 +1,309 @@ +package org.siloserver.silo.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.siloserver.silo.model.subtitles.SubtitleSyncJob +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.model.subtitles.SubtitleTiming +import org.siloserver.silo.model.subtitles.describeSyncTiming +import org.siloserver.silo.model.subtitles.syncFailureMessage +import org.siloserver.silo.model.subtitles.syncPhaseLabel +import org.siloserver.silo.model.subtitles.syncProgressPercent + +/** What the player's subtitle sync card shows. */ +data class SubtitleSyncNotice( + /** Stays the same while one job runs, so the card updates in place. */ + val id: String, + val tone: Tone, + val title: String, + val detail: String? = null, + /** 0..100 while a job runs. */ + val percent: Int? = null, +) { + enum class Tone { Progress, Success, Info, Warning } +} + +/** The most recent job this viewer started, with its subtitle. */ +data class SubtitleSyncWatched( + val key: String, + /** The subtitle's language name, or null when it has none to show. */ + val name: String?, + val job: SubtitleSyncJob, + val timing: SubtitleTiming, + /** The subtitle's cue revision now. */ + val revision: Int, +) + +/** + * What one update tells the feedback about sync state and the screen. A cue + * revision grows each time the player fetches a track again because its + * timing changed; the loaded revision is the one whose cues are on screen. + */ +data class SubtitleSyncFeedbackInput( + val watched: SubtitleSyncWatched?, + /** Sync key of the track on screen. */ + val activeKey: String?, + val activeRevision: Int = 0, + val activeLoadedRevision: Int = 0, + /** The entry of the track on screen. */ + val active: SubtitleSyncEntry? = null, +) + +data class SubtitleSyncFeedbackState( + val input: SubtitleSyncFeedbackInput? = null, + val notice: SubtitleSyncNotice? = null, + /** Watched jobs whose outcome was announced. */ + val announced: Set = emptySet(), + /** Watched synced jobs whose corrected cues are showing. */ + val applied: Set = emptySet(), + /** Cue revision of a watched job's track when the job was first seen. */ + val startRevision: Map = emptyMap(), + val applying: Applying? = null, + /** A timing change of the track on screen nobody here asked for, until it shows. */ + val foreign: Foreign? = null, +) { + data class Applying( + val key: String, + val jobId: String, + val detail: String, + /** The track's cue revision when the job started; a later one that loads shows the result. */ + val startRevision: Int, + ) + + data class Foreign(val key: String, val revision: Int) +} + +/** Shows the success the applying job earned once its cues show. */ +fun SubtitleSyncFeedbackState.finishApplying(): SubtitleSyncFeedbackState { + val applying = applying ?: return this + return copy( + applying = null, + applied = applied + applying.jobId, + notice = SubtitleSyncNotice( + id = applying.jobId, + tone = SubtitleSyncNotice.Tone.Success, + title = "Subtitles synced", + detail = applying.detail, + ), + ) +} + +private fun subtitlesOf(name: String?): String = name?.let { "$it subtitles" } ?: "subtitles" + +private fun outcomeNotice(watched: SubtitleSyncWatched): SubtitleSyncNotice? { + val job = watched.job + val subject = subtitlesOf(watched.name).replaceFirstChar(Char::uppercaseChar) + return when (job.status) { + SubtitleSyncJob.SYNCED -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Success, + title = "$subject synced", + detail = describeSyncTiming(watched.timing), + ) + SubtitleSyncJob.ALREADY_SYNCED -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Info, + title = "$subject already match the audio", + ) + SubtitleSyncJob.NO_MATCH -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Warning, + title = "$subject don't match the audio", + detail = "They're probably for another release. The timing wasn't changed.", + ) + SubtitleSyncJob.FAILED -> SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Warning, + title = "Couldn't sync ${subtitlesOf(watched.name)}", + detail = syncFailureMessage(job.failure), + ) + else -> null + } +} + +/** + * Advances the feedback by one update: the progress, then the outcome, of a + * sync this viewer started; when it changed the subtitle on screen, the + * moment its corrected cues show; and a note when someone else's change to + * the subtitle on screen shows. Mirrors the web player's feedback. + */ +fun SubtitleSyncFeedbackState.step(input: SubtitleSyncFeedbackInput): SubtitleSyncFeedbackState { + val before = this.input + var next = copy(input = input) + fun show(notice: SubtitleSyncNotice?) { + if (next.notice != notice) next = next.copy(notice = notice) + } + fun loadedAfter(key: String, revision: Int) = key == input.activeKey && input.activeLoadedRevision > revision + + val watched = input.watched + if (watched != null) { + val job = watched.job + if (job.id !in next.startRevision) { + next = next.copy(startRevision = next.startRevision + (job.id to watched.revision)) + } + if (job.inProgress) { + show( + SubtitleSyncNotice( + id = job.id, + tone = SubtitleSyncNotice.Tone.Progress, + title = "Syncing ${subtitlesOf(watched.name)}", + detail = syncPhaseLabel(job), + percent = syncProgressPercent(job) ?: 0, + ), + ) + } else if (job.id !in next.announced) { + next = next.copy(announced = next.announced + job.id) + if (job.status == SubtitleSyncJob.SYNCED && watched.key == input.activeKey) { + next = next.copy( + applying = SubtitleSyncFeedbackState.Applying( + key = watched.key, + jobId = job.id, + detail = describeSyncTiming(watched.timing), + startRevision = next.startRevision[job.id] ?: watched.revision, + ), + ) + show(SubtitleSyncNotice(job.id, SubtitleSyncNotice.Tone.Progress, "Applying new timing…", percent = 100)) + } else { + show(outcomeNotice(watched)) + } + } + } + // A progress card whose job is no longer followed (another file, or its + // polling gave up) would otherwise stay up forever. + next.notice?.let { notice -> + if (notice.tone == SubtitleSyncNotice.Tone.Progress && + notice.id != next.applying?.jobId && + notice.id != watched?.job?.id + ) { + show(null) + } + } + + next.applying?.let { applying -> + if (applying.key != input.activeKey || loadedAfter(applying.key, applying.startRevision)) { + next = next.finishApplying() + } + } + + val activeKey = input.activeKey + if (activeKey != null && before?.activeKey == activeKey && input.activeRevision > before.activeRevision) { + val active = input.active + val job = active?.state?.sync + val own = active?.watchedJobId != null && job?.id == active.watchedJobId && + (job.inProgress || (job.status == SubtitleSyncJob.SYNCED && job.id !in next.applied)) + if (!own) next = next.copy(foreign = SubtitleSyncFeedbackState.Foreign(activeKey, before.activeRevision)) + } + next.foreign?.let { foreign -> + if (foreign.key != activeKey) { + next = next.copy(foreign = null) + } else if (loadedAfter(foreign.key, foreign.revision)) { + next = next.copy(foreign = null) + show( + SubtitleSyncNotice( + id = "timing:$activeKey:${input.activeRevision}", + tone = SubtitleSyncNotice.Tone.Info, + title = "Subtitle timing updated", + ), + ) + } + } + return next +} + +/** The most recent job this viewer started, among the file's subtitles, while it is still followed. */ +fun SubtitleSyncUiState.watchedEntry(): SubtitleSyncEntry? = + entries.values + .filter { entry -> + entry.watchedJobId != null && entry.state.sync?.id == entry.watchedJobId && + !(entry.inProgress && entry.pollExpired) + } + .maxByOrNull { it.state.sync?.createdAt.orEmpty() } + +/** Builds one feedback update from the sync state and what the player shows. */ +fun SubtitleSyncUiState.feedbackInput( + activeKey: String?, + cueRevisions: Map, + loadedCueRevisions: Map, + nameOf: (SubtitleSyncState) -> String?, +): SubtitleSyncFeedbackInput { + val watched = watchedEntry()?.let { entry -> + SubtitleSyncWatched( + key = entry.state.key, + name = nameOf(entry.state)?.takeIf(String::isNotBlank), + job = entry.state.sync ?: return@let null, + timing = entry.state.timing, + revision = cueRevisions[entry.state.key] ?: 0, + ) + } + return SubtitleSyncFeedbackInput( + watched = watched, + activeKey = activeKey, + activeRevision = activeKey?.let(cueRevisions::get) ?: 0, + activeLoadedRevision = activeKey?.let(loadedCueRevisions::get) ?: 0, + active = activeKey?.let(entries::get), + ) +} + +/** + * Turns subtitle sync state into one notice at a time for the viewer (see + * [step]). Finished notices leave on their own; "Applying new timing" gives + * way to its success after [applyTimeoutMs] even when the reload never + * reports. + */ +class SubtitleSyncFeedbackTracker( + private val scope: CoroutineScope, + private val visibleMs: Long = NOTICE_VISIBLE_MS, + private val warningVisibleMs: Long = WARNING_VISIBLE_MS, + private val applyTimeoutMs: Long = APPLY_TIMEOUT_MS, +) { + private var state = SubtitleSyncFeedbackState() + private val _notice = MutableStateFlow(null) + val notice: StateFlow = _notice.asStateFlow() + + private var applyTimer: Job? = null + private var hideTimer: Job? = null + + fun update(input: SubtitleSyncFeedbackInput) { + if (state.input == input) return + set(state.step(input)) + } + + fun dismiss() = set(state.copy(notice = null)) + + private fun set(next: SubtitleSyncFeedbackState) { + val previous = state + state = next + if (next.applying != previous.applying) { + applyTimer?.cancel() + val applying = next.applying + applyTimer = applying?.let { + scope.launch { + delay(applyTimeoutMs) + if (state.applying == applying) set(state.finishApplying()) + } + } + } + if (next.notice != previous.notice) { + hideTimer?.cancel() + val notice = next.notice + _notice.value = notice + hideTimer = notice?.takeIf { it.tone != SubtitleSyncNotice.Tone.Progress }?.let { + scope.launch { + delay(if (notice.tone == SubtitleSyncNotice.Tone.Warning) warningVisibleMs else visibleMs) + if (state.notice == notice) set(state.copy(notice = null)) + } + } + } + } + + companion object { + const val NOTICE_VISIBLE_MS = 6_000L + const val WARNING_VISIBLE_MS = 9_000L + const val APPLY_TIMEOUT_MS = 8_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SubtitlesRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SubtitlesRepository.kt index 26326b9b67..c458dead76 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SubtitlesRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SubtitlesRepository.kt @@ -17,6 +17,10 @@ import org.siloserver.silo.model.subtitles.SubtitleSearchResponse import org.siloserver.silo.model.subtitles.SubtitleTranslateRequest import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.SubtitlesApi +import org.siloserver.silo.network.apiv2.SubtitleSyncV2Api +import org.siloserver.silo.model.subtitles.SubtitleSyncCapability +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.model.subtitles.SubtitleTiming import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay @@ -32,7 +36,20 @@ import kotlinx.coroutines.delay * - rethrows [CancellationException] so callers can cancel via structured * concurrency (player exit cancels the viewModelScope job) */ -class SubtitlesRepository(private val api: SubtitlesApi, private val tokens: org.siloserver.silo.network.TokenManager? = null) { +/** The subtitle sync operations the player needs, by media file and sync key. */ +interface SubtitleSyncSource { + suspend fun syncCapability(): ApiResult + suspend fun listSync(mediaFileId: Int): ApiResult> + suspend fun readSync(mediaFileId: Int, key: String): ApiResult + suspend fun startSync(mediaFileId: Int, key: String): ApiResult + suspend fun resetTiming(mediaFileId: Int, key: String): ApiResult +} + +class SubtitlesRepository( + private val api: SubtitlesApi, + private val tokens: org.siloserver.silo.network.TokenManager? = null, + private val sync: SubtitleSyncV2Api? = null, +) : SubtitleSyncSource { /** Terminal result of [pollJob]. */ sealed class SubtitleJobOutcome { @@ -52,6 +69,22 @@ class SubtitlesRepository(private val api: SubtitlesApi, private val tokens: org suspend fun list(mediaFileId: Int): ApiResult = api.list(mediaFileId) + // ---- Subtitle sync (stored subtitles and sidecars, by sync key) ---- + + override suspend fun syncCapability(): ApiResult = sync?.status() ?: syncUnavailable + + override suspend fun listSync(mediaFileId: Int): ApiResult> = + sync?.list(mediaFileId) ?: syncUnavailable + + override suspend fun readSync(mediaFileId: Int, key: String): ApiResult = + sync?.read(mediaFileId, key) ?: syncUnavailable + + override suspend fun startSync(mediaFileId: Int, key: String): ApiResult = + sync?.start(mediaFileId, key) ?: syncUnavailable + + override suspend fun resetTiming(mediaFileId: Int, key: String): ApiResult = + sync?.setTiming(mediaFileId, key, SubtitleTiming()) ?: syncUnavailable + suspend fun aiStatus(): ApiResult = api.aiStatus() suspend fun aiQuota(): ApiResult = api.aiQuota() @@ -125,3 +158,5 @@ class SubtitlesRepository(private val api: SubtitlesApi, private val tokens: org } } } + +private val syncUnavailable = ApiResult.Error(0, "sync_unavailable", "Subtitle sync is not available.") diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt index f85c42642e..710ad95e61 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt @@ -170,4 +170,37 @@ class OfflineTracksTest { val unversioned = OfflineTrackInfo(subtitles = listOf(current.copy(revision = null))) assertEquals(listOf(current.copy(revision = null) to "1"), unversioned.subtitlesWithNewRevision(manifest)) } + + @Test + fun refreshesSavedExternalSidecarsButNotOnesWhosePositionNowNamesAnotherFile() { + val manifest = requireNotNull( + decodeOfflineManifestTracks( + """ + {"subtitles": [ + {"language": "en", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0", "revision": "x2"}, + {"language": "de", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:1", "revision": "y2"}, + {"language": "es", "format": "ass", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:2", "revision": "z2"}, + {"language": "en", "format": "srt", "title": "Commentary", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:3", "revision": "t2"}, + {"language": "en", "format": "srt", "hearing_impaired": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:4", "revision": "h2"}, + {"language": "fr", "format": "srt", "fetch_url": "/api/v2/downloads/dl_1/subtitles/downloaded:9", "revision": "d2"} + ]} + """.trimIndent(), + ), + ) + val english = OfflineSubtitleFile( + path = "/d/0.srt", format = "srt", language = "en", + fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:0", revision = "x1", + ) + // external:1 was French when saved; another file now sits at that position. + val shifted = english.copy(path = "/d/1.srt", language = "fr", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:1", revision = "y1") + val reformatted = english.copy(path = "/d/2.srt", language = "es", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:2", revision = "z1") + // Same language and format, but the row now lists another title or SDH flag. + val retitled = english.copy(path = "/d/3.srt", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:3", revision = "t1") + val nowSdh = english.copy(path = "/d/4.srt", fetchUrl = "/api/v2/downloads/dl_1/subtitles/external:4", revision = "h1") + // A stored subtitle's ref is stable, so its edited metadata does not block a refresh. + val stored = english.copy(path = "/d/5.srt", fetchUrl = "/api/v2/downloads/dl_1/subtitles/downloaded:9", revision = "d1") + val tracks = OfflineTrackInfo(subtitles = listOf(english, shifted, reformatted, retitled, nowSdh, stored)) + + assertEquals(listOf(english to "x2", stored to "d2"), tracks.subtitlesWithNewRevision(manifest)) + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMergeTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMergeTest.kt index 73dc05d4ac..5e4550dca1 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMergeTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/SubtitleTrackMergeTest.kt @@ -72,6 +72,22 @@ class SubtitleTrackMergeTest { assertEquals(313, second.downloadId) assertEquals("Dune Part Three (subdl)", second.label) assertEquals("/stream/sess-1/subtitles/3.ass", second.url) + + // Stored subtitles the server can retime are named for sync as `stored-{id}`. + assertEquals("stored-312", first.syncKey) + assertEquals("stored-313", second.syncKey) + } + + @Test + fun `a stored subtitle the server cannot retime gets no sync key`() { + val merged = mergeDownloadedSubtitles( + existing = emptyList(), + downloaded = listOf( + downloaded(id = 7, language = "en", format = "pgs", releaseName = "r", provider = "upload"), + ), + sessionId = "sess-1", + ) + assertNull(merged.single().syncKey) } @Test diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncLabelTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncLabelTest.kt new file mode 100644 index 0000000000..c4e36f1f9b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncLabelTest.kt @@ -0,0 +1,62 @@ +package org.siloserver.silo.model.subtitles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SubtitleSyncLabelTest { + private val identity = SubtitleTiming() + + private fun job(status: String, result: SubtitleTiming? = null) = SubtitleSyncJob(status = status, result = result) + + @Test + fun formatsOffsetsWithOneDecimal() { + assertEquals("−3.2 s", formatSyncOffset(-3_200)) + assertEquals("+1.4 s", formatSyncOffset(1_400)) + assertEquals("+2.0 s", formatSyncOffset(2_000)) + assertEquals("−6.2 s", formatSyncOffset(-6_192)) + assertEquals("+0.0 s", formatSyncOffset(0)) + } + + @Test + fun namesFrameRateConversionsAndSpeedFactors() { + assertEquals("25→23.976 fps", describeSyncScale(25.0 / 23.976)) + assertEquals("×1.0008 speed", describeSyncScale(1.0008)) + assertEquals("×0.9991 speed", describeSyncScale(0.9991277785047734)) + assertEquals("×1.1 speed", describeSyncScale(1.1)) + assertEquals("×1 speed", describeSyncScale(1.00001)) + assertNull(describeSyncScale(1.0)) + } + + @Test + fun matchesTheWebPlayerWording() { + assertEquals("Syncing…", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.PENDING))) + assertEquals("Syncing…", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.RUNNING))) + assertEquals("Doesn't match this video", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.NO_MATCH))) + assertEquals("Sync failed", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.FAILED))) + assertEquals("Already in sync", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.ALREADY_SYNCED))) + + val offset = SubtitleTiming(offsetMs = -3_200) + assertEquals("Synced −3.2 s", subtitleSyncStatusLabel(offset, job(SubtitleSyncJob.SYNCED, offset))) + val drift = SubtitleTiming(offsetMs = -23_700, scale = 1.0008) + assertEquals("Synced −23.7 s · ×1.0008 speed", subtitleSyncStatusLabel(drift, job(SubtitleSyncJob.SYNCED, drift))) + val frameRate = SubtitleTiming(offsetMs = 1_400, scale = 25.0 / 23.976) + assertEquals( + "Synced +1.4 s · 25→23.976 fps", + subtitleSyncStatusLabel(frameRate, job(SubtitleSyncJob.SYNCED, frameRate)), + ) + } + + @Test + fun reportsResetsAndManualChanges() { + // A synced subtitle that was reset afterwards plays its original timing. + val found = SubtitleTiming(offsetMs = -3_200) + assertEquals("Original timing", subtitleSyncStatusLabel(identity, job(SubtitleSyncJob.SYNCED, found))) + // Timing that differs from the job's result was set some other way. + assertEquals( + "Timing adjusted +0.5 s", + subtitleSyncStatusLabel(SubtitleTiming(offsetMs = 500), job(SubtitleSyncJob.SYNCED, found)), + ) + assertNull(subtitleSyncStatusLabel(identity, null)) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncStateTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncStateTest.kt new file mode 100644 index 0000000000..df63541d0b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/subtitles/SubtitleSyncStateTest.kt @@ -0,0 +1,117 @@ +package org.siloserver.silo.model.subtitles + +import org.siloserver.silo.network.SiloJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SubtitleSyncStateTest { + private val key = "external-1b12a8ccaa6122106f36824eb19e86f7a6dbee64b733031838a285229dbed67b" + + @Test + fun decodesARunningSidecarSync() { + val state = SiloJson.decodeFromString( + SubtitleSyncState.serializer(), + """{"key":"$key","media_file_id":"1","source":"external","language":"en","format":"srt", + "label":"Night Train (2024).en.srt","timing":{"offset_ms":0,"scale":1}, + "sync":{"id":"2","status":"running","trigger":"manual","phase":"analyzing","progress":0.4, + "confidence":null,"created_at":"2026-10-04T02:37:35.235Z","finished_at":null}}""", + ) + assertTrue(state.isSidecar) + assertNull(state.storedSubtitleId) + val job = state.sync!! + assertTrue(job.inProgress) + assertEquals("2", job.id) + assertEquals("manual", job.trigger) + assertEquals(40, syncProgressPercent(job)) + assertEquals("Listening to the audio…", syncPhaseLabel(job)) + assertEquals("Syncing… 40%", subtitleSyncStatusLabel(state.timing, job)) + } + + @Test + fun decodesAFinishedStoredSyncAndTheCapability() { + val state = SiloJson.decodeFromString( + SubtitleSyncState.serializer(), + """{"key":"stored-9","media_file_id":"1","source":"downloaded","stored_subtitle_id":"9","language":"en", + "format":"srt","label":"release","timing":{"offset_ms":-3010,"scale":1}, + "sync":{"id":"5","status":"synced","trigger":"auto","confidence":1,"result":{"offset_ms":-3010,"scale":1}, + "created_at":"2026-10-04T02:37:35.235Z","finished_at":"2026-10-04T02:37:35.351Z"}}""", + ) + assertFalse(state.isSidecar) + assertEquals("9", state.storedSubtitleId) + assertNull(syncProgressPercent(state.sync)) + assertEquals("Synced −3.0 s", subtitleSyncStatusLabel(state.timing, state.sync)) + assertEquals( + SubtitleSyncResultLine("Synced to the audio: −3.0 s"), + subtitleSyncResultLine(state.timing, state.sync), + ) + + val capability = SiloJson.decodeFromString( + SubtitleSyncCapability.serializer(), + """{"revision":"r","state":"available","allowed":true,"auto_sync":true,"external":true}""", + ) + assertTrue(capability.available) + assertTrue(capability.external) + // A server that predates sidecar sync omits `external`. + assertFalse( + SiloJson.decodeFromString( + SubtitleSyncCapability.serializer(), + """{"revision":"r","state":"available","allowed":true,"auto_sync":true}""", + ).external, + ) + } + + @Test + fun aStoredDownloadCarriesItsAutomaticJob() { + val subtitle = DownloadedSubtitle( + id = 9, mediaFileId = 1, provider = "upload", language = "en", format = "srt", releaseName = "", + sync = SubtitleSyncJob(id = "7", status = SubtitleSyncJob.PENDING, trigger = "auto"), + ) + val state = subtitle.syncState() + assertEquals("stored-9", state.key) + assertEquals("1", state.mediaFileId) + assertEquals("upload", state.label) + assertEquals("7", state.sync?.id) + } + + @Test + fun describesProgressPhasesAndFailures() { + fun running(phase: String?, progress: Double?) = + SubtitleSyncJob(id = "1", status = SubtitleSyncJob.RUNNING, phase = phase, progress = progress) + assertEquals("Waiting to start…", syncPhaseLabel(running("queued", 0.0))) + assertEquals("Matching lines to speech…", syncPhaseLabel(running("matching", 0.9))) + assertEquals("Syncing…", subtitleSyncStatusLabel(SubtitleTiming(), running("queued", 0.0))) + assertEquals(100, syncProgressPercent(running("matching", 1.4))) + assertNull(syncPhaseLabel(SubtitleSyncJob(status = SubtitleSyncJob.FAILED))) + + assertEquals("The subtitle changed while it was syncing. Try again.", syncFailureMessage("subtitle_changed")) + assertEquals("This video has no audio Silo can read.", syncFailureMessage("no_audio")) + assertEquals("The server is busy. Try again in a few minutes.", syncFailureMessage("unavailable")) + assertEquals("Sync failed. Try again.", syncFailureMessage("error")) + assertEquals("Sync failed. Try again.", syncFailureMessage(null)) + } + + @Test + fun resultLinesMatchTheWebPlayer() { + val identity = SubtitleTiming() + assertEquals( + SubtitleSyncResultLine("Doesn't match this video's audio; probably for another release.", warning = true), + subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.NO_MATCH)), + ) + assertEquals( + SubtitleSyncResultLine("This video has no audio Silo can read.", warning = true), + subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.FAILED, failure = "no_audio")), + ) + assertEquals( + SubtitleSyncResultLine("Already matches the audio."), + subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.ALREADY_SYNCED)), + ) + // A synced subtitle that was reset afterwards has no result to show. + assertNull(subtitleSyncResultLine(identity, SubtitleSyncJob(status = SubtitleSyncJob.SYNCED))) + assertNull(subtitleSyncResultLine(identity, null)) + assertEquals("+0.5 s · 25→23.976 fps", describeSyncTiming(SubtitleTiming(500, 25.0 / 23.976))) + assertEquals("25→23.976 fps", describeSyncTiming(SubtitleTiming(0, 25.0 / 23.976))) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Test.kt new file mode 100644 index 0000000000..4041c98f1b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/SubtitleSyncV2Test.kt @@ -0,0 +1,146 @@ +package org.siloserver.silo.network.apiv2 + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.subtitles.* +import org.siloserver.silo.network.* +import kotlin.test.* + +class SubtitleSyncV2Test { + private val scope = AuthScopeSnapshot("server", "profile", "https://example.invalid", null, identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = scope } + private val key = "external-" + "0123456789abcdef".repeat(4) + + private fun state(timing: String = """{"offset_ms":0,"scale":1}""", sync: String? = null, file: String = "1", key: String = this.key) = + """{"key":"$key","media_file_id":"$file","source":"external","language":"en","format":"srt","label":"a.en.srt","timing":$timing""" + + (sync?.let { ""","sync":$it""" } ?: "") + "}" + + private val pending = """{"id":"2","status":"pending","trigger":"manual","phase":"queued","progress":0,"confidence":null,"created_at":"2026-10-04T02:37:35.235Z","finished_at":null}""" + + private fun client(handler: suspend MockRequestHandleScope.(io.ktor.client.request.HttpRequestData) -> io.ktor.client.request.HttpResponseData) = + HttpClient(MockEngine(handler)) { install(ContentNegotiation) { json(SiloJson) } } + + private fun MockRequestHandleScope.reply(body: String, status: HttpStatusCode = HttpStatusCode.OK, etag: String? = null) = + respond( + body, + status, + if (etag == null) headersOf(HttpHeaders.ContentType, "application/json") + else headersOf(HttpHeaders.ContentType to listOf("application/json"), HttpHeaders.ETag to listOf(etag)), + ) + + @Test + fun listsAndStartsBySyncKey() = runTest { + val c = client { request -> + when (request.method to request.url.encodedPath) { + HttpMethod.Get to "/api/v2/subtitles/1/sync" -> reply("""{"subtitles":[${state()}]}""") + HttpMethod.Post to "/api/v2/subtitles/1/sync/$key" -> + reply("""{"subtitle":${state(sync = pending)}}""", HttpStatusCode.Accepted) + else -> error("unexpected ${request.method} ${request.url}") + } + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + val listed = assertIs>>(api.list(1)).data + assertEquals(listOf(key), listed.map { it.key }) + val started = assertIs>(api.start(1, key)).data + assertEquals("pending", started.sync?.status) + assertEquals("queued", started.sync?.phase) + } finally { + c.close() + } + } + + @Test + fun refusesAnswersForAnotherFileOrSubtitle() = runTest { + var body = """{"subtitles":[${state(file = "2")}]}""" + val c = client { reply(body, if (it.method == HttpMethod.Post) HttpStatusCode.Accepted else HttpStatusCode.OK) } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertIs(api.list(1)) + body = """{"subtitle":${state(key = "stored-9")}}""" + assertIs(api.start(1, key)) + } finally { + c.close() + } + } + + @Test + fun neverPutsAMalformedKeyInAUrl() = runTest { + val c = client { error("no request expected: ${it.url}") } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + for (bad in listOf("stored-0", "stored-x", "external-ABC", "../stored-9", "external-" + "a".repeat(63))) { + assertEquals("invalid_sync_key", assertIs(api.start(1, bad)).error) + } + assertTrue(isSubtitleSyncKey("stored-9")) + assertTrue(isSubtitleSyncKey(key)) + } finally { + c.close() + } + } + + @Test + fun resetSendsTheReadValidator() = runTest { + var puts = 0 + val c = client { request -> + when (request.method) { + HttpMethod.Get -> reply("""{"subtitle":${state(timing = """{"offset_ms":-3010,"scale":1}""")}}""", etag = "\"v1\"") + HttpMethod.Put -> { + puts++ + assertEquals("/api/v2/subtitles/1/sync/$key/timing", request.url.encodedPath) + assertEquals("\"v1\"", request.headers[HttpHeaders.IfMatch]) + val sent = SiloJson.decodeFromString(SubtitleTiming.serializer(), request.body.toByteArray().decodeToString()) + assertEquals(SubtitleTiming(), sent) + reply("""{"subtitle":${state()}}""", etag = "\"v2\"") + } + else -> error("unexpected ${request.method}") + } + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + val reset = assertIs>(api.setTiming(1, key, SubtitleTiming())).data + assertTrue(reset.timing.isIdentity) + assertEquals(1, puts) + } finally { + c.close() + } + } + + @Test + fun aStaleValidatorOrADemoRefusalSurfacesItsStatus() = runTest { + var putStatus = HttpStatusCode.PreconditionFailed + val problem = """{"type":"https://siloserver.org/docs/api/v2/problems/precondition_failed","title":"x","status":412,"detail":"stale"}""" + val c = client { request -> + when (request.method) { + HttpMethod.Get -> reply("""{"subtitle":${state()}}""", etag = "\"v1\"") + else -> reply(problem, putStatus) + } + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertEquals(412, assertIs(api.setTiming(1, key, SubtitleTiming())).code) + putStatus = HttpStatusCode.Forbidden + assertEquals(403, assertIs(api.start(1, key)).code) + } finally { + c.close() + } + } + + @Test + fun aReadWithoutValidatorDoesNotPut() = runTest { + val c = client { request -> + assertEquals(HttpMethod.Get, request.method) + reply("""{"subtitle":${state()}}""") + } + try { + val api = SubtitleSyncV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertEquals("missing_etag", assertIs(api.setTiming(1, key, SubtitleTiming())).error) + } finally { + c.close() + } + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleSyncEventsTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleSyncEventsTest.kt new file mode 100644 index 0000000000..b53f51fcac --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackSubtitleSyncEventsTest.kt @@ -0,0 +1,99 @@ +package org.siloserver.silo.playback + +import kotlinx.serialization.json.JsonObject +import org.siloserver.silo.model.playback.PlaybackSubtitleInventoryItemV3 +import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.model.playback.SubtitleIdentity +import org.siloserver.silo.model.subtitles.SubtitleSyncJob +import org.siloserver.silo.model.subtitles.SubtitleTiming +import org.siloserver.silo.network.SiloJson +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 PlaybackSubtitleSyncEventsTest { + private val sidecarKey = "external-" + "a".repeat(64) + + private fun payload(json: String) = SiloJson.parseToJsonElement(json) as JsonObject + + @Test + fun decodesSyncProgressAndOutcome() { + val running = decodePlaybackSubtitleSyncUpdated( + payload( + """{"session_id":"s","file_id":1,"sync_key":"$sidecarKey","timing":{"offset_ms":0,"scale":1}, + "job":{"id":"2","status":"running","trigger":"manual","phase":"analyzing","progress":0.3, + "confidence":null,"created_at":"2026-10-04T02:37:35.235Z","finished_at":null}}""", + ), + ) + assertNotNull(running) + assertEquals("s", running.sessionId) + assertEquals(1, running.mediaFileId) + assertEquals(sidecarKey, running.syncKey) + assertNull(running.subtitleId) + assertEquals(0.3, running.job.progress) + assertEquals(SubtitleSyncJob.PHASE_ANALYZING, running.job.phase) + + val synced = decodePlaybackSubtitleSyncUpdated( + payload( + """{"session_id":"s","file_id":1,"sync_key":"stored-9","subtitle_id":9,"timing":{"offset_ms":-3010,"scale":1}, + "job":{"id":"3","status":"synced","trigger":"auto","confidence":1,"result":{"offset_ms":-3010,"scale":1}, + "created_at":"2026-10-04T02:37:35.235Z","finished_at":"2026-10-04T02:37:35.351Z"}}""", + ), + ) + assertNotNull(synced) + assertEquals(9, synced.subtitleId) + assertEquals(SubtitleTiming(-3010, 1.0), synced.timing) + assertEquals(SubtitleTiming(-3010, 1.0), synced.job.result) + } + + @Test + fun dropsAnUnreadableSyncUpdate() { + assertNull(decodePlaybackSubtitleSyncUpdated(payload("""{"session_id":"s","file_id":1,"timing":{},"job":{"status":"running"}}"""))) + assertNull(decodePlaybackSubtitleSyncUpdated(payload("""{"session_id":"s","file_id":1,"sync_key":"stored-9","job":{"status":"running"}}"""))) + assertNull(decodePlaybackSubtitleSyncUpdated(payload("""{"sync_key":"stored-9","timing":{},"job":{"progress":"x"}}"""))) + } + + @Test + fun aSidecarTimingChangeMatchesTheMountedTrackBySyncKey() { + val update = decodePlaybackSubtitleTimingChanged( + payload("""{"session_id":"s","file_id":1,"sync_key":"$sidecarKey"}"""), + ) + assertEquals(sidecarKey, update.syncKey) + assertNull(update.subtitleId) + val sidecar = PlayerSubtitleInfo(index = 0, source = "external", url = "/stream/s/subtitles/0.vtt", syncKey = sidecarKey) + assertTrue(update.affects(listOf(sidecar))) + assertFalse(update.affects(listOf(sidecar.copy(syncKey = "external-" + "b".repeat(64))))) + assertFalse(update.affects(emptyList())) + } + + @Test + fun aStoredKeyFindsARowFromAServerThatPublishesNoKey() { + val row = PlayerSubtitleInfo(index = 3, source = "downloaded", url = "/stream/s/subtitles/3.vtt?downloaded_subtitle_id=9") + assertTrue(listOf(row).includesSyncKey("stored-9")) + assertFalse(listOf(row).includesSyncKey("stored-90")) + // A row with a key is matched by its key only. + assertFalse(listOf(row.copy(syncKey = "stored-10")).includesSyncKey("stored-9")) + } + + @Test + fun subtitleReadyCarriesTheInventorySyncKey() { + val existing = listOf(PlayerSubtitleInfo(index = 0, source = "external", url = "/0.vtt", serverTrackId = "file:1:subtitle:0", syncKey = sidecarKey)) + val track = SiloJson.decodeFromString( + PlaybackSubtitleInventoryItemV3.serializer(), + """{"track_id":"file:1:subtitle:1","combined_index":1,"source":"downloaded","codec":"srt","language":"en", + "delivery":"sidecar","url":"/stream/s/subtitles/1.vtt?downloaded_subtitle_id=9","sync_key":"stored-9"}""", + ) + val rows = applyAuthoritativeSubtitleReadyTrack(existing, PlaybackSubtitleReady("s", 1, 9, track)) + assertNotNull(rows) + assertEquals(listOf(sidecarKey, "stored-9"), rows.map { it.syncKey }) + assertEquals("stored-9", rows.syncKeyFor(SubtitleIdentity.ServerSidecar(1))) + assertNull(rows.syncKeyFor(SubtitleIdentity.Off)) + // The selected track is the active one only while it is mounted; a + // legacy session mounts every row. + assertEquals("stored-9", rows.activeSyncKey(SubtitleIdentity.ServerSidecar(1), mounted = rows)) + assertNull(rows.activeSyncKey(SubtitleIdentity.ServerSidecar(1), mounted = rows.take(1))) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncControllerTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncControllerTest.kt new file mode 100644 index 0000000000..0c4be7f9fd --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncControllerTest.kt @@ -0,0 +1,325 @@ +package org.siloserver.silo.playback + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.subtitles.DownloadedSubtitle +import org.siloserver.silo.model.subtitles.SubtitleSyncCapability +import org.siloserver.silo.model.subtitles.SubtitleSyncJob +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.model.subtitles.SubtitleTiming +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.SubtitleSyncSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SubtitleSyncControllerTest { + private val sidecar = "external-" + "a".repeat(64) + private val stored = "stored-9" + private val corrected = SubtitleTiming(offsetMs = -3_010) + + private class FakeSource : SubtitleSyncSource { + var capability = SubtitleSyncCapability(state = "available", autoSync = true, external = true, allowed = true) + val states = linkedMapOf() + val reads = mutableListOf() + var readError: ApiResult.Error? = null + var startError: ApiResult.Error? = null + /** When set, a start request waits for it before answering. */ + var startGate: CompletableDeferred? = null + /** When set, the next read takes its answer, then waits for it before returning. */ + var readGate: CompletableDeferred? = null + var jobs = 0 + + override suspend fun syncCapability() = ApiResult.Success(capability) + override suspend fun listSync(mediaFileId: Int) = ApiResult.Success(states.values.toList()) + override suspend fun readSync(mediaFileId: Int, key: String): ApiResult { + reads += key + readError?.let { return it } + val response = states[key]?.let { ApiResult.Success(it) } ?: ApiResult.Error(404, "not_found", "") + readGate?.also { readGate = null }?.await() + return response + } + override suspend fun startSync(mediaFileId: Int, key: String): ApiResult { + startError?.let { return it } + val job = SubtitleSyncJob(id = "j${++jobs}", status = SubtitleSyncJob.PENDING, trigger = "manual", phase = "queued", progress = 0.0) + val response = states.getValue(key).copy(sync = job).also { states[key] = it } + startGate?.await() + return ApiResult.Success(response) + } + override suspend fun resetTiming(mediaFileId: Int, key: String): ApiResult = + ApiResult.Success(states.getValue(key).copy(timing = SubtitleTiming()).also { states[key] = it }) + + fun finish(key: String, status: String, timing: SubtitleTiming = states.getValue(key).timing) { + val state = states.getValue(key) + states[key] = state.copy(timing = timing, sync = state.sync?.copy(status = status, phase = null, progress = null, result = timing)) + } + } + + private fun sidecarState(timing: SubtitleTiming = SubtitleTiming()) = + SubtitleSyncState(key = sidecar, mediaFileId = "1", source = "external", language = "en", format = "srt", label = "a.en.srt", timing = timing) + + private fun storedState() = + SubtitleSyncState(key = stored, mediaFileId = "1", source = "downloaded", storedSubtitleId = "9", language = "fr", format = "srt", label = "release") + + private fun TestScope.controller(source: FakeSource, changes: MutableList) = + SubtitleSyncController(source, backgroundScope, onTimingChanged = { changes += it }, pollIntervalMs = 3_000, pollLimitMs = 9_000) + + @Test + fun loadsTheFileAndOffersSidecarsOnlyWhenTheServerCanSyncThem() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState(); states[stored] = storedState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar, stored)) + runCurrent() + val state = sync.state.value + assertTrue(state.available && state.externalAvailable) + assertEquals(setOf(sidecar, stored), state.entries.keys) + val actions = assertNotNull(state.timingActionsFor(sidecar)) + assertTrue(actions.canSync) + assertFalse(actions.canReset) + assertEquals(SubtitleTimingActions.SIDECAR_NOTE, actions.note) + assertEquals(SubtitleTimingActions.STORED_NOTE, state.timingActionsFor(stored)?.note) + // No sync key, nothing to show. + assertNull(state.timingActionsFor(null)) + + source.capability = source.capability.copy(external = false) + sync.reload() + runCurrent() + assertNull(sync.state.value.timingActionsFor(sidecar)) + assertTrue(sync.state.value.timingActionsFor(stored)!!.canSync) + } + + @Test + fun followsAStartedSyncByPollingAndReportsTheTimingChangeOnce() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + sync.requestSync(sidecar) + runCurrent() + val entry = sync.state.value.entries.getValue(sidecar) + assertEquals("j1", entry.watchedJobId) + assertTrue(entry.inProgress) + assertEquals(0, sync.state.value.timingActionsFor(sidecar)?.percent) + assertFalse(sync.state.value.timingActionsFor(sidecar)!!.actionsEnabled) + + source.finish(sidecar, SubtitleSyncJob.SYNCED, corrected) + advanceTimeBy(3_001) + val done = sync.state.value.entries.getValue(sidecar) + assertEquals(SubtitleSyncJob.SYNCED, done.state.sync?.status) + assertEquals("Synced −3.0 s", done.statusLabel) + assertEquals(listOf(sidecar), changes) + + // The realtime event that follows reads the same timing: no second reload. + sync.timingChanged(sidecar) + runCurrent() + assertEquals(listOf(sidecar), changes) + + // Reset brings the original timing back, and its cues reload. + sync.resetTiming(sidecar) + runCurrent() + assertEquals(listOf(sidecar, sidecar), changes) + assertEquals("Original timing", sync.state.value.entries.getValue(sidecar).statusLabel) + } + + @Test + fun realtimeUpdatesCarryProgressAndSkipTheNextPoll() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + sync.requestSync(sidecar) + runCurrent() + val job = sync.state.value.entries.getValue(sidecar).state.sync!! + + source.reads.clear() + sync.syncUpdated(update(job.copy(status = SubtitleSyncJob.RUNNING, phase = "analyzing", progress = 0.6))) + assertEquals("Syncing… 60%", sync.state.value.entries.getValue(sidecar).statusLabel) + advanceTimeBy(3_001) + assertTrue(source.reads.isEmpty(), "a fresh push makes the poll redundant") + + sync.syncUpdated(update(job.copy(status = SubtitleSyncJob.SYNCED, phase = null, progress = null, result = corrected), corrected)) + assertEquals(listOf(sidecar), changes) + assertEquals("j1", sync.state.value.watchedEntry()?.state?.sync?.id) + } + + @Test + fun aStartResponseThatArrivesAfterTheRealtimeOutcomeDoesNotRewindIt() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + source.startGate = CompletableDeferred() + sync.requestSync(sidecar) + runCurrent() + // The job ran on cached speech: its outcome arrives before the POST answers. + val finished = SubtitleSyncJob(id = "j1", status = SubtitleSyncJob.SYNCED, trigger = "manual", result = corrected) + sync.syncUpdated(update(finished, corrected)) + assertEquals(listOf(sidecar), changes) + + source.startGate!!.complete(Unit) + runCurrent() + val entry = sync.state.value.entries.getValue(sidecar) + assertEquals(SubtitleSyncJob.SYNCED, entry.state.sync?.status) + assertEquals(corrected, entry.state.timing) + assertEquals("j1", entry.watchedJobId) + assertEquals(listOf(sidecar), changes, "no second reload for the stale response") + } + + @Test + fun anOlderJobsSnapshotDoesNotReplaceANewerJob() = runTest { + val older = SubtitleSyncJob(id = "1", status = SubtitleSyncJob.SYNCED, createdAt = "2026-10-04T02:00:00.000Z", result = corrected) + val newer = SubtitleSyncJob(id = "2", status = SubtitleSyncJob.RUNNING, progress = 0.3, createdAt = "2026-10-04T02:05:00.000Z") + val source = FakeSource().apply { states[sidecar] = sidecarState(corrected).copy(sync = older) } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + runCurrent() + + sync.syncUpdated(update(newer, corrected)) + // A read that left before the new job was queued answers with the old one. + sync.timingChanged(sidecar) + runCurrent() + assertEquals("2", sync.state.value.entries.getValue(sidecar).state.sync?.id) + // The same job going back in progress is stale too. + sync.syncUpdated(update(newer.copy(progress = 0.1), corrected)) + assertEquals(0.3, sync.state.value.entries.getValue(sidecar).state.sync?.progress) + } + + @Test + fun aReadSentBeforeANewerOneDoesNotRestoreItsTiming() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + // Timing set to +0.5 s elsewhere; that event's read answers slowly. + source.states[sidecar] = sidecarState(SubtitleTiming(offsetMs = 500)) + val slowRead = CompletableDeferred().also { source.readGate = it } + sync.timingChanged(sidecar) + runCurrent() + // Then reset; this event's read answers first. + source.states[sidecar] = sidecarState() + sync.timingChanged(sidecar) + runCurrent() + + slowRead.complete(Unit) + runCurrent() + assertEquals(SubtitleTiming(), sync.state.value.entries.getValue(sidecar).state.timing) + assertEquals(emptyList(), changes, "the cues on screen already have the current timing") + } + + @Test + fun someoneElsesChangeReloadsTheTrack() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val changes = mutableListOf() + val sync = controller(source, changes) + sync.bind(1, setOf(sidecar)) + runCurrent() + + source.states[sidecar] = sidecarState(SubtitleTiming(offsetMs = 500)) + sync.timingChanged(sidecar) + runCurrent() + assertEquals(listOf(sidecar), changes) + assertEquals("Timing adjusted +0.5 s", sync.state.value.entries.getValue(sidecar).statusLabel) + assertNull(sync.state.value.watchedEntry()) + + // A server without the read (or a lost subtitle) still reloads on the event. + source.readError = ApiResult.Error(404, "not_found", "") + sync.timingChanged(stored) + runCurrent() + assertEquals(listOf(sidecar, stored), changes) + } + + @Test + fun aDemoRefusalReplacesTheActionsAndAnUnsupportedFormatHidesSync() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState(); states[stored] = storedState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar, stored)) + runCurrent() + + source.startError = ApiResult.Error(403, "forbidden", "demo") + sync.requestSync(sidecar) + runCurrent() + val refused = assertNotNull(sync.state.value.timingActionsFor(sidecar)) + assertTrue(refused.forbidden) + assertNull(refused.note) + + source.startError = ApiResult.Error(422, "unsupported", "") + sync.requestSync(stored) + runCurrent() + val unsupported = assertNotNull(sync.state.value.timingActionsFor(stored)) + assertFalse(unsupported.canSync) + assertEquals("This format can't be synced.", unsupported.error) + } + + @Test + fun followsTheAutomaticSyncOfANewDownload() = runTest { + val source = FakeSource() + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + runCurrent() + val auto = SubtitleSyncJob(id = "a1", status = SubtitleSyncJob.PENDING, trigger = "auto", createdAt = "2026-10-04T02:00:00.000Z") + sync.remember( + DownloadedSubtitle(id = 9, mediaFileId = 1, provider = "upload", language = "fr", format = "srt", releaseName = "x", sync = auto), + ) + assertEquals("a1", sync.state.value.watchedEntry()?.watchedJobId) + // Another file's download is not this player's business. + sync.remember(DownloadedSubtitle(id = 10, mediaFileId = 2, provider = "upload", language = "fr", format = "srt", releaseName = "x", sync = auto)) + assertFalse("stored-10" in sync.state.value.entries) + // Polling follows it; the inventory gaining its key keeps the watch. + source.states[stored] = storedState().copy(sync = auto.copy(status = SubtitleSyncJob.RUNNING, progress = 0.2)) + sync.bind(1, setOf(sidecar, stored)) + advanceTimeBy(3_001) + assertEquals("a1", sync.state.value.entries.getValue(stored).watchedJobId) + assertEquals("Syncing… 20%", sync.state.value.entries.getValue(stored).statusLabel) + } + + @Test + fun pollingGivesUpAfterItsLimitAndAReloadRearmsIt() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + runCurrent() + sync.requestSync(sidecar) + runCurrent() + advanceTimeBy(12_001) + assertTrue(sync.state.value.entries.getValue(sidecar).pollExpired) + assertNull(sync.state.value.watchedEntry(), "a job nobody can follow leaves the card") + val reads = source.reads.size + advanceTimeBy(6_000) + assertEquals(reads, source.reads.size) + + sync.reload() + runCurrent() + assertFalse(sync.state.value.entries.getValue(sidecar).pollExpired) + advanceTimeBy(3_001) + assertTrue(source.reads.size > reads) + } + + @Test + fun aNewFileDropsLateResults() = runTest { + val source = FakeSource().apply { states[sidecar] = sidecarState() } + val sync = controller(source, mutableListOf()) + sync.bind(1, setOf(sidecar)) + sync.bind(2, emptySet()) + runCurrent() + assertTrue(sync.state.value.entries.isEmpty()) + assertFalse(sync.state.value.available) + } + + private fun update(job: SubtitleSyncJob, timing: SubtitleTiming = SubtitleTiming()) = + PlaybackSubtitleSyncUpdated(sessionId = "s", mediaFileId = 1, syncKey = sidecar, subtitleId = null, timing = timing, job = job) +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedbackTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedbackTest.kt new file mode 100644 index 0000000000..76c025edfd --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/SubtitleSyncFeedbackTest.kt @@ -0,0 +1,143 @@ +package org.siloserver.silo.playback + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.subtitles.SubtitleSyncJob +import org.siloserver.silo.model.subtitles.SubtitleSyncState +import org.siloserver.silo.model.subtitles.SubtitleTiming +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SubtitleSyncFeedbackTest { + private val key = "external-" + "a".repeat(64) + private val corrected = SubtitleTiming(offsetMs = -3_000) + + private fun job(status: String, progress: Double? = null, phase: String? = null, failure: String? = null) = + SubtitleSyncJob(id = "j1", status = status, trigger = "manual", phase = phase, progress = progress, failure = failure) + + private fun entry(job: SubtitleSyncJob?, timing: SubtitleTiming = SubtitleTiming(), watched: Boolean = true) = + SubtitleSyncEntry( + state = SubtitleSyncState(key = key, mediaFileId = "1", source = "external", language = "en", timing = timing, sync = job), + watchedJobId = job?.id?.takeIf { watched }, + ) + + /** One update: the sync state of [entry], with [key] on screen at a revision whose cues loaded up to [loaded]. */ + private fun input(entry: SubtitleSyncEntry, onScreen: Boolean = true, revision: Int = 0, loaded: Int = 0) = + SubtitleSyncUiState(available = true, externalAvailable = true, entries = mapOf(key to entry)) + .feedbackInput( + activeKey = key.takeIf { onScreen }, + cueRevisions = mapOf(key to revision), + loadedCueRevisions = mapOf(key to loaded), + nameOf = { "English" }, + ) + + private fun SubtitleSyncFeedbackState.after(vararg inputs: SubtitleSyncFeedbackInput) = + inputs.fold(this) { state, input -> state.step(input) } + + @Test + fun followsAnOwnSyncUntilTheNewCuesShow() { + var state = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.RUNNING, 0.4, "analyzing")))) + assertEquals( + SubtitleSyncNotice("j1", SubtitleSyncNotice.Tone.Progress, "Syncing English subtitles", "Listening to the audio…", 40), + state.notice, + ) + + val synced = entry(job(SubtitleSyncJob.SYNCED), corrected) + state = state.after(input(synced)) + assertEquals("Applying new timing…", state.notice?.title) + assertEquals(100, state.notice?.percent) + + // The player asks for the corrected cues; it is this viewer's own change. + state = state.after(input(synced, revision = 1)) + assertEquals("Applying new timing…", state.notice?.title) + assertNull(state.foreign) + + state = state.after(input(synced, revision = 1, loaded = 1)) + assertEquals( + SubtitleSyncNotice("j1", SubtitleSyncNotice.Tone.Success, "Subtitles synced", "−3.0 s"), + state.notice, + ) + assertTrue("j1" in state.applied) + // A later update does not announce it again. + assertEquals(state.notice, state.after(input(synced, revision = 1, loaded = 1)).notice) + } + + @Test + fun aSyncOfATrackNotOnScreenSaysSoAtOnce() { + val state = SubtitleSyncFeedbackState().after( + input(entry(job(SubtitleSyncJob.PENDING, 0.0, "queued")), onScreen = false), + input(entry(job(SubtitleSyncJob.SYNCED), corrected), onScreen = false), + ) + assertEquals( + SubtitleSyncNotice("j1", SubtitleSyncNotice.Tone.Success, "English subtitles synced", "−3.0 s"), + state.notice, + ) + } + + @Test + fun warnsWhenASyncCannotHelp() { + val noMatch = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.NO_MATCH)))) + assertEquals(SubtitleSyncNotice.Tone.Warning, noMatch.notice?.tone) + assertEquals("English subtitles don't match the audio", noMatch.notice?.title) + assertEquals("They're probably for another release. The timing wasn't changed.", noMatch.notice?.detail) + + val failed = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.FAILED, failure = "unavailable")))) + assertEquals("Couldn't sync English subtitles", failed.notice?.title) + assertEquals("The server is busy. Try again in a few minutes.", failed.notice?.detail) + + val already = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.ALREADY_SYNCED)))) + assertEquals(SubtitleSyncNotice.Tone.Info, already.notice?.tone) + assertEquals("English subtitles already match the audio", already.notice?.title) + } + + @Test + fun someoneElsesChangeIsAnnouncedOnceItsCuesShow() { + val other = entry(job(SubtitleSyncJob.SYNCED), corrected, watched = false) + var state = SubtitleSyncFeedbackState().after(input(other)) + assertNull(state.notice) + state = state.after(input(other, revision = 1)) + assertNull(state.notice) + state = state.after(input(other, revision = 1, loaded = 1)) + assertEquals("Subtitle timing updated", state.notice?.title) + assertEquals(SubtitleSyncNotice.Tone.Info, state.notice?.tone) + } + + @Test + fun aProgressCardNobodyFollowsLeaves() { + var state = SubtitleSyncFeedbackState().after(input(entry(job(SubtitleSyncJob.RUNNING, 0.2)))) + assertEquals(SubtitleSyncNotice.Tone.Progress, state.notice?.tone) + // Another file: the entries are gone. + state = state.step(SubtitleSyncFeedbackInput(watched = null, activeKey = null)) + assertNull(state.notice) + } + + @Test + fun finishedCardsLeaveOnTheirOwnAndApplyingGivesUpWaiting() = runTest { + val tracker = SubtitleSyncFeedbackTracker(backgroundScope) + tracker.update(input(entry(job(SubtitleSyncJob.RUNNING, 0.5)))) + tracker.update(input(entry(job(SubtitleSyncJob.SYNCED), corrected))) + assertEquals("Applying new timing…", tracker.notice.value?.title) + // The reload never reports: the success shows anyway. + advanceTimeBy(SubtitleSyncFeedbackTracker.APPLY_TIMEOUT_MS + 1) + assertEquals("Subtitles synced", tracker.notice.value?.title) + advanceTimeBy(SubtitleSyncFeedbackTracker.NOTICE_VISIBLE_MS + 1) + assertNull(tracker.notice.value) + + val warning = SubtitleSyncFeedbackTracker(backgroundScope) + warning.update(input(entry(job(SubtitleSyncJob.NO_MATCH)))) + advanceTimeBy(SubtitleSyncFeedbackTracker.NOTICE_VISIBLE_MS + 1) + assertEquals(SubtitleSyncNotice.Tone.Warning, warning.notice.value?.tone) + advanceTimeBy(SubtitleSyncFeedbackTracker.WARNING_VISIBLE_MS) + assertNull(warning.notice.value) + + warning.update(input(entry(job(SubtitleSyncJob.FAILED).copy(id = "j2")))) + runCurrent() + warning.dismiss() + assertNull(warning.notice.value) + } +} From a6611c3e543fd48410da5917a76eec7a971bbc76 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:38:26 -0400 Subject: [PATCH 24/87] fix(cast): keep the TV reachable after a TLS 1.2 client connects (#379) * fix(cast): keep the TV reachable after a TLS 1.2 client connects SiloCastTls handed BouncyCastle one shared PSK array. BouncyCastle zeroes the PSK buffer after a TLS 1.2 handshake, so the first TLS 1.2 client wiped the key for the rest of the process, and every later handshake (TLS 1.3 included) failed with bad_record_mac until the TV app restarted. The key and identity are now fresh arrays on every use. A regression test connects two TLS 1.2-only clients and then an ordinary client; it fails without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) * test(cast): wait for the TLS test's server thread before cleanup The regression test iterated the server sessions while the worker thread could still be adding the last one. It now joins the worker (bounded) and reads a synchronized list. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../siloserver/silo/common/lan/SiloCastTls.kt | 18 ++++--- .../silo/common/lan/SiloCastTlsTest.kt | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/lan/SiloCastTls.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/lan/SiloCastTls.kt index c83ac3704a..82d7351881 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/lan/SiloCastTls.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/lan/SiloCastTls.kt @@ -37,8 +37,12 @@ import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto * external-PSK hook. */ object SiloCastTls { - private val KEY = "silo-cast-v1".toByteArray(Charsets.UTF_8) - private val IDENTITY = "silo-cast".toByteArray(Charsets.UTF_8) + // Fresh arrays on every use: BouncyCastle zeroes the PSK buffer it is + // handed once a TLS 1.2 handshake has derived its keys. Handing it one + // shared array let the first TLS 1.2 client wipe the key for the rest of + // the process, failing every later handshake (TLS 1.3 included). + private fun key(): ByteArray = "silo-cast-v1".toByteArray(Charsets.UTF_8) + private fun identity(): ByteArray = "silo-cast".toByteArray(Charsets.UTF_8) private const val TLS12_SUITE = CipherSuite.TLS_PSK_WITH_AES_128_GCM_SHA256 private const val TLS13_SUITE = CipherSuite.TLS_AES_128_GCM_SHA256 private const val HANDSHAKE_TIMEOUT_MS = 10_000 @@ -117,7 +121,7 @@ object SiloCastTls { object : TlsPSKIdentityManager { override fun getHint(): ByteArray? = null - override fun getPSK(identity: ByteArray?): ByteArray = KEY + override fun getPSK(identity: ByteArray?): ByteArray = key() }, ) { override fun getCipherSuites(): IntArray = intArrayOf(TLS13_SUITE, TLS12_SUITE) @@ -128,7 +132,7 @@ object SiloCastTls { override fun allowCertificateStatus(): Boolean = false override fun getExternalPSK(identities: Vector<*>?): TlsPSKExternal = - BasicTlsPSKExternal(IDENTITY, bcCrypto.createSecret(KEY), PRFAlgorithm.tls13_hkdf_sha256) + BasicTlsPSKExternal(identity(), bcCrypto.createSecret(key()), PRFAlgorithm.tls13_hkdf_sha256) override fun notifyAlertRaised( alertLevel: Short, @@ -146,7 +150,7 @@ object SiloCastTls { private val bcCrypto: BcTlsCrypto, ) : PSKTlsClient( bcCrypto, - BasicTlsPSKIdentity(IDENTITY, KEY), + BasicTlsPSKIdentity(identity(), key()), ) { override fun getSupportedCipherSuites(): IntArray = intArrayOf(TLS13_SUITE, TLS12_SUITE) @@ -157,8 +161,8 @@ object SiloCastTls { Vector().apply { add( BasicTlsPSKExternal( - IDENTITY, - bcCrypto.createSecret(KEY), + identity(), + bcCrypto.createSecret(key()), PRFAlgorithm.tls13_hkdf_sha256, ), ) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/lan/SiloCastTlsTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/lan/SiloCastTlsTest.kt index 244ba8cc1c..6dfdce855f 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/lan/SiloCastTlsTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/lan/SiloCastTlsTest.kt @@ -1,11 +1,20 @@ package org.siloserver.silo.common.lan import java.net.ServerSocket +import java.net.Socket +import java.security.SecureRandom +import java.util.Collections import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import kotlin.concurrent.thread import kotlin.test.Test import kotlin.test.assertContentEquals +import org.bouncycastle.tls.BasicTlsPSKIdentity +import org.bouncycastle.tls.CipherSuite +import org.bouncycastle.tls.PSKTlsClient +import org.bouncycastle.tls.ProtocolVersion +import org.bouncycastle.tls.TlsClientProtocol +import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto class SiloCastTlsTest { @Test @@ -37,6 +46,46 @@ class SiloCastTlsTest { } } + /** + * BouncyCastle zeroes the PSK buffer after a TLS 1.2 handshake. The server + * once handed it a shared key array, so one TLS 1.2 client wiped the key + * for the process and every later handshake failed. + */ + @Test + fun tls12ClientCannotWipeTheReceiversKey() { + val listener = ServerSocket(0) + val servers = Collections.synchronizedList(mutableListOf()) + val worker = thread(isDaemon = true, name = "silocast-tls-test-server") { + repeat(3) { runCatching { servers += SiloCastTls.accept(listener.accept()) } } + } + try { + repeat(2) { + val socket = Socket("127.0.0.1", listener.localPort) + val protocol = TlsClientProtocol(socket.getInputStream(), socket.getOutputStream()) + // Offers only TLS 1.2, so a completed handshake is a TLS 1.2 one. + protocol.connect(Tls12PskClient(BcTlsCrypto(SecureRandom()))) + protocol.close() + socket.close() + } + // An ordinary (TLS 1.3) client still gets through afterwards. + SiloCastTls.connect("127.0.0.1", listener.localPort, 5_000).close() + } finally { + // The last server-side handshake can still be finishing. + worker.join(5_000) + listener.close() + synchronized(servers) { servers.toList() }.forEach { it.close() } + } + } + + private class Tls12PskClient(crypto: BcTlsCrypto) : PSKTlsClient( + crypto, + BasicTlsPSKIdentity("silo-cast".toByteArray(), "silo-cast-v1".toByteArray()), + ) { + override fun getSupportedCipherSuites(): IntArray = intArrayOf(CipherSuite.TLS_PSK_WITH_AES_128_GCM_SHA256) + + override fun getSupportedVersions(): Array = arrayOf(ProtocolVersion.TLSv12) + } + private fun java.io.InputStream.readBytes(count: Int): ByteArray { val result = ByteArray(count) var offset = 0 From ca53cb00f53a36d32e49fa3bdc38de5e4a64fa5d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:40:35 -0400 Subject: [PATCH 25/87] fix(cast): speak the Remote Control protocol the way Apple does (#381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android and Apple peers disagreed on parts of the Remote Control wire format: - A frame with a message type this build doesn't know failed to decode, and the decode error tore the session down. Unknown types are now skipped; the Apple apps get the same change. A known type with a broken payload still fails. - The TV answered a command name it doesn't implement with player_not_ready. It now ignores it without an error, as tvOS does. - The TV sent its aspect ratio as fit/zoom/stretch and the phone sent the subtitle position "standard"; tvOS uses fit/fill/stretch and "bottom" and rejects anything else. Episode subtitles now read "Series · S1 · E2". - A discovered TV without a `v` TXT key was treated as protocol v2; like iOS, it is now treated as v1. - Apple removed the HDR toggle. The phone remote drops it too, and the TV advertises supportsHDRToggle = false (the key stays on the wire because older remotes require it) and ignores set_hdr_enabled from older phones. Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/common/cast/SiloCastNsdBrowser.kt | 3 +- .../silo/android/cast/SiloCastController.kt | 7 +--- .../ui/screens/cast/SiloCastRemoteScreen.kt | 25 ++--------- .../silo/tv/cast/TvSiloCastPlayerAdapter.kt | 2 - .../silo/tv/cast/TvSiloCastReceiver.kt | 12 +++++- .../tv/ui/screens/player/TvPlayerScreen.kt | 16 +++++-- .../siloserver/silo/cast/SiloCastMessage.kt | 42 ++++++++++++++++--- .../silo/cast/SiloCastMessageTest.kt | 19 +++++++++ 8 files changed, 84 insertions(+), 42 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt index d0a5096e32..17c2d279d4 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt @@ -170,7 +170,8 @@ class SiloCastNsdBrowser(context: Context) { val name = attributes.string("name") ?: serviceName val mdnsName = serviceName val deviceId = attributes.string("deviceId") ?: attributes.string("id") ?: "$host:$port" - val version = attributes.string("v")?.toIntOrNull() ?: SiloCastProtocol.version + // Every v2 receiver advertises `v`; one that doesn't predates it (iOS reads it the same way). + val version = attributes.string("v")?.toIntOrNull() ?: 1 return SiloCastTarget( serviceName = mdnsName, deviceId = deviceId, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt index d91f4f7bd4..c9f5c275c6 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt @@ -446,10 +446,6 @@ class SiloCastController( sendControl(SiloCastControlCommand.setVideoGravity(value)) } - fun setHdrEnabled(enabled: Boolean) { - sendControl(SiloCastControlCommand.setHdrEnabled(enabled)) - } - fun setSubtitleSyncMs(milliseconds: Int) { sendControl(SiloCastControlCommand.setSubtitleSyncMs(milliseconds)) } @@ -757,7 +753,8 @@ class SiloCastController( val read = withContext(Dispatchers.IO) { input.read(chunk) } if (read < 0) break frameBuffer.append(chunk.copyOf(read)).forEach { payload -> - handleMessage(json.decodeFromString(SiloCastMessage.serializer(), payload.decodeToString())) + // A kind added after this build is skipped, not fatal. + SiloCastMessage.decodeOrNull(json, payload.decodeToString())?.let { handleMessage(it) } } } } catch (e: CancellationException) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt index 32415c4ff0..6620d5b129 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt @@ -166,7 +166,6 @@ fun SiloCastRemoteScreen( onChooseTv = { showTargetPicker = true }, onStopPlayback = { controller.stopPlayback() }, onSetVideoGravity = controller::setVideoGravity, - onSetHdrEnabled = controller::setHdrEnabled, onDisconnect = { controller.disconnect() onBack() @@ -280,7 +279,6 @@ private fun RemoteTopBar( onChooseTv: () -> Unit, onStopPlayback: () -> Unit, onSetVideoGravity: (String) -> Unit, - onSetHdrEnabled: (Boolean) -> Unit, onDisconnect: () -> Unit, showBatterySettings: Boolean, onBatterySettings: () -> Unit, @@ -342,23 +340,6 @@ private fun RemoteTopBar( }, ) } - if (playback?.supportsHDRToggle == true) { - DropdownMenuItem( - text = { Text("HDR") }, - leadingIcon = { - Icon(Icons.Outlined.AspectRatio, contentDescription = null) - }, - trailingIcon = { - if (playback.hdrEnabled) { - Icon(Icons.Filled.Check, contentDescription = "Enabled") - } - }, - onClick = { - menuExpanded = false - onSetHdrEnabled(!playback.hdrEnabled) - }, - ) - } if (showBatterySettings) { DropdownMenuItem( text = { Text(stringResource(R.string.remote_battery_settings)) }, @@ -980,12 +961,12 @@ private fun subtitleMenuEntries( } if (playback.supportsSubtitlePosition == true) { add(MenuEntry(label = "Position", selected = false, isSectionHeader = true)) - listOf("standard" to "Bottom", "lower-third" to "Lower Third", "top" to "Top").forEach { (id, label) -> + // tvOS SubtitlePositionPreset raw values; it rejects anything else. + listOf("bottom" to "Bottom", "lower-third" to "Lower Third", "top" to "Top").forEach { (id, label) -> add( MenuEntry( label = label, - selected = playback.subtitlePosition == id || - (id == "standard" && playback.subtitlePosition == "bottom"), + selected = playback.subtitlePosition == id, onClick = { controller.setSubtitlePosition(id) }, ), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt index be0a7199b4..c03207ec00 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt @@ -13,7 +13,6 @@ class TvSiloCastPlayerAdapter( private val setPlaybackSpeed: (Double) -> Unit, private val setQuality: (String) -> Unit, private val setVideoGravity: (String) -> Unit, - private val setHdrEnabled: (Boolean) -> Unit, private val setSubtitleSyncMs: (Int) -> Unit, private val setSubtitlePosition: (String) -> Unit, private val setVolume: (Double) -> Unit, @@ -35,7 +34,6 @@ class TvSiloCastPlayerAdapter( SiloCastControlCommand.SetQuality -> command.value?.let(setQuality) SiloCastControlCommand.SetPlaybackSpeed -> command.speed?.let(setPlaybackSpeed) SiloCastControlCommand.SetVideoGravity -> command.value?.let(setVideoGravity) - SiloCastControlCommand.SetHdrEnabled -> command.enabled?.let(setHdrEnabled) SiloCastControlCommand.SetSubtitleSyncMs -> command.milliseconds?.let(setSubtitleSyncMs) SiloCastControlCommand.SetSubtitlePosition -> command.value?.let(setSubtitlePosition) SiloCastControlCommand.SetVolume -> command.volume?.let(setVolume) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index 4c85794f50..81a73c5876 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import org.siloserver.silo.cast.SiloCastControlCommand import org.siloserver.silo.cast.SiloCastError import org.siloserver.silo.cast.SiloCastHello import org.siloserver.silo.cast.SiloCastHandoffCancel @@ -547,6 +548,13 @@ class TvSiloCastReceiver( } is SiloCastMessage.Control -> { if (!requireAuthorized(session)) return true + if (message.control.name !in SiloCastControlCommand.knownNames) { + // A command from a newer (or retired, like set_hdr_enabled) + // remote. Ignored without an error, as tvOS does: the phone + // shows errors as banners, and nothing happened anyway. + Log.i(TAG, "SiloCast ignoring unsupported command ${message.control.name}") + return true + } val player = activePlayer if (player == null) { session.send( @@ -651,8 +659,8 @@ class TvSiloCastReceiver( if (read < 0) return val payloads = buffer.append(chunk.copyOf(read)) for (payload in payloads) { - val text = payload.decodeToString() - val message = json.decodeFromString(SiloCastMessage.serializer(), text) + // A kind added after this build is skipped, not fatal. + val message = SiloCastMessage.decodeOrNull(json, payload.decodeToString()) ?: continue if (!onMessage(message)) return } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index bf8d1d06ee..6c62975ca2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -559,7 +559,6 @@ fun TvPlayerScreen( setVideoGravity = { value -> viewModel.onVideoFillModeChanged(value.toSiloCastVideoFillMode()) }, - setHdrEnabled = viewModel::onSetHdrEnabled, setSubtitleSyncMs = viewModel::onSubtitleDelayChanged, setSubtitlePosition = { value -> viewModel.onSetSubtitleAppearance( @@ -3428,10 +3427,12 @@ private fun TvPlayerViewModel.UiState.toSiloCastPlaybackState( contentId = contentId, sessionId = sessionId, title = title, + // tvOS shape: "Series · S1 · E2", so remotes read the same for either TV. subtitle = listOfNotNull( + seriesTitle?.takeIf { it.isNotBlank() }, seasonNumber?.let { "S$it" }, episodeNumber?.let { "E$it" }, - ).joinToString(" ").ifBlank { null }, + ).joinToString(" · ").ifBlank { null }, isPlaying = isPlaying && !isPaused, isLoading = isLoading, isBuffering = isBuffering, @@ -3445,10 +3446,17 @@ private fun TvPlayerViewModel.UiState.toSiloCastPlaybackState( activeQualityId = activeQualityId, isQualitySwitching = false, playbackSpeed = playbackSpeed, - videoGravity = videoFillMode.name.lowercase(), + // Wire values are tvOS VideoGravity raw values: fit, fill, stretch. + videoGravity = when (videoFillMode) { + VideoFillMode.Fit -> "fit" + VideoFillMode.Zoom -> "fill" + VideoFillMode.Stretch -> "stretch" + }, hdrEnabled = hdrEnabled, supportsVideoGravity = true, - supportsHDRToggle = true, + // Remotes no longer offer the toggle (Apple dropped it too); the key + // stays on the wire because older remotes require it. + supportsHDRToggle = false, subtitleSyncMs = subtitleDelayMs, subtitlePosition = subtitleAppearance.position.toSiloCastPositionValue(), supportsSubtitleDelay = true, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt index 88ac686318..eb52afed18 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt @@ -4,7 +4,13 @@ import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull /** * Wire model for the `_silocast._tcp` phone→TV control channel. @@ -103,6 +109,28 @@ sealed class SiloCastMessage { data class Close( @EncodeDefault(EncodeDefault.Mode.ALWAYS) override val v: Int = SiloCastProtocol.version, ) : SiloCastMessage() + + companion object { + /** Every `type` this build understands, read from the serializer so it can't drift. */ + val knownTypes: Set by lazy { + serializer().descriptor.getElementDescriptor(1).elementNames.toSet() + } + + /** + * Decodes one frame, or returns null for a `type` this build doesn't + * know. A newer peer may send kinds added after this build; dropping + * them keeps the session up where a decode error would tear it down + * (Apple ignores them the same way). A known type with a malformed + * payload still throws. + */ + fun decodeOrNull(json: Json, text: String): SiloCastMessage? { + val frame = json.parseToJsonElement(text) as? JsonObject + ?: throw SerializationException("SiloCast frame is not a JSON object") + val type = (frame["type"] as? JsonPrimitive)?.contentOrNull + if (type != null && type !in knownTypes) return null + return json.decodeFromJsonElement(serializer(), frame) + } + } } @Serializable @@ -232,8 +260,8 @@ data class SiloCastPlaybackState( /** * Command envelope: a [name] from Apple's closed Name enum plus the sparse - * argument fields. Apple's decoder REJECTS unknown names, so never send a - * string outside this set. + * argument fields. Current Apple builds ignore a name they don't know, but + * older ones reject the whole frame, so never send a string outside this set. */ @Serializable data class SiloCastControlCommand( @@ -257,13 +285,18 @@ data class SiloCastControlCommand( const val SetPlaybackSpeed = "set_playback_speed" const val SetQuality = "set_quality" const val SetVideoGravity = "set_video_gravity" - const val SetHdrEnabled = "set_hdr_enabled" const val SetSubtitleSyncMs = "set_subtitle_sync_ms" const val SetSubtitlePosition = "set_subtitle_position" const val SetVolume = "set_volume" const val SetMuted = "set_muted" const val PlayNext = "play_next" + /** Every command name this build implements. */ + val knownNames: Set = setOf( + Play, Pause, PlayPause, Seek, Stop, SelectAudioTrack, SelectSubtitleTrack, SetPlaybackSpeed, + SetQuality, SetVideoGravity, SetSubtitleSyncMs, SetSubtitlePosition, SetVolume, SetMuted, PlayNext, + ) + fun play(): SiloCastControlCommand = SiloCastControlCommand(name = Play) fun pause(): SiloCastControlCommand = SiloCastControlCommand(name = Pause) @@ -290,9 +323,6 @@ data class SiloCastControlCommand( fun setVideoGravity(value: String): SiloCastControlCommand = SiloCastControlCommand(name = SetVideoGravity, value = value) - fun setHdrEnabled(enabled: Boolean): SiloCastControlCommand = - SiloCastControlCommand(name = SetHdrEnabled, enabled = enabled) - fun setSubtitleSyncMs(milliseconds: Int): SiloCastControlCommand = SiloCastControlCommand(name = SetSubtitleSyncMs, milliseconds = milliseconds) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt index ea9a1eee8a..38e71b9507 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt @@ -1,8 +1,10 @@ package org.siloserver.silo.cast +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull @@ -250,4 +252,21 @@ class SiloCastMessageTest { SiloCastMessage.HandoffCancel(SiloCastHandoffCancel("req-1", "denied", "No")), ) } + + @Test + fun unknownMessageTypeIsDroppedNotFatal() { + assertNull(SiloCastMessage.decodeOrNull(json, """{"type":"queue_update","v":3,"queueUpdate":{"items":[]}}""")) + assertIs(SiloCastMessage.decodeOrNull(json, """{"type":"ping","v":2}""")) + // A known kind with a broken payload is still a protocol error. + assertFailsWith { + SiloCastMessage.decodeOrNull(json, """{"type":"state","v":2,"state":{"title":"x"}}""") + } + assertEquals( + setOf( + "hello", "handoff_offer", "handoff_challenge", "handoff_ready", "handoff_cancel", + "launch", "control", "state", "error", "ping", "pong", "close", + ), + SiloCastMessage.knownTypes, + ) + } } From bb8d9cdf03f550efe9b75a00dd366b01db90f827 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:59:41 -0400 Subject: [PATCH 26/87] fix(tv): end the remote-playback identity after the player's session stop (#481) * fix(tv): end the remote-playback identity after the player's session stop Backgrounding the TV app during phone-handed-off playback ended the temporary identity while the player's queued final progress report and stopSession were still in flight, so stopSession failed with 401. The player's ON_STOP observer queues its teardown before MainTvActivity.onStop() calls TvSiloCastReceiver.stop(). stop() now waits (up to 15 s) for that queued teardown before ending the identity, so an unconsumed identity is still always ended. The generation check moves inside RemotePlaybackIdentityManager's lock so a delayed cleanup cannot end a replacement identity. Co-Authored-By: Claude Opus 5.5 * fix(tv): keep the deferred stop cleanup off a reused handoff identity stop() now waits up to 15 s for the player's teardown before ending the temporary identity. If the TV returns and the same phone hands off again in that window, prepare() reuses the identity with the same generation, so the generation guard alone let the old cleanup revoke it. A reuse now bumps a claim count on the active identity, and the stop cleanup ends it only if it is still exactly the identity it captured. An offer that fails before claiming the identity leaves it to the cleanup, so the identity is still always ended. Co-Authored-By: Claude Opus 5.5 * test(tv): cover the remote-playback identity claim guard Covers the three outcomes of stop()'s deferred identity cleanup: a same-phone handoff that reuses the identity blocks it, an offer that fails before claiming the identity still lets it end, and a replacement identity is left alone. Co-Authored-By: Claude Opus 5.5 * fix(tv): tie the stop cleanup's claim check to the receiver run The claim-count snapshot could still leak the identity: a handoff from the session stop() was tearing down could finish its reuse after stop() took the snapshot. The cleanup then skipped, and with the receiver scope gone nothing else ended it. Each start() now begins a new receiver run, and a handoff tags the identity with the run that installed or reused it. stop()'s cleanup ends the active identity unless a later run has claimed it, so claims from the stopped run never protect it. The cleanup is now always scheduled, since a stopped-run handoff can install an identity after stop(). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../common/player/PlaybackSessionLifecycle.kt | 7 +- .../tv/cast/RemotePlaybackIdentityManager.kt | 33 +++- .../silo/tv/cast/TvSiloCastReceiver.kt | 41 +++-- .../siloserver/silo/tv/di/AndroidTvModule.kt | 2 + .../cast/RemotePlaybackIdentityClaimTest.kt | 159 ++++++++++++++++++ 5 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt index 4077a3163b..8d309fb3bf 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt @@ -713,7 +713,12 @@ class PlaybackSessionLifecycle( job.start() } - private suspend fun awaitPendingStop() { + /** + * Waits for a queued [stopAsync] teardown (final progress + stopSession) + * to finish. Callers that revoke the credentials those requests ride on + * must await this first, or the teardown lands as a 401. + */ + suspend fun awaitPendingStop() { val job = synchronized(pendingStopLock) { pendingStopJob } ?: return job.join() } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt index 0d3754f652..de8ffe7610 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt @@ -37,6 +37,8 @@ class RemotePlaybackIdentityManager( val controllerDeviceId: String, val controllerDeviceName: String?, val expiresAtEpochMs: Long, + /** The receiver run (see [TvSiloCastReceiver]) that last installed or reused it. */ + val receiverRun: Long, ) private val mutex = Mutex() @@ -56,12 +58,15 @@ class RemotePlaybackIdentityManager( offer: SiloCastHandoffOffer, controllerDeviceId: String, controllerDeviceName: String?, + receiverRun: Long, onChallenge: suspend (SiloCastHandoffChallenge) -> Unit, ): SiloCastHandoffReady = mutex.withLock { validateOffer(offer) activeIdentity?.takeIf { matches(offer, controllerDeviceId) }?.let { active -> - return@withLock active.toReady(offer.requestId, reused = true) + val reclaimed = active.copy(receiverRun = receiverRun) + activeIdentity = reclaimed + return@withLock reclaimed.toReady(offer.requestId, reused = true) } endLocked() @@ -139,6 +144,7 @@ class RemotePlaybackIdentityManager( controllerDeviceId = controllerDeviceId, controllerDeviceName = controllerDeviceName, expiresAtEpochMs = expiresAtMs, + receiverRun = receiverRun, ) activeIdentity = active return@withLock active.toReady(offer.requestId, reused = false) @@ -161,6 +167,31 @@ class RemotePlaybackIdentityManager( suspend fun end() = mutex.withLock { endLocked() } + /** + * Ends the identity only if [generationId] is still the active one. The + * check runs under the same lock as [prepare], so a delayed cleanup can + * never end a replacement identity installed after it was scheduled. + */ + suspend fun end(generationId: String): Boolean = mutex.withLock { + if (activeIdentity?.generationId != generationId) return@withLock false + endLocked() + true + } + + /** + * Ends the active identity unless a receiver run started after [run] has + * installed or reused it. A same-phone handoff keeps the generation, so a + * generation check would let a delayed stop cleanup revoke the identity + * that handoff just claimed. Claims made by [run] itself (or earlier) do + * not protect it: that run is stopped, so nothing else will end it. + */ + suspend fun endIfNotClaimedSince(run: Long): Boolean = mutex.withLock { + val active = activeIdentity ?: return@withLock false + if (active.receiverRun > run) return@withLock false + endLocked() + true + } + private suspend fun endLocked() { val active = activeIdentity try { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index 81a73c5876..21812467c7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import org.siloserver.silo.cast.SiloCastControlCommand import org.siloserver.silo.cast.SiloCastError @@ -69,6 +70,8 @@ class TvSiloCastReceiver( private val identityManager: RemotePlaybackIdentityManager, private val deviceNameProvider: () -> String, private val deviceIdProvider: () -> String, + /** Suspends until the player's queued session teardown has finished. */ + private val awaitPlaybackTeardown: suspend () -> Unit, ) { data class StandbyState( val controllerName: String?, @@ -102,10 +105,16 @@ class TvSiloCastReceiver( // stop() cancels the receiver scope, so cleanup for a ready-but-unconsumed // temporary identity must have an owner that survives that cancellation. private val identityCleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // Bumped by every start(). Handoffs tag the identity with the run that + // claimed it, so stop()'s deferred cleanup can tell a returning phone's + // claim from one made by the session it is tearing down. + @Volatile + private var receiverRun: Long = 0 @Synchronized fun start() { if (scope != null) return + receiverRun += 1 DiagnosticsCastLogger.event("TV cast receiver started") val newScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope = newScope @@ -148,7 +157,7 @@ class TvSiloCastReceiver( fun stop() { DiagnosticsCastLogger.event("TV cast receiver stopped") advertiser.stop() - val identityGeneration = identityManager.activeIdentity?.generationId + val stoppedRun = receiverRun pendingPlayerIdentityGeneration = null identityEndJob = null // Close the session directly (not via closePreviousController, which @@ -164,14 +173,22 @@ class TvSiloCastReceiver( serverSocket = null scope?.cancel() scope = null - if (identityGeneration != null) { - identityCleanupScope.launch { - // Exact-generation guard: a rapid stop/start/new handoff must - // not let the old shutdown clean up the replacement identity. - if (identityManager.activeIdentity?.generationId == identityGeneration) { - identityManager.end() - } - } + // Always scheduled, even with no identity yet: a handoff from the + // session being torn down can still install or reuse one after this. + identityCleanupScope.launch { + // The player's ON_STOP observer runs before this (Activity + // onStop) and has already queued its final progress report and + // stopSession, which ride on this temporary identity. Ending it + // first revokes the session under them (401). The player's + // unregister path cannot own this: its composition is not + // disposed until the activity starts again. Wait for the queued + // teardown (bounded, so the identity is always ended), then end it. + withTimeoutOrNull(PLAYBACK_TEARDOWN_TIMEOUT_MS) { awaitPlaybackTeardown() } + // Run guard: only a handoff from a later start() protects the + // identity, whether it reused this generation or replaced it. + // Claims from the stopped run's own sessions do not, since + // nothing else is left to end them. + identityManager.endIfNotClaimedSince(stoppedRun) } } @@ -417,12 +434,14 @@ class TvSiloCastReceiver( session.handoffJob?.cancel() identityEndJob?.cancel() identityEndJob = null + val run = receiverRun session.handoffJob = scope?.launch { try { val ready = identityManager.prepare( offer = offer, controllerDeviceId = controllerId, controllerDeviceName = session.controllerDeviceName, + receiverRun = run, ) { challenge -> if (activeSession === session) { session.send(SiloCastMessage.HandoffChallenge(challenge)) @@ -602,8 +621,7 @@ class TvSiloCastReceiver( val owner = scope ?: return identityEndJob = owner.launch { delay(delayMs) - if (identityManager.activeIdentity?.generationId != generationId) return@launch - identityManager.end() + if (!identityManager.end(generationId)) return@launch pendingPlayerIdentityGeneration = null refreshAdvertisement() val session = activeSession @@ -802,5 +820,6 @@ class TvSiloCastReceiver( const val GOODBYE_TIMEOUT_MS = 1_000L const val READY_TIMEOUT_MS = 60_000L const val IDENTITY_END_GRACE_MS = 2_000L + const val PLAYBACK_TEARDOWN_TIMEOUT_MS = 15_000L } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 3c621c986e..045d95012b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -356,6 +356,7 @@ val androidTvModule = module { ) } single { + val playbackLifecycle = get() TvSiloCastReceiver( advertiser = get(), serverRegistry = get(), @@ -364,6 +365,7 @@ val androidTvModule = module { deviceIdProvider = { org.siloserver.silo.common.pairing.PairingDeviceId.stable(androidContext()) }, + awaitPlaybackTeardown = playbackLifecycle::awaitPendingStop, ) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt new file mode 100644 index 0000000000..997a053b2f --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt @@ -0,0 +1,159 @@ +package org.siloserver.silo.tv.cast + +import android.app.Application +import kotlin.test.* +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.siloserver.silo.cast.SiloCastHandoffOffer +import org.siloserver.silo.cast.SiloCastProtocol +import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse +import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.network.AndroidServerRegistry +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.DeviceLoginApi + +/** + * The receiver's stop() ends the temporary identity only after the player's + * teardown, so a returning phone can hand off again (reusing the same + * generation, or replacing it) in the meantime. The deferred cleanup must leave + * an identity claimed by a later receiver run alone, but still end one that + * only the stopped run ever claimed. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RemotePlaybackIdentityClaimTest { + + private val api = FakeRemotePlaybackApi() + private val tokens = TokenManagerImpl() + private val manager = RemotePlaybackIdentityManager(api, tokens, deviceNameProvider = { "TV" }) + + @Test fun reuseByALaterRunBlocksTheStaleCleanup() = runTest { + prepare(offer(), run = STOPPED_RUN) + val installed = assertNotNull(manager.activeIdentity) + + val ready = prepare(offer(), run = NEXT_RUN) + + assertTrue(ready.reused) + assertFalse(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertEquals(installed.generationId, manager.activeIdentity?.generationId) + assertTrue(tokens.hasTemporaryScope()) + assertEquals(0, api.endCalls) + } + + @Test fun reuseByTheStoppedRunStillLetsTheCleanupEndIt() = runTest { + prepare(offer(), run = STOPPED_RUN) + + // A handoff from the session stop() is tearing down can finish its + // reuse after stop() ran. That run is gone, so nothing else ends it. + assertTrue(prepare(offer(), run = STOPPED_RUN).reused) + + assertTrue(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertNull(manager.activeIdentity) + assertFalse(tokens.hasTemporaryScope()) + assertEquals(1, api.endCalls) + } + + @Test fun anOfferThatFailsBeforeClaimingLeavesItToTheCleanup() = runTest { + prepare(offer(), run = STOPPED_RUN) + + // The server id does not encode the URL, so validation rejects it + // before the active identity is touched. + assertFails { prepare(offer().copy(serverId = "mismatched"), run = NEXT_RUN) } + + assertTrue(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertNull(manager.activeIdentity) + assertFalse(tokens.hasTemporaryScope()) + assertEquals(1, api.endCalls) + } + + @Test fun aReplacementIdentityIsNotEndedByTheOldCleanup() = runTest { + prepare(offer(), run = STOPPED_RUN) + val installed = assertNotNull(manager.activeIdentity) + + api.approvedProfileId = "profile-2" + prepare(offer(profileId = "profile-2"), run = NEXT_RUN) + val replacement = assertNotNull(manager.activeIdentity) + + assertNotEquals(installed.generationId, replacement.generationId) + assertFalse(manager.endIfNotClaimedSince(STOPPED_RUN)) + assertEquals(replacement, manager.activeIdentity) + assertTrue(tokens.hasTemporaryScope()) + } + + private suspend fun prepare(offer: SiloCastHandoffOffer, run: Long) = + manager.prepare(offer, CONTROLLER, controllerDeviceName = null, receiverRun = run) {} + + private fun offer(profileId: String = "profile-1") = SiloCastHandoffOffer( + requestId = "request", + serverId = AndroidServerRegistry.idFor(SERVER_URL), + serverURL = SERVER_URL, + profileId = profileId, + ) + + private class FakeRemotePlaybackApi : DeviceLoginApi { + var endCalls = 0 + var approvedProfileId = "profile-1" + + override suspend fun remotePlaybackCapabilityAt(serverUrl: String) = ApiResult.Success( + DeviceLoginCapabilityResponse( + remotePlaybackHandoff = true, + protocolVersions = listOf(SiloCastProtocol.version), + ), + ) + + override suspend fun startRemotePlaybackAt( + serverUrl: String, + deviceName: String?, + devicePlatform: String?, + ) = ApiResult.Success( + DeviceLoginStartResponse( + deviceCode = "device-code", + userCode = "USER", + matchCode = "12", + verificationUri = "$serverUrl/pair", + verificationUriComplete = "$serverUrl/pair?code=USER", + expiresAt = "2099-01-01T00:00:00Z", + expiresIn = 60, + interval = 1, + deviceName = deviceName.orEmpty(), + devicePlatform = devicePlatform.orEmpty(), + clientPurpose = "remote_playback", + temporary = true, + ), + ) + + override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String) = ApiResult.Success( + DeviceLoginPollResponse( + status = "approved", + accessToken = "access", + refreshToken = "refresh", + profileId = approvedProfileId, + profileToken = "profile-token", + temporary = true, + ), + ) + + override suspend fun endRemotePlayback(scope: AuthScopeSnapshot): ApiResult { + endCalls += 1 + return ApiResult.Success(Unit) + } + + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unused") + override suspend fun pollDeviceLogin(deviceCode: String) = error("unused") + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unused") + } + + private companion object { + const val SERVER_URL = "https://media.example.test" + const val CONTROLLER = "phone-1" + const val STOPPED_RUN = 1L + const val NEXT_RUN = 2L + } +} From 6e61709a8724e088e3d83134110f32cc16b7df72 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:33:17 -0400 Subject: [PATCH 27/87] feat(watch-party): bring Watch Party to phone and TV behind an Experimental toggle (#377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(watch-party): plan the Android Watch Party against the current contract The September 21 draft predated the server's buffering and catch-up policy and Apple's merged client. This plan follows server main 2812ac39, the web reference client, and Apple main 5d9dc764, and lists every Android input and session-replacement path the room must own. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): adopt the current room contract in the shared room owner The shared Watch Party layer followed an older server contract. Room proof never renewed, the same-room rejoin reused an obsolete socket, connection replacement was treated as a transport drop and reclaimed the membership, suggestion hydration ran on the socket receive path, and events could be dropped without notice. The API now covers every v2 room operation with status and body validation, caller-selected ids kept by the logical action, and a single attempt for the non-retryable start, select, and promote. Host operations authorize by identity; member operations send the room proof, which is adopted from every successful room response. WatchTogetherRepository owns one engagement per membership: it renews the proof before expiry, orders HTTP snapshots against socket receipts so a delayed response cannot erase a newer attachment, treats ended snapshots, room_closed, and connection_replaced as terminal, serializes room and vote actions and reconciles uncertain outcomes by reading the room, drains suggestion pages off the socket path, and samples the server clock on the room socket from the moment it opens. A socket that stayed up reconnects at once after the server's five-minute rotation; quick failures keep retrying through the host's two-minute grace. RoomSession keys connections by membership generation, so an explicit rejoin replaces the old socket. The player controllers and entry ViewModels are patched to the new API only; the player binding and shared lobby are rebuilt in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): cache availability and remember the recent party Entry points need to know whether this server and profile can use Watch Party, and a user who is displaced or loses the connection needs a way back into the party. WatchPartyAvailabilityRepository reads the room and playback capability documents, caches the answer for the captured authority, and never lets a failed probe downgrade a known answer. RecentWatchParties keeps one room code per device in the encrypted preferences for 24 hours, shown only to the server, login, and profile that saved it; no room or join token is stored. The recorder keeps it after a replacement or lost connection, forgets it when the room ended, and clears it before sign-out, account replacement, or server removal. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): add the shared room playback binding The phone and TV room controllers each carried their own copy of the sync mechanics, reported the intended rather than the actual player state, never re-armed readiness for a second seek on the same session, and had no support for the server's buffering and catch-up policy. RoomPlaybackBinding runs one event loop per playback epoch against a small player port. It attaches the committed session on every socket epoch and waits for the server's echo, reports observed state every 1.5 s and stays quiet while stalled, seeking, or suspended, schedules commands on the server clock and advances a late Play, acknowledges the exact waiting command until a fresh snapshot confirms it, reports buffering after a 2 s stall, acknowledges recovery while catching up, and converges small drift by a bounded temporary rate. Media reloads for Play corrections are paced by RoomReloadBudget; explicit seeks are never delayed. The catch-up rules are ported from the web client. The repository implements the binding's room interface. The platform adapters follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): start room playback on the room's exact file and position A room start could fall back to another version when the room's file was missing, resume from the viewer's own progress when no position was passed, and let a replan move the viewer onto a different file. Room starts now carry a WatchPartyPlaybackContext. Both starters play exactly the room's file or fail, start at the room position (an explicit zero included), never apply personal resume or the resume rewind, send allow_alternate_versions: false so every replan keeps the file, and refuse a plan for a different file. StartParams keeps the flag so a 404 renewal pins the same file. Solo starts omit the field. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): add the player-side Watch Party coordinator Both players need the same wiring around the room binding: the room's playback context, status, transport requests, reconciliation of seeks made outside the app, leave and end, and coordinated source fallback for the four approved refusal reasons. WatchPartyPlayback provides it so each platform keeps only a thin adapter. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): share hub, lobby, and picker state between phone and TV The phone and TV each had their own entry, lobby, and suggestion ViewModels. They opened the player whenever a title was selected, held a lone host in the lobby, created vote rooms from the menu, minted new ids on every retry, and normalized codes differently. Shared ViewModels now back both platforms. The hub probes availability, hosts Host Picks rooms (staging a title without starting it), keeps a create's room id for an explicit retry after an uncertain outcome, joins by normalized code or invitation link (never sending a token to another server), and offers Return to Party and Rejoin. The lobby exposes eligibility computed from phase, role, capabilities, and pending work, advisory lobby Ready, suggestions with Queue (stage, never promote), and opens the player only when the room plays. The picker loads the server's shared rows and debounces search. Invitation parsing accepts codes, web links with a base path, and the silo://watch-party app link proposed in silo-apple#412. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): add the device-local Experimental toggle Watch Party ships off in release builds and on in debug builds, like the Apple apps. WatchPartyExperiment keeps the choice on the device, never syncs it, and leaves any active party when it is turned off. Both apps inject it as WatchPartyExposure; the entry points switch to it with the new party screens. Co-Authored-By: Claude Opus 5.5 (1M context) * test(watch-party): expose party state to the playback harness Device runs need to correlate each client's room, socket, commands, and catch-up state and to measure the spread between two players. The debug receiver now reports sampling timestamps and a party object (room phase, revision, connection epoch, attached and applied commands, clock offset, correction rate) with no tokens or tickets, and the script gains party and spread commands. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): never let the HTTP engine resend start, select, or promote A new test shows OkHttp silently resending a Watch Party start when a reused connection drops after the request was sent. These operations are non-retryable on the server: a replay after another selection could start the wrong item. createPlatformHttpClient and createSiloClient take a retryOnConnectionFailure switch, and the Watch Party API sends start, select, and promote on a client with it off. The repository already reconciles such uncertain outcomes by reading the room. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): drive the phone player from the room binding RoomSyncController reported intended state, never re-armed readiness, played downloaded copies, and let local controls and system media controls fork the viewer from the room. The phone player now uses WatchPartyPlayback. Room loads play exactly the room's file at the room position, start paused, skip downloads, and reuse the room context on every restart; version switching and On Deck are refused. The ViewModel reports the true engine position mapped to the source timeline and applies room seeks, play state, and a temporary correction rate through paths that are never user intent. Every user and system transport input goes through the room; seeks the screen did not issue are sent to the room or undone. Audio focus loss, a noisy route, the sleep timer, and backgrounding are local suspensions. Cast and automatic PiP are off in a room, intro skip is pinned to Ask with no pill for members who cannot seek, and approved refusals ask the room for a coordinated fallback. Start refusal reasons now reach the ViewModel. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): hold room commands during a mount and while suspended A room Play reached a locally suspended player, and a command that landed while media was still mounting acted on media being replaced. The binding now leaves a suspended player paused (resuming re-attaches, and the server resends the room position) and holds a command for up to five seconds while a mount or seek is in flight; a late Play still advances by the full elapsed server time. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): drive the TV player from the room binding TvRoomSyncController duplicated the phone controller's defects, left the room when the TV app paused or stopped, and suppressed all SiloCast transport in rooms, hosts included. The TV player now uses WatchPartyPlayback. It loads exactly the room's file at the room position, starts paused, and reuses the room context on every restart; version switching, the Dolby Vision restart, speed writes, and next-up are refused. The ViewModel reports what the player is really doing and applies room seeks, play state, and a temporary correction rate through paths that are never user intent. Remote keys, the scrubber, skips, chapters, the intro pill, MediaSession, admin commands, and SiloCast transport go through the room's permission decision; SiloCast launches are refused while a party plays. ON_PAUSE/ON_STOP, audio focus, and the sleep timer are local holds instead of leaving the room. Approved refusals ask the room for a coordinated fallback. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): refuse SiloCast handoffs while the TV is in a party A handoff swaps in the phone's profile, and any identity change leaves the Watch Party the TV is in. The receiver now cancels the offer with watch_party_active and asks the viewer to leave the party first. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): phone hub, lobby, picker, party panel, and invitations Replace the phone's Watch Together screens with the Watch Party experience on the shared ViewModels: - Hub (profile menu, detail action, invitation): availability, Return to Party, Rejoin, host, and join by code or link. - Lobby: staged title, members and lobby Ready, Start, mode and guest policy, invitation, suggestions and votes; only the room phase opens the player. - Picker sheet for staging and suggesting. - Party panel over the retained player (Back): members, invite, Leave, Return everyone to lobby, End for everyone (D2). - In-playback status: waiting for, catching up, continuing without, host away, and a reconnect notice after two seconds. - Detail pages: one party action (Watch Party, Add to party, Play for everyone, Suggest to Party) and the D5 leave-first guard in front of solo playback, home quick play, and external player links. - silo://watch-party invitations, accepted only while the Experimental toggle is on; the join token stays in memory and routes carry an opaque id. - Settings → Experimental → Watch Party. - A restored party route without a live membership opens the hub. Entry points follow WatchPartyExposure. The old watchtogether package and its tests are removed. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): TV hub, lobby, picker, party panel, and settings toggle Replace the TV's Watch Together dialogs and lobby with the Watch Party experience on the shared ViewModels: - Hub from the profile menu: availability states with a focusable control, Return to Party, Rejoin, Host (Host Picks), Join with code, and why the last party ended. - Lobby: staged title and edition, members and lobby Ready, Start, options, Leave with the host note, the code for everyone and a QR for the host, and suggestions. Controls are disabled rather than removed so live updates keep focus. - Picker with server rows and search. - Detail page: Watch Party, Add to party, Play for everyone (confirmed), or Suggest to Party, and a leave-first prompt in front of solo Play and extras (D5). A launcher play link during a party opens the title page instead. - Back in party playback opens a party panel over the running player (members, code, Back to playback, Return everyone to lobby, End, Leave) and a status overlay that never takes focus. - Settings → General → Experimental → Watch Party. - Party screens already on the back stack are reused, and a restored party route without a live membership opens the hub. The old watchtogether package and its tests are removed. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(watch-party): report why the last room socket ended in the debug status Live qualification showed room sockets ending after 17-40 s on one emulator with nothing in the server log to explain it. The repository now records each socket attempt's end (terminal frame, replacement, or the transport exception class and message) with how long it was open, and the debug receiver's party status includes it as lastSocketEnd. It carries no tickets or tokens. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): issues found in live phone and TV qualification - Ending a party for everyone left a Rejoin for a room that no longer exists. The repository now announces rooms this device closed, and the recent-party recorder forgets them. Leave still keeps Rejoin, which the host Leave dialog promises. - Joining an ended room showed the generic conflict text. A join 409 now says the party has ended. - TV: the confirm dialog meant to focus Cancel, but the popup handed focus to its first row (End party). Focus now moves to the intended row through the observed-focus helper. - TV: a guest's on-screen Pause did nothing and said nothing. It now goes to the room like the remote's play/pause key, so a refusal shows its notice. - TV: the join-code keypad was translucent over the hub's text. It now has a scrim and a near-opaque panel, like the party's other dialogs. - Phone: member statuses no longer shift with name length. - Phone: finding an existing party player uses the public back-stack API instead of the restricted currentBackStack. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor(watch-party): report the server's raw refusal reason on phone The phone told the room why playback was refused from diagnosticsCode, a telemetry allow-list, while the TV used the terminal reason exactly as the server sent it. The mobile starter now records terminalReason too, and the phone reports it, so both platforms send the same value. Co-Authored-By: Claude Opus 5.5 (1M context) * chore(watch-party): remove the Watch Together entry leftovers Both platforms now use the shared Watch Party ViewModels and the Experimental toggle, so these have no callers: - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED and its test (WatchPartyExposure replaces it; WatchPartyExperimentTest covers the per-build default). - WatchTogetherEntryPolicy and its test (content-present entry rules). - The WatchTogetherEntryGateway interface and its DI binding. Co-Authored-By: Claude Opus 5.5 (1M context) * docs: describe Watch Party as experimental behind the Settings toggle D1 exposes Watch Party on phone and TV behind Settings → Experimental → Watch Party (on in debug builds, off in release builds) and the server's capabilities. Update the product-exposure rules, README, FEATURES (with an experimental legend), and docs/README to match. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(harness): measure party spread from the fresh player position `spread` used screenPositionSec, which the TV updates only every 500 ms, so readings swung by up to half a second. It now uses the player position when that is on the same timeline as the screen position, and falls back to the screen position when a remux or transcode starts mid-title. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): stop remembering a party as it closes The TV hub offered Rejoin for a party it had just ended for everyone. The repository publishes an end while the last snapshot is still live, then clears both. The recorder checked the live membership first, so it remembered the room again, and the cleared pair never reached the forget branch. A room-gone end now wins, and a membership whose own room just ended is never remembered. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): a dead room socket can no longer stop the playback binding On a throttled TV, the socket died while the binding was sending to it. Its outgoing channel was cancelled, the pending send threw CancellationException, and the realtime client rethrew it as if the binding had been cancelled. The binding's event loop ended silently. The TV kept playing 30 minutes behind the room, marked as left behind, and never caught up, even after it reconnected and new commands arrived. A send now propagates only the caller's own cancellation; any other failure, including a cancelled channel, is a failed send the binding retries on the next socket. Each send is also bounded to 5 s, so the loop never waits on a socket that has stopped draining. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): stop a phone guest from fighting the room's seeks With a web host seeking, the phone guest seeked back and forth between the old and new positions every ~35 ms, thousands of times, and never became ready. A MediaController reports its own seek at once and the session reports it again; the phone's issued-seek tracker consumed the first match, so the second report looked like an outside seek, which a guest undoes, which the room redoes. The TV's tracker never consumed matches and did not loop. Issued seeks now stay recognized until they expire, as on TV, and the coordinator undoes at most one outside seek per two seconds, so a future mismatch cannot turn into a loop. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): keep party invitations through sign-in A silo://watch-party link that arrived before sign-in or profile selection was pinned to the identity generation at arrival. Signing in moves that generation, so the pending hub route failed its scope check and was consumed without opening. Invitations now travel unscoped, like account invitations: delivery still waits until the user is past setup, login, and profile selection, and the hub joins only when the link's server is the active one. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): start each membership with a fresh server clock The room clock estimator kept its samples across memberships. After leaving one party and joining another, possibly on another server, the new room's commands were scheduled with the old offset, and the estimator could keep preferring the old low-RTT samples for up to ten minutes. Installing or clearing a membership now resets and republishes the estimate. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): offer TV "Add to party" only when the server stages The TV detail page offered "Add to party" to a Host Picks host whenever the lobby was open, even on a server that does not advertise staged_selection, and then called the unsupported endpoint. It now requires the capability, as the phone's eligibility rule already did. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): repair command timing and party transitions * fix(watch-party): undo every denied outside seek on a guest The two-second cooldown on undoing outside seeks meant a guest's second genuine seek within that window (a double press on a headset or notification control) stayed at a position the room had refused until a later command corrected it. Both players now recognize their own restore seeks, so every outside seek is undone. For a guest, only a runaway burst of more than four within two seconds stops restoring, as a guard against a loop; a host always restores so its report cannot re-anchor the room early. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): show only movies, series, and episodes in picker rows The picker's Continue together and watchlist rows come from profile catalog access, which can include ebooks and other non-video items. Search results were already limited to playable video types, but the rows were not, so TV could show reading items (excluded on TV) and both platforms could offer items that cannot play in a party. The shared picker ViewModel now applies the same filter to both rows. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): restore every denied external seek * feat(watch-party): match the iOS and tvOS Watch Party design The Android party screens were plain utility lists while Apple's are artwork-led rooms. Phone and TV now follow the Apple layouts: - Lobby: the staged title's backdrop fills the room, with a code pill, connection dot, options, a hero with facts and quality chips, member seats whose rings show readiness, a voting ballot or suggestion list, and the one action that matters pinned at the bottom. - Back during party playback reopens that room over the player with "Return to playback" as the main action. - New invitation page with a QR code of the invite link. - Hub, options, join keypad, and the title picker (Home-style shelves plus a confirmation page with "Who's seen it") follow Apple too. The lobby's wording and foregrounded action come from one shared policy ported from Apple's WatchPartyLobbyPolicy, so phone and TV say the same thing. Guests can mark ready in any lobby, as on Apple, and a voting room only offers Start when the server can promote its leader. Avatar colours use Apple's hash, so a member has one colour on every client. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): close three review gaps in party commands and entry - A room command whose execute_at can't be read is now a malformed frame, so the room reconciles instead of running the command at once. - A SiloCast launch is refused whenever the TV is in a party, including the lobby, where no party player is registered yet. - The TV suggestion menu offers Queue only when the server supports staged selection, as the phone already does. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): suggest in playing vote rooms and await TV departure - A host on a detail page in a playing voting room now gets "Suggest to Party" on phone and TV. "Play for everyone" sends a direct selection, which the server refuses in vote rooms. - The TV's "Leave and play" waits for the party to finish leaving before starting solo playback, as the phone already does, so a queued room command can't reach the new playback. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): re-check server support whenever the hub is shown A successful capability probe was cached until the process or identity changed, so a server that turned Watch Party off went unnoticed. The phone and TV hubs now force a probe each time they're shown outside a party, as Apple does. A failed probe still keeps the last known answer. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): reconcile instead of running unknown room actions A transport command with an action this client doesn't know decoded as Unknown, and the playback binding treated it as advancing playback. The decoder now reports it as malformed, so the room reconciles instead. The server sends only play, pause, and seek. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): tighten room commands, host entry, and outside play/pause - A transport command without a known playback_state is malformed and reconciles the room; the server always sends one. - Phone and TV hubs hide Start on servers without staged selection and say the party can still be joined, instead of offering a button the view model then refuses. TV focus falls to Join there. - After an outside play/pause (notification, headset, Assistant), both players sample the room state only once the room's play/pause is restored, so a report can't carry the outside change to the room. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): hide detail-page hosting on servers that can't host The detail page still offered "Watch Party" (host with this title) on a server without staged selection, and the hub then refused it. Phone and TV now hide that entry once the server is known not to host, and keep it while support is still unknown so the hub can explain the result. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): wait for the hub re-check and reuse in-flight suggestions - The phone hub now waits for its forced capability re-check before it consumes an invitation or detail-page host handoff, since the previous answer stays published while the probe runs. - "Suggest to Party" records its request before sending, so a second tap while the first is in flight resends the same suggestion id instead of adding a duplicate, and a later result can't clear another's retry. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): fresh start per title, TV host re-check, lobby suggest reuse - A new selection revision resets the reload budget and stall history, so the previous title's backoff and lower-quality offer don't carry over to the new stream. - TV detail-page hosting forces a capability re-check and waits for it before creating a party, like the hub. - The lobby's "Suggest" reuses the id of an uncertain or in-flight suggestion of the same title, and only a request's own outcome can clear the retry draft. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(watch-party): harden room sync after review - Return the correction rate to 1x while the room socket is detached or reconnecting, instead of leaving up to 1.25x running. - Hold a Play correction that the reload budget defers and run it once the budget allows, rather than playing on at the wrong position. - Count expiring ws-tickets against the reconnect budget and renew the proof when its expiry is unknown, so the loop cannot retry forever. - Renew the room proof on the server clock estimate, and never re-read the room back to back after a successful renewal. - Pair each pong's receipt time with the monotonic time of the same moment, so a queued pong no longer reads as a wall-clock jump and wipes the clock estimate. - Retry a ws-ticket 401 as a transport failure instead of ending the party. - A coalesced suggestion read now shares the read's real outcome. - Phone retries resume where the player was within the same epoch. - Share the issued-seek tracker, invite server match, and notice copy between phone and TV in android-shared. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- AGENTS.md | 3 +- FEATURES.md | 20 +- README.md | 12 +- .../silo/common/di/WatchPartyModule.kt | 61 + .../common/player/PlaybackSessionLifecycle.kt | 2 + .../common/player/PlaybackSessionManager.kt | 3 + .../player/debug/PlaybackDebugReceiver.kt | 58 + .../video/VideoPlaybackSessionCoordinator.kt | 2 + .../player/video/VideoPlaybackStartRequest.kt | 7 + .../player/video/VideoPlaybackStartResult.kt | 7 + .../common/player/video/VideoPlayerUiState.kt | 4 + .../watchparty/WatchPartyDebugRegistry.kt | 21 + .../player/watchparty/WatchPartyPlayback.kt | 142 ++ .../watchparty/WatchPartyPlayerSupport.kt | 63 + .../common/watchparty/WatchPartyExperiment.kt | 43 + .../watchparty/WatchPartyServerMatch.kt | 33 + .../network/WatchPartyRequestReplayTest.kt | 78 + .../watchparty/WatchPartyPlayerSupportTest.kt | 37 + .../watchparty/WatchPartyExperimentTest.kt | 38 + .../watchparty/WatchPartyServerMatchTest.kt | 39 + androidApp/build.gradle.kts | 1 + androidApp/gradle.lockfile | 1 + .../src/androidMain/AndroidManifest.xml | 2 + .../siloserver/silo/android/MainActivity.kt | 27 +- .../silo/android/SiloApplication.kt | 3 +- .../silo/android/di/AndroidModule.kt | 58 +- .../android/ui/components/MainAppTopBar.kt | 4 +- .../silo/android/ui/components/ProfileMenu.kt | 14 +- .../android/ui/components/TopBarActions.kt | 8 +- .../android/ui/navigation/AppNavigation.kt | 263 ++- .../ui/navigation/ExternalRouteNavigation.kt | 12 +- .../silo/android/ui/navigation/Routes.kt | 32 +- .../silo/android/ui/screens/MainScreen.kt | 69 +- .../ui/screens/detail/ItemDetailScreen.kt | 140 +- .../ui/screens/detail/MovieDetailContent.kt | 26 +- .../ui/screens/detail/SeriesDetailContent.kt | 26 +- .../android/ui/screens/home/HomeScreen.kt | 8 +- .../ui/screens/libraries/LibrariesScreen.kt | 8 +- .../MobileSubtitleTransactionAdapter.kt | 6 + .../player/MobileVideoPlaybackStarter.kt | 56 +- .../player/PlaybackRealtimeController.kt | 22 +- .../ui/screens/player/PlayerControls.kt | 18 +- .../ui/screens/player/PlayerGestureHandler.kt | 2 +- .../ui/screens/player/PlayerOverlay.kt | 108 +- .../android/ui/screens/player/PlayerScreen.kt | 687 ++++-- .../ui/screens/player/PlayerSettingsSheet.kt | 30 +- .../ui/screens/player/PlayerViewModel.kt | 445 +++- .../ui/screens/player/RoomSyncController.kt | 423 ---- .../screens/player/RoomSyncStateReportGate.kt | 28 - .../screens/player/WatchPartyPlayerSupport.kt | 92 + .../ui/screens/settings/SettingsScreen.kt | 16 +- .../watchparty/WatchPartyComponents.kt | 121 + .../watchparty/WatchPartyDetailActions.kt | 198 ++ .../screens/watchparty/WatchPartyHandoff.kt | 71 + .../screens/watchparty/WatchPartyHubScreen.kt | 463 ++++ .../watchparty/WatchPartyInviteSheet.kt | 210 ++ .../watchparty/WatchPartyLobbyScreen.kt | 262 +++ .../watchparty/WatchPartyPanelSheet.kt | 141 ++ .../watchparty/WatchPartyPickerSheet.kt | 729 ++++++ .../watchparty/WatchPartyRoomContent.kt | 969 ++++++++ .../ui/screens/watchparty/WatchPartyStyle.kt | 612 +++++ .../screens/watchparty/WatchPartySupport.kt | 116 + .../watchtogether/SuggestToRoomViewModel.kt | 61 - .../watchtogether/WatchTogetherEntrySheet.kt | 135 -- .../WatchTogetherEntryViewModel.kt | 162 -- .../watchtogether/WatchTogetherLobbyScreen.kt | 226 -- .../WatchTogetherLobbyViewModel.kt | 109 - .../WatchTogetherMenuEntrySheet.kt | 127 -- ...erViewModelLoadOwnershipIntegrationTest.kt | 173 ++ .../player/RoomDeliveryKeySourceTest.kt | 47 - .../player/RoomSyncStateReportGateTest.kt | 57 - .../player/WatchPartyExternalSeekTest.kt | 131 ++ .../player/WatchPartyPlayerSupportTest.kt | 53 + .../watchparty/WatchPartyInviteRouteTest.kt | 34 + .../watchparty/WatchPartySupportTest.kt | 156 ++ .../watchtogether/LobbyAutoNavigateTest.kt | 57 - .../WatchTogetherEntryDestinationTest.kt | 65 - .../WatchTogetherEntryViewModelTest.kt | 145 -- .../WatchTogetherLobbyErrorTest.kt | 177 -- .../siloserver/silo/tv/SiloTvApplication.kt | 3 +- .../silo/tv/cast/TvSiloCastPlayerAdapter.kt | 6 + .../silo/tv/cast/TvSiloCastReceiver.kt | 35 + .../siloserver/silo/tv/di/AndroidTvModule.kt | 57 +- .../silo/tv/ui/navigation/TvAppNavigation.kt | 243 +- .../silo/tv/ui/navigation/TvRoute.kt | 34 +- .../ui/screens/detail/TvItemDetailScreen.kt | 162 +- .../player/TvPlaybackRealtimeController.kt | 26 +- .../silo/tv/ui/screens/player/TvPlayerHud.kt | 58 +- .../tv/ui/screens/player/TvPlayerScreen.kt | 1009 +++++---- .../tv/ui/screens/player/TvPlayerViewModel.kt | 437 +++- .../ui/screens/player/TvRoomSyncController.kt | 474 ---- .../screens/player/TvVideoPlaybackStarter.kt | 38 +- .../ui/screens/player/TvWatchPartyPlayer.kt | 503 +++++ .../player/TvWatchPartyPlayerOverlays.kt | 379 ++++ .../ui/screens/settings/TvSettingsScreen.kt | 24 + .../ui/screens/watchparty/TvJoinCodeDialog.kt | 291 +++ .../watchparty/TvWatchPartyComponents.kt | 194 ++ .../watchparty/TvWatchPartyDetailEntry.kt | 371 +++ .../watchparty/TvWatchPartyHubScreen.kt | 313 +++ .../watchparty/TvWatchPartyLobbyScreen.kt | 514 +++++ .../screens/watchparty/TvWatchPartyPicker.kt | 587 +++++ .../ui/screens/watchparty/TvWatchPartyRoom.kt | 961 ++++++++ .../watchparty/TvWatchPartyStagedItem.kt | 136 ++ .../screens/watchparty/TvWatchPartyStyle.kt | 837 +++++++ .../ui/screens/watchparty/TvWatchPartyText.kt | 75 + .../screens/watchtogether/TvJoinCodeDialog.kt | 276 --- .../watchtogether/TvSuggestToRoomViewModel.kt | 61 - .../TvWatchTogetherDestination.kt | 20 - .../TvWatchTogetherEntryDialog.kt | 117 - .../TvWatchTogetherLobbyScreen.kt | 567 ----- .../TvWatchTogetherLobbyViewModel.kt | 101 - .../TvWatchTogetherMenuEntryDialog.kt | 140 -- .../watchtogether/TvWatchTogetherViewModel.kt | 155 -- .../silo/tv/ui/shell/TvMainShell.kt | 77 +- .../components/TvControlWiringCallSiteTest.kt | 4 +- .../player/TvRoomDeliveryKeySourceTest.kt | 47 - .../screens/player/TvRoomTransportGateTest.kt | 67 - .../screens/player/TvWatchPartyPlayerTest.kt | 231 ++ .../TvJoinCodeStateTest.kt | 15 +- .../watchparty/TvWatchPartyTextTest.kt | 89 + .../LobbyNavigationDecisionTest.kt | 80 - .../TvWatchTogetherDestinationTest.kt | 49 - .../TvWatchTogetherLobbyErrorTest.kt | 172 -- .../TvWatchTogetherMenuInitialActionTest.kt | 18 - .../TvWatchTogetherViewModelTest.kt | 136 -- docs/README.md | 9 +- docs/playback/README.md | 1 + docs/watch-party-implementation-plan.md | 1032 +++++++++ scripts/android-playback-test.sh | 57 + .../silo/network/SiloHttpClient.android.kt | 3 +- .../siloserver/silo/util/WallClock.android.kt | 3 + ...haredPreferencesRecentWatchPartyStorage.kt | 22 + .../WatchPartyFixtures.android.kt | 6 + .../org/siloserver/silo/RoomSyncEngine.kt | 5 + .../org/siloserver/silo/di/NetworkModule.kt | 10 +- .../siloserver/silo/di/RepositoryModule.kt | 2 - .../silo/model/feature/ClientSurfacePolicy.kt | 13 +- .../silo/model/playback/PlaybackProtocolV3.kt | 7 + .../model/watchtogether/WatchPartyV2Models.kt | 93 + .../watchtogether/WatchTogetherModels.kt | 120 +- .../siloserver/silo/network/SiloHttpClient.kt | 6 +- .../silo/network/SiloHttpClientImpl.kt | 3 +- .../network/WatchTogetherRealtimeClient.kt | 204 +- .../network/WatchTogetherRealtimeEvent.kt | 58 +- .../silo/network/api/WatchTogetherApi.kt | 374 +++- .../repository/WatchTogetherRepository.kt | 1126 +++++++--- .../org/siloserver/silo/util/Rfc3339.kt | 31 + .../org/siloserver/silo/util/WallClock.kt | 8 + .../silo/viewmodel/WatchPartyViewModels.kt | 600 +++++ .../silo/watchtogether/RecentWatchParty.kt | 180 ++ .../silo/watchtogether/RoomCatchup.kt | 157 ++ .../silo/watchtogether/RoomClock.kt | 93 + .../silo/watchtogether/RoomPlaybackBinding.kt | 722 ++++++ .../silo/watchtogether/RoomProof.kt | 37 + .../silo/watchtogether/RoomSession.kt | 34 +- .../siloserver/silo/watchtogether/RoomVote.kt | 22 +- .../WatchPartyAvailabilityRepository.kt | 103 + .../watchtogether/WatchPartyCapabilities.kt | 79 + .../watchtogether/WatchPartyEligibility.kt | 132 ++ .../silo/watchtogether/WatchPartyIds.kt | 12 + .../watchtogether/WatchPartyInvitation.kt | 139 ++ .../WatchPartyLobbyPresentation.kt | 260 +++ .../WatchPartyPlaybackContext.kt | 41 + .../WatchTogetherEntryGateway.kt | 16 - .../watchtogether/WatchTogetherEntryPolicy.kt | 23 - .../model/feature/ClientSurfacePolicyTest.kt | 11 - .../model/playback/PlaybackProtocolV3Test.kt | 18 + .../model/watchtogether/WatchPartyFixtures.kt | 7 + .../WatchTogetherModelsSerializationTest.kt | 16 +- .../silo/network/RoomFrameDecoderTest.kt | 37 +- .../WatchTogetherRealtimeClientTest.kt | 7 + .../silo/network/api/WatchTogetherApiTest.kt | 417 ++-- .../repository/WatchTogetherRepositoryTest.kt | 1986 ++++++++++------- .../viewmodel/WatchPartyViewModelsTest.kt | 405 ++++ .../silo/watchtogether/RoomCatchupTest.kt | 79 + .../watchtogether/RoomPlaybackBindingTest.kt | 694 ++++++ .../silo/watchtogether/RoomSessionTest.kt | 37 + .../WatchPartyAvailabilityAndRecentsTest.kt | 195 ++ .../watchtogether/WatchPartyEntryRulesTest.kt | 184 ++ .../WatchPartyLobbyPresentationTest.kt | 120 + .../silo/watchtogether/WatchPartyWireTest.kt | 253 +++ .../WatchTogetherEntryPolicyTest.kt | 57 - .../resources/watchtogether/v2/SOURCE | 13 + .../watchtogether/v2/capabilities.json | 15 + .../watchtogether/v2/member_state.json | 1 + .../resources/watchtogether/v2/picker.json | 1 + .../resources/watchtogether/v2/room_http.json | 49 + .../v2/socket_connection_replaced.json | 1 + .../watchtogether/v2/socket_pong.json | 1 + .../watchtogether/v2/socket_room_closed.json | 1 + .../watchtogether/v2/socket_snapshot.json | 1 + .../v2/socket_suggestions_update.json | 1 + .../v2/socket_transport_command.json | 1 + .../watchtogether/v2/suggestions_page.json | 1 + 194 files changed, 22496 insertions(+), 7260 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/WatchPartyModule.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyDebugRegistry.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayback.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupport.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperiment.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatch.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/WatchPartyRequestReplayTest.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupportTest.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperimentTest.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatchTest.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncController.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGate.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupport.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyComponents.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyDetailActions.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHandoff.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHubScreen.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteSheet.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyLobbyScreen.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPanelSheet.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupport.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/SuggestToRoomViewModel.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt delete mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt delete mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/RoomDeliveryKeySourceTest.kt delete mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGateTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyExternalSeekTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupportTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteRouteTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupportTest.kt delete mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt delete mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt delete mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt delete mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomSyncController.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvJoinCodeDialog.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyDetailEntry.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyHubScreen.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyText.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt delete mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomTransportGateTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt rename androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/{watchtogether => watchparty}/TvJoinCodeStateTest.kt (59%) create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyTextTest.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuInitialActionTest.kt delete mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt create mode 100644 docs/watch-party-implementation-plan.md create mode 100644 shared/src/androidMain/kotlin/org/siloserver/silo/util/WallClock.android.kt create mode 100644 shared/src/androidMain/kotlin/org/siloserver/silo/watchtogether/SharedPreferencesRecentWatchPartyStorage.kt create mode 100644 shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.android.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyV2Models.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/util/WallClock.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RecentWatchParty.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomCatchup.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomClock.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBinding.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomProof.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityRepository.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyCapabilities.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyEligibility.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyIds.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyInvitation.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentation.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyPlaybackContext.kt delete mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryGateway.kt delete mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryPolicy.kt delete mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/ClientSurfacePolicyTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModelsTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomCatchupTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBindingTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityAndRecentsTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyEntryRulesTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentationTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyWireTest.kt delete mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryPolicyTest.kt create mode 100644 shared/src/commonTest/resources/watchtogether/v2/SOURCE create mode 100644 shared/src/commonTest/resources/watchtogether/v2/capabilities.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/member_state.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/picker.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/room_http.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/socket_connection_replaced.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/socket_pong.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/socket_room_closed.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/socket_snapshot.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/socket_suggestions_update.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/socket_transport_command.json create mode 100644 shared/src/commonTest/resources/watchtogether/v2/suggestions_page.json diff --git a/AGENTS.md b/AGENTS.md index db783e1eb1..93efc11a97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ This repository contains only the Silo Android clients. Shared Kotlin logic live - Ebooks/Reading are phone-only. Do not expose ebooks or Reading on Android TV. - Android mobile navigation is Home, Libraries, For You, Calendar, and Downloads only when the active profile has downloads. Video, Audio, and Reading are library modes reached through Libraries, not bottom-nav tabs. - Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), Calendar, and Requests while the server enables it (mirroring tvOS `.requests`), plus search and profile actions. Reading/ebooks are excluded. -- Requests is live on phone and TV, server-gated by `requests_enabled` (phone: profile menu + search; TV: a top-bar tab + search, matching Apple). Admins who can moderate requests approve, decline, and retry them in the Requests screens, as on Apple; limits and auto-approval stay in web admin. Other admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans) — and neither is Watch Together. Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. +- Requests is live on phone and TV, server-gated by `requests_enabled` (phone: profile menu + search; TV: a top-bar tab + search, matching Apple). Admins who can moderate requests approve, decline, and retry them in the Requests screens, as on Apple; limits and auto-approval stay in web admin. Other admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans). Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. +- Watch Party (synchronized playback) is experimental on phone and TV. Its entry points (profile menu, the detail page's party action, and phone `silo://watch-party` invitations) appear only while Settings → Experimental → Watch Party is on, a device-local toggle that defaults on in debug builds and off in release builds, and it works only when the server advertises the room and playback capabilities. Keep it behind the toggle until the qualification matrix in `docs/watch-party-implementation-plan.md` passes. ## Build, Test, and Development Commands diff --git a/FEATURES.md b/FEATURES.md index 98f4fbc841..f4cc7295a5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -5,11 +5,12 @@ A detailed inventory of what the Android **phone** and **TV** clients do today. - ✅ implemented - 🟡 partial / basic ("bones-level") — works but slated for improvement - 🚧 planned (design/plan exists, not built) +- 🧪 experimental — built, behind Settings → Experimental - ➖ not present on this platform (by design or not yet built) File pointers are repository-relative. -> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV — matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. The richer admin screens (users/sessions/logs/scans) and Watch Together remain inaccessible. +> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV — matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. The richer admin screens (users/sessions/logs/scans) remain inaccessible. Watch Party is experimental, behind Settings → Experimental → Watch Party. --- @@ -47,15 +48,18 @@ File pointers are repository-relative. | Landscape-on-play (auto-rotate aware) | 🚧 | ➖ | Implemented then reverted; pending re-apply | | Picture-in-Picture | ✅ | ✅ | `SiloPictureInPictureCoordinator`; enters on home-press during playback | -## Watch Together (not exposed) +## Watch Party (experimental) + +Shown only while Settings → Experimental → Watch Party is on (default on in debug builds, off in release builds) and the server advertises Watch Party. | Feature | Phone | TV | Notes | |---|:---:|:---:|---| -| Create / join / leave room | 🚧 | 🚧 | Code/design artifacts exist, but users cannot access this flow | -| Clock sync (NTP-style) + drift correction | 🚧 | 🚧 | Shared infrastructure exists but is not a live feature | -| Host vs guest transport gating | 🚧 | 🚧 | Not reachable from production navigation | -| Room snapshots / member list / suggestions / voting | 🚧 | 🚧 | Not reachable from production navigation | -| Graceful reconnect + host-closed auto-exit | 🚧 | 🚧 | Not reachable from production navigation | +| Hub: host, join by code, Return to Party, Rejoin | 🧪 | 🧪 | Profile menu; detail page party action | +| Lobby: staged title, Host Picks or voting, lobby Ready, suggestions | 🧪 | 🧪 | Only the room's phase opens the player | +| Invitations | 🧪 | 🧪 | Phone: share/copy link, `silo://watch-party`; TV: code and QR | +| Synced playback (server clock, rate convergence, catch-up) | 🧪 | 🧪 | Shared `RoomPlaybackBinding` | +| Party panel on Back: Leave, Return everyone to lobby, End | 🧪 | 🧪 | Host-only actions per role | +| Reconnect, socket rotation, same-profile replacement | 🧪 | 🧪 | Room socket owned by `RoomSession` | ## Offline & Downloads @@ -154,6 +158,6 @@ File pointers are repository-relative. **TV** is a 10-foot, D-pad client focused on browsing and playback, including audiobooks, calendar, the subtitle suite, person detail, and system Watch Next integration. It intentionally omits ebooks/reading and downloads management. -**Not currently exposed on either Android surface:** the admin stats dashboard, full admin management (users/sessions/logs/scans), and Watch Together. Request moderation (approve, decline, retry) is the one admin action the Requests screens offer. +**Not currently exposed on either Android surface:** the admin stats dashboard and full admin management (users/sessions/logs/scans). **Experimental:** Watch Party, behind Settings → Experimental. Request moderation (approve, decline, retry) is the one admin action the Requests screens offer. Both apps share the same networking, auth, repositories, most ViewModels, and the entire Media3 playback/capability stack. diff --git a/README.md b/README.md index b918c5b1d9..75962270fe 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Built as a Kotlin Multiplatform project: one shared business-logic core, two Jet > **Status:** WIP (`v0.3.x`). Silo Android is pre-1.0 and some areas remain under active development (see [Roadmap](#roadmap)). > -> **Current exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV — matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. Other admin surfaces, session management, and Watch Together are not exposed in the Android clients. +> **Current exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV — matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. Other admin surfaces and session management are not exposed in the Android clients. Watch Party is experimental, behind Settings → Experimental → Watch Party (on in debug builds, off in release builds). --- @@ -71,8 +71,8 @@ A condensed tour. For the exhaustive, per-feature checklist (with phone/TV cover - **Tracks & subtitles** — audio-track switching (incl. mid-stream), subtitle selection with **styling, position, and sync offset**, plus a subtitle suite: provider **search & download** and **AI transcription/translation** with quota tracking. - **System integration** — single Media3 `MediaSession` powers lock-screen / notification / headset / Assistant controls; TV adds D-pad transport, an info HUD, a chapter scrubber, and HDMI EDID-driven display-mode selection. -### Watch Together -Not currently exposed on Android phone or Android TV. Shared sync infrastructure and design notes may exist in the repository, but users cannot create or join Watch Together sessions from the apps on this branch. +### Watch Party +Experimental on Android phone and Android TV. Turn on Settings → Experimental → Watch Party (on by default in debug builds, off in release builds); the server must also advertise Watch Party. Host from a title's page or the profile menu, pick what everyone watches in the lobby (Host Picks or voting), and invite others with the party code (phone: share or copy the invitation link; TV: code and QR). Playback stays in sync across devices, and Back during a party opens the party panel instead of leaving. See [`docs/watch-party-implementation-plan.md`](docs/watch-party-implementation-plan.md). ### ⬇️ Offline & Downloads (phone) WorkManager-backed downloads of video, audiobooks, and books to public device storage (scoped `MediaStore` on API 30+), preserving original filenames/formats so other apps can discover and open them. Metadata lives in Room, local playback/read paths work without a server session, and the app can boot straight to Downloads when launched offline. Download monitoring supports subscription-style queues and **Reclaim Watched** cleanup for completed items that have been watched/read/listened. @@ -84,7 +84,7 @@ WorkManager-backed downloads of video, audiobooks, and books to public device st - **Browse** with genre/rating filters, sorting, and infinite-scroll grids; **collections** are browse-only in the Android clients, while collection authoring/management remains web-only. - **Item detail** for movies and series includes seasons → episodes, multi-version files, cast/crew, local download controls, and phone-to-TV playback handoff. - **Search** scoped by media type, debounced and paginated. -- **Requests** — live on phone and TV behind the server's `requests_enabled` flag (phone: profile menu + search; TV: top-bar tab + search), with request moderation for admins. **Not exposed** — other admin surfaces, session management, and Watch Together are not reachable app surfaces today. +- **Requests** — live on phone and TV behind the server's `requests_enabled` flag (phone: profile menu + search; TV: top-bar tab + search), with request moderation for admins. **Experimental** — Watch Party, behind Settings → Experimental. **Not exposed** — other admin surfaces and session management are not reachable app surfaces today. ### 📖 Reading & 🎧 Audio - **Ebook reader (phone only)** — EPUB, PDF, CBZ (comics), TXT/Markdown, FB2/FBZ, plus MOBI/AZW/AZW3 when the server can convert to EPUB; CBR and unsupported originals can be downloaded/opened externally. Themes, text size, margins, table of contents, bookmarks, and progress are supported. @@ -238,9 +238,9 @@ Active design work lives in `docs/superpowers/specs/` with phased plans in `docs - **Audiobook polish** — the phone and TV players have chapter-aware UI, speed, bookmarks, and sleep timers. Remaining work includes skip-silence, volume normalization, rich notification polish, Android Auto, and a phone widget. - **Ebook reader enhancements** — in-text search, highlights, and notes, with coordinated server work where the shared contract changes. -- **Admin management (users/sessions/logs/scans), Watch Together** — code/design work exists, but these are not currently exposed to users in the Android apps and need product/navigation decisions before being treated as live features. +- **Admin management (users/sessions/logs/scans)** — code/design work exists, but these are not currently exposed to users in the Android apps and need product/navigation decisions before being treated as live features. -Known gaps the docs track: TV has no reader/ebooks and no downloads management by design; admin surfaces other than request moderation, session management, and Watch Together are not accessible on either Android surface today. +Known gaps the docs track: TV has no reader/ebooks and no downloads management by design; admin surfaces other than request moderation, and session management, are not accessible on either Android surface today, and Watch Party is experimental. --- diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/WatchPartyModule.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/WatchPartyModule.kt new file mode 100644 index 0000000000..a412999e91 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/WatchPartyModule.kt @@ -0,0 +1,61 @@ +package org.siloserver.silo.common.di + +import android.content.SharedPreferences +import org.siloserver.silo.network.DurableLoginAuthorityProvider +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.api.WatchTogetherApi +import org.siloserver.silo.network.apiv2.PlaybackV2Api +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.RecentPartyOwner +import org.siloserver.silo.watchtogether.RecentWatchParties +import org.siloserver.silo.watchtogether.SharedPreferencesRecentWatchPartyStorage +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.WatchPartyRecentsRecorder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.dsl.module + +/** + * Watch Party wiring shared by the phone and TV apps: capability discovery + * and the recent-party store. The room owner itself lives in the shared + * repository module. + */ +val watchPartyModule = module { + single { + val tokens = get() + val playback = get() + val api = get() + WatchPartyAvailabilityRepository( + roomCapabilities = { scope -> api.capabilities(scope) }, + playbackCapabilities = { scope -> playback.capabilities(scope) }, + authScopeProvider = { tokens.snapshotCurrentScope() }, + ) + } + single { + val authorities = get() as? DurableLoginAuthorityProvider + RecentWatchParties( + // The app-level SharedPreferences binding is the encrypted store. + storage = SharedPreferencesRecentWatchPartyStorage(get()), + owner = { + authorities?.snapshotDurableLoginAuthority()?.let { authority -> + val serverId = authority.scope.serverId + val profileId = authority.scope.profileId + if (serverId.isBlank() || profileId.isNullOrBlank()) null + else RecentPartyOwner(serverId, authority.loginId, profileId) + } + }, + ) + } + single(createdAtStart = true) { + val repository = get() + WatchPartyRecentsRecorder( + recents = get(), + room = repository.roomSnapshot, + ended = repository.ended, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + identityTransitions = get(), + closedByThisDevice = repository.closedByThisDevice, + ) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt index 8d309fb3bf..17bfdffbea 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt @@ -1069,4 +1069,6 @@ data class StartParams( val qualityPreference: String? = null, val startPosition: Double? = null, val clientPlaybackContext: ClientPlaybackContext, + /** Watch Party starts pin the file (false); renewals must keep that. */ + val allowAlternateVersions: Boolean? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt index 01534ac0d4..d1949c6237 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt @@ -297,6 +297,8 @@ open class PlaybackSessionManager( progressPersistence: ProgressPersistenceV3 = ProgressPersistenceV3.SERVER, deferPublication: Boolean = false, expectedMetadataOwner: AuthScopeSnapshot? = null, + /** False pins [fileId] through every replan (Watch Party). Null keeps the server default. */ + allowAlternateVersions: Boolean? = null, ): ApiResult = contentStartMutex.withLock { if (!tokenManager.acceptsMetadataOwner(expectedMetadataOwner, profileId)) return@withLock ApiResult.Error(0, "identity_changed", "The metadata viewer changed before playback admission.") @@ -375,6 +377,7 @@ open class PlaybackSessionManager( bandwidthCapKbps = maxBitrateKbps?.takeIf { it > 0 }, capabilities = capabilities, clientPlaybackContext = clientPlaybackContext, + allowAlternateVersions = allowAlternateVersions, ) if (!tokenManager.acceptsMetadataOwner(expectedMetadataOwner, profileId)) return@withLock ApiResult.Error(0, "identity_changed", "The metadata viewer changed before playback admission.") diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt index e9539daae8..771f776cdb 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.common.player.debug import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.SystemClock import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Player @@ -11,6 +12,8 @@ import org.json.JSONObject import org.koin.java.KoinJavaComponent import org.siloserver.silo.common.BuildConfig import org.siloserver.silo.common.player.ActivePlayerHolder +import org.siloserver.silo.common.player.watchparty.WatchPartyDebugRegistry +import org.siloserver.silo.repository.WatchTogetherRepository /** * Debug-only adb hook for scripted playback testing (see @@ -50,6 +53,11 @@ class PlaybackDebugReceiver : BroadcastReceiver() { private fun statusJson(player: Player?): JSONObject { val json = JSONObject() + // Correlate samples across devices: monotonic and wall time at the + // moment of sampling, plus the room's server-clock estimate. + json.put("sampledElapsedMs", SystemClock.elapsedRealtime()) + json.put("sampledWallMs", System.currentTimeMillis()) + partyJson()?.let { json.put("party", it) } PlaybackDebugState.screenError?.let { json.put("screenError", it) } PlaybackDebugState.screenPositionSec?.let { json.put("screenPositionSec", it) } PlaybackDebugState.screenDurationSec?.let { json.put("screenDurationSec", it) } @@ -91,6 +99,56 @@ class PlaybackDebugReceiver : BroadcastReceiver() { return json } + /** Room and binding state while a Watch Party is active; never tokens or tickets. */ + private fun partyJson(): JSONObject? { + val repository = runCatching { + KoinJavaComponent.get(WatchTogetherRepository::class.java) + }.getOrNull() ?: return null + val room = repository.roomSnapshot.value + val ended = repository.ended.value + if (room == null && ended == null) return null + val json = JSONObject() + room?.let { + json.put("roomId", it.roomId) + json.put("phase", it.phase.wire) + json.put("playbackState", it.playbackState.wire) + json.put("selectionRevision", it.selectionRevision) + json.put("generation", it.generation) + json.put("selfRole", it.selfRole.wire) + json.put("anchorSec", it.anchorPositionSeconds) + json.put("roomPaused", it.isPaused) + json.put("attachedSessionId", it.attachedSessionId ?: JSONObject.NULL) + json.put("selfReady", it.selfMember?.isReady == true) + json.put("selfIgnoreWait", it.selfIgnoreWait) + json.put("memberCount", it.memberCount) + } + ended?.let { json.put("endedReason", it.reason) } + val connection = repository.connectionState.value + json.put("connectionEpoch", connection.epoch) + json.put("connected", connection.writable) + repository.lastSocketEnd?.let { json.put("lastSocketEnd", it) } + val clock = repository.clock.value + clock.offsetMs?.let { json.put("serverOffsetMs", it) } + clock.rttMs?.let { json.put("clockRttMs", it) } + repository.latestTransportCommand.value?.command?.let { command -> + json.put("latestCommandId", command.commandId) + json.put("latestCommandAction", command.action.wire) + } + WatchPartyDebugRegistry.binding?.debug?.value?.let { debug -> + json.put("bindingAttachedSessionId", debug.attachedSessionId ?: JSONObject.NULL) + json.put("serverAttached", debug.serverAttached) + json.put("pendingCommandId", debug.pendingCommandId ?: JSONObject.NULL) + json.put("appliedCommandId", debug.appliedCommandId ?: JSONObject.NULL) + json.put("readinessCommandId", debug.readinessCommandId ?: JSONObject.NULL) + json.put("catchingUp", debug.catchingUp) + json.put("correctionRate", debug.correctionRate ?: JSONObject.NULL) + json.put("reloadInFlight", debug.reloadInFlight) + json.put("stallReported", debug.stallReported) + json.put("suspended", debug.suspended) + } + return json + } + private fun runCommand(player: Player?, intent: Intent): JSONObject { val json = JSONObject() val cmd = intent.getStringExtra(EXTRA_CMD) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt index b31dd6d16f..44c228c4df 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt @@ -57,6 +57,8 @@ class VideoPlaybackSessionCoordinator( VideoPlayerUiState.Error( contentId = result.contentId, message = result.message, + diagnosticsCode = result.diagnosticsCode, + terminalReason = result.terminalReason, ) } is VideoPlaybackStartResult.ServerUnreachable -> { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartRequest.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartRequest.kt index f6512ea2ca..adb20d780f 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartRequest.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartRequest.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.common.player.video import org.siloserver.silo.common.player.StartParams +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext data class VideoPlaybackStartRequest( val contentId: String, @@ -43,4 +44,10 @@ data class VideoPlaybackStartRequest( */ val recoveryStartParams: StartParams? = null, val libraryId: Int? = null, + /** + * Watch Party playback: the exact room file, position, and paused state. + * Room starts never use a downloaded copy, personal resume, or another + * version, and pin the file through every replan. + */ + val room: WatchPartyPlaybackContext? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt index b7581a0dc2..93282f7a43 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt @@ -62,6 +62,12 @@ sealed interface VideoPlaybackStartResult { val contentId: String, val message: String, val diagnosticsCode: PlaybackDiagnosticsCode? = null, + /** + * The server's terminal refusal reason exactly as sent, when the + * failure was a playback terminal. A Watch Party reports it to the + * room; [diagnosticsCode] keeps only the reasons safe for telemetry. + */ + val terminalReason: String? = null, ) : VideoPlaybackStartResult /** @@ -113,6 +119,7 @@ value class PlaybackDiagnosticsCode private constructor(val wireValue: String) { "replan_loop_detected", "source_metadata_incomplete", "source_unavailable", + "subtitle_conversion_unsupported", "transcoding_disabled", "transcode_start_failed", "transcode_node_unavailable", diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt index 444b149273..4d2219bb76 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt @@ -21,6 +21,10 @@ sealed interface VideoPlayerUiState { data class Error( override val contentId: String, val message: String, + /** Why the start failed, when known; a server refusal carries its terminal reason. */ + val diagnosticsCode: PlaybackDiagnosticsCode? = null, + /** The server's terminal refusal reason, when there was one. */ + val terminalReason: String? = null, ) : VideoPlayerUiState { override val hasPlayableMedia: Boolean = false } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyDebugRegistry.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyDebugRegistry.kt new file mode 100644 index 0000000000..0a654dde2f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyDebugRegistry.kt @@ -0,0 +1,21 @@ +package org.siloserver.silo.common.player.watchparty + +import org.siloserver.silo.watchtogether.RoomPlaybackBinding + +/** + * The binding of the player screen currently in a Watch Party, for the + * debug-only harness receiver. Holds no credentials. + */ +object WatchPartyDebugRegistry { + @Volatile + var binding: RoomPlaybackBinding? = null + private set + + fun register(binding: RoomPlaybackBinding) { + this.binding = binding + } + + fun unregister(binding: RoomPlaybackBinding) { + if (this.binding === binding) this.binding = null + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayback.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayback.kt new file mode 100644 index 0000000000..044f037089 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayback.kt @@ -0,0 +1,142 @@ +package org.siloserver.silo.common.player.watchparty + +import android.os.SystemClock +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.SourceFallbackReason +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.WatchPartyEnded +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.RoomPlaybackBinding +import org.siloserver.silo.watchtogether.RoomPlaybackNotice +import org.siloserver.silo.watchtogether.RoomPlayerPort +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.RoomTransportIntent +import org.siloserver.silo.watchtogether.RoomTransportResult +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext +import org.siloserver.silo.watchtogether.playbackContext +import org.siloserver.silo.watchtogether.roomTransportAuthorized +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.math.abs + +/** + * One player screen's view of the Watch Party engagement. It owns the + * [RoomPlaybackBinding] for the screen's lifetime and gives the screen the + * room's playback context, status, and actions. The process-wide + * [RoomSession] owns the socket; disposing this never leaves the room. + * + * Both the phone and TV players use it in place of their own controllers. + */ +class WatchPartyPlayback( + private val roomId: String, + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val availability: WatchPartyAvailabilityRepository, + player: RoomPlayerPort, + scope: CoroutineScope, +) { + val binding = RoomPlaybackBinding( + room = repository, + player = player, + parentScope = scope, + monotonicNowMs = SystemClock::elapsedRealtime, + wallNowMs = System::currentTimeMillis, + ) + + val room: StateFlow get() = repository.roomSnapshot + + /** Why the engagement ended, once it has; the screen leaves the player. */ + val ended: StateFlow get() = repository.ended + + val notices: SharedFlow get() = binding.notices + val catchingUp: StateFlow get() = binding.catchingUp + val offerLowerQuality: StateFlow get() = binding.offerLowerQuality + + /** + * The playback the player must be prepared for. It changes only when the + * room starts a new epoch (a new selection revision or file); anchor and + * membership updates keep the current player. + */ + val playbackContext: Flow = + repository.roomSnapshot + .map { it?.playbackContext() } + .distinctUntilChanged { old, new -> + old?.selectionRevision == new?.selectionRevision && old?.fileId == new?.fileId + } + + fun start() { + binding.start() + WatchPartyDebugRegistry.register(binding) + roomSession.adopt(roomId) + } + + /** Stop this screen's binding; the room engagement continues. */ + fun dispose() { + WatchPartyDebugRegistry.unregister(binding) + binding.dispose() + } + + fun requestPlayPause(pause: Boolean): RoomTransportResult = binding.requestPlayPause(pause) + + fun requestSeek(sourceSeconds: Double): RoomTransportResult = binding.requestSeek(sourceSeconds) + + /** Whether the local member may use [intent] right now. */ + fun allows(intent: RoomTransportIntent): Boolean = roomTransportAuthorized(room.value, intent) + + /** + * A seek this screen did not issue (notification, headset, Bluetooth, + * Assistant, any other MediaSession controller). The host's becomes a room + * request; anyone else's is denied and explained. Restore the local + * position before dispatching: only the accepted room command may apply + * the target, and a host report must never re-anchor the room early. + */ + fun onExternalSeek(fromSeconds: Double, toSeconds: Double, restore: (Double) -> Unit): RoomTransportResult { + if (abs(toSeconds - fromSeconds) < EXTERNAL_SEEK_NOISE_SECONDS) return RoomTransportResult.Ignored + // Phone and TV retain issued seek targets across both the controller + // and session callbacks to prevent correction loops. Restore each + // outside seek, including repeated denied guest input. + restore(fromSeconds) + return binding.requestSeek(toSeconds) + } + + /** Leave this device's membership. For a host, the room ends two minutes later unless they rejoin. */ + fun leave() { + dispose() + roomSession.depart(closeRoom = false) + } + + /** End the party for everyone (host). */ + fun endForEveryone() { + dispose() + roomSession.depart(closeRoom = true) + } + + /** + * Report a playback refusal. Only the four approved reasons, and only when + * the server advertises coordinated fallback, ask the room to move + * everyone to another source; anything else stays a local error, and no + * other file is ever chosen locally. Returns true when the room took over. + */ + suspend fun reportRefusal(context: WatchPartyPlaybackContext, failedFileId: Int, reason: String?): Boolean { + val approved = SourceFallbackReason.fromWire(reason?.trim()?.lowercase()) ?: return false + if (availability.features?.sourceFallback != true) return false + if (room.value?.selectionRevision != context.selectionRevision) return false + val result = repository.requestSourceFallback( + selectionRevision = context.selectionRevision, + failedFileId = failedFileId.toString(), + reason = approved, + ) + return result is ApiResult.Success && + result.data.room.selectionRevision != context.selectionRevision + } + + private companion object { + /** Seek adjustments smaller than this are the player settling, not a request. */ + const val EXTERNAL_SEEK_NOISE_SECONDS = 1.0 + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupport.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupport.kt new file mode 100644 index 0000000000..aa8c1f0f26 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupport.kt @@ -0,0 +1,63 @@ +package org.siloserver.silo.common.player.watchparty + +import android.os.SystemClock +import org.siloserver.silo.watchtogether.RoomPlaybackNotice +import org.siloserver.silo.watchtogether.RoomTransportIntent +import kotlin.math.abs + +/** + * Seeks a player screen sent to Media3, remembered briefly so a seek made + * anywhere else (notification, headset or Bluetooth skip, Assistant, another + * MediaSession controller) can be told apart from its own. Positions are on + * the mounted player's timeline. + * + * Register a target before calling `seekTo`: a MediaController reports its own + * seek to listeners synchronously, inside that call. + */ +class IssuedSeekTracker( + private val nowMs: () -> Long = SystemClock::elapsedRealtime, +) { + private data class Issued(val targetMs: Long, val issuedAtMs: Long) + + private val issued = ArrayDeque() + + fun note(targetMs: Long) { + prune() + issued.addLast(Issued(targetMs.coerceAtLeast(0L), nowMs())) + while (issued.size > MAX_TRACKED) issued.removeFirst() + } + + /** + * True when a seek landing at [positionMs] was not one this screen issued. + * A match is not consumed: a MediaController reports its own seek at once + * and the session reports the same seek again, and a guest that took the + * second report for someone else's seek undid it, which the room then + * redid, in a loop. Issued targets simply expire. + */ + fun isExternal(positionMs: Long): Boolean { + prune() + return issued.none { abs(it.targetMs - positionMs) <= TOLERANCE_MS } + } + + private fun prune() { + val cutoff = nowMs() - EXPIRY_MS + while (issued.isNotEmpty() && issued.first().issuedAtMs < cutoff) issued.removeFirst() + } + + companion object { + const val TOLERANCE_MS = 500L + const val EXPIRY_MS = 3_000L + private const val MAX_TRACKED = 16 + } +} + +/** Plain-language text for a room notice, on phone and TV alike. */ +fun watchPartyNoticeText(notice: RoomPlaybackNotice): String = when (notice) { + is RoomPlaybackNotice.Denied -> when (notice.intent) { + RoomTransportIntent.Seek -> "Only the host can seek." + RoomTransportIntent.PlayPause -> "Only the host can play or pause." + } + RoomPlaybackNotice.Reconnecting -> "Reconnecting to the party…" + RoomPlaybackNotice.ClockUnavailable -> "Waiting for party timing. If this continues, leave and rejoin." + RoomPlaybackNotice.Undelivered -> "Couldn't reach the party. Try again." +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperiment.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperiment.kt new file mode 100644 index 0000000000..798a9bec48 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperiment.kt @@ -0,0 +1,43 @@ +package org.siloserver.silo.common.watchparty + +import android.content.SharedPreferences +import org.siloserver.silo.model.feature.WatchPartyExposure +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The Settings → Experimental → Watch Party toggle. It is device-local (never + * synced with the server's settings) and, until the user chooses, follows + * [defaultEnabled]: on in debug builds, off in release builds. Turning it off + * hides every entry point, and [onDisabled] leaves any active party. + */ +class WatchPartyExperiment( + private val read: () -> Boolean?, + private val write: (Boolean) -> Unit, + private val defaultEnabled: Boolean, + private val onDisabled: () -> Unit, +) : WatchPartyExposure { + constructor(prefs: SharedPreferences, defaultEnabled: Boolean, onDisabled: () -> Unit) : this( + read = { if (prefs.contains(KEY)) prefs.getBoolean(KEY, false) else null }, + write = { value -> prefs.edit().putBoolean(KEY, value).apply() }, + defaultEnabled = defaultEnabled, + onDisabled = onDisabled, + ) + + private val _enabled = MutableStateFlow(read() ?: defaultEnabled) + override val enabled: StateFlow = _enabled.asStateFlow() + + fun setEnabled(value: Boolean) { + write(value) + val was = _enabled.value + _enabled.value = value + if (was && !value) onDisabled() + } + + companion object { + /** Preferences file for device-local experiments. */ + const val PREFS_NAME = "silo_experimental" + private const val KEY = "watch_party" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatch.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatch.kt new file mode 100644 index 0000000000..2791a69026 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatch.kt @@ -0,0 +1,33 @@ +package org.siloserver.silo.common.watchparty + +import java.net.URI + +/** + * Whether an invitation's server is the server this app is signed in to: + * the same scheme, host (ignoring case), port (an omitted default port equals + * the explicit one), and path once a trailing slash is trimmed. A LAN address + * and a public address for the same server do not match, so a join token is + * never sent to a deployment the user did not sign in to. Phone and TV share + * this so a link matches on both or on neither. + */ +fun watchPartyServerMatches(linkServerUrl: String, activeServerUrl: String?): Boolean { + val link = comparableServer(linkServerUrl) ?: return false + val active = activeServerUrl?.let(::comparableServer) ?: return false + return link == active +} + +private data class ComparableServer(val scheme: String, val host: String, val port: Int, val path: String) + +private fun comparableServer(url: String): ComparableServer? { + val uri = runCatching { URI(url.trim()) }.getOrNull() ?: return null + if (uri.isOpaque) return null + val scheme = uri.scheme?.lowercase()?.takeIf { it == "http" || it == "https" } ?: return null + if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return null + val host = uri.host?.lowercase()?.takeIf { it.isNotEmpty() } ?: return null + val port = when { + uri.port != -1 -> uri.port + scheme == "https" -> 443 + else -> 80 + } + return ComparableServer(scheme, host, port, uri.rawPath.orEmpty().trimEnd('/')) +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/WatchPartyRequestReplayTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/WatchPartyRequestReplayTest.kt new file mode 100644 index 0000000000..6d87c5d1c4 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/WatchPartyRequestReplayTest.kt @@ -0,0 +1,78 @@ +package org.siloserver.silo.common.network + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.DefaultWatchTogetherApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.canonicalHttpOrigin +import org.siloserver.silo.network.createSiloClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Start, select, and promote are non-retryable: a replay after another + * selection could start the wrong item. This pins what the real client does + * when the connection drops after the request was sent. + */ +class WatchPartyRequestReplayTest { + + private class ScopeTokens(private val scope: AuthScopeSnapshot, private val delegate: TokenManager = TokenManagerImpl()) : + TokenManager by delegate { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = scope + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = "ACCESS".takeIf { scope == this.scope } + } + + @Test + fun `a start whose pooled connection drops after sending is not resent`() = runBlocking { + val server = MockWebServer() + val room = """{"room":{"room_id":"room-1","phase":"lobby"},"room_access_token":"proof"}""" + // Warm the connection pool so the start reuses a kept-alive connection, + // the case where OkHttp treats a failure as a stale connection. + server.enqueue(MockResponse().setResponseCode(200).addHeader("Content-Type", "application/json").setBody(room)) + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + server.enqueue( + MockResponse().setResponseCode(200).addHeader("Content-Type", "application/json") + .setBody("""{"room":{"room_id":"room-1","phase":"playing"},"room_access_token":"proof"}"""), + ) + server.start() + val scope = AuthScopeSnapshot( + serverId = "server", + profileId = "profile", + serverUrl = server.url("/").toString().trimEnd('/'), + profileToken = null, + identityGeneration = 1, + credentialEpoch = 1, + ) + val serverOrigin = canonicalHttpOrigin(server.url("/").toString()) + val consent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = origin == serverOrigin + } + val client = createSiloClient(tokenManager = ScopeTokens(scope), cleartextOriginConsent = consent) + val nonReplaying = createSiloClient( + tokenManager = ScopeTokens(scope), + cleartextOriginConsent = consent, + retryOnConnectionFailure = false, + ) + try { + val api = DefaultWatchTogetherApi(client, ApiV2Gate.Unrestricted, nonReplayingClient = nonReplaying) + assertIs>(api.getRoom("room-1", "proof", scope)) + + val result = api.startPlayback("room-1", scope) + + assertIs(result) + assertEquals(2, server.requestCount, "the non-retryable start was replayed") + } finally { + client.close() + nonReplaying.close() + server.shutdown() + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupportTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupportTest.kt new file mode 100644 index 0000000000..e556cdcbf1 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/watchparty/WatchPartyPlayerSupportTest.kt @@ -0,0 +1,37 @@ +package org.siloserver.silo.common.player.watchparty + +import org.siloserver.silo.watchtogether.RoomPlaybackNotice +import org.siloserver.silo.watchtogether.RoomTransportIntent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartyPlayerSupportTest { + @Test + fun `seeks this screen issued stay ours until they expire`() { + var now = 0L + val tracker = IssuedSeekTracker { now } + tracker.note(30_000L) + assertFalse(tracker.isExternal(30_000L)) + assertFalse(tracker.isExternal(30_400L), "Media3 may land slightly off the target") + // The session reports the controller's seek a second time; still ours. + assertFalse(tracker.isExternal(30_400L)) + // A headset skip that lands nowhere near an issued target. + assertTrue(tracker.isExternal(40_000L)) + // Issued targets expire. + now = IssuedSeekTracker.EXPIRY_MS + 1 + assertTrue(tracker.isExternal(30_000L)) + } + + @Test + fun `room notices use plain copy`() { + assertEquals("Only the host can seek.", watchPartyNoticeText(RoomPlaybackNotice.Denied(RoomTransportIntent.Seek))) + assertEquals( + "Only the host can play or pause.", + watchPartyNoticeText(RoomPlaybackNotice.Denied(RoomTransportIntent.PlayPause)), + ) + assertEquals("Reconnecting to the party…", watchPartyNoticeText(RoomPlaybackNotice.Reconnecting)) + assertEquals("Couldn't reach the party. Try again.", watchPartyNoticeText(RoomPlaybackNotice.Undelivered)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperimentTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperimentTest.kt new file mode 100644 index 0000000000..dfd12d5a84 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyExperimentTest.kt @@ -0,0 +1,38 @@ +package org.siloserver.silo.common.watchparty + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartyExperimentTest { + private var stored: Boolean? = null + private var departures = 0 + + private fun experiment(defaultEnabled: Boolean) = + WatchPartyExperiment({ stored }, { stored = it }, defaultEnabled) { departures++ } + + @Test + fun `release builds default off and debug builds default on`() { + assertFalse(experiment(defaultEnabled = false).enabled.value) + assertTrue(experiment(defaultEnabled = true).enabled.value) + } + + @Test + fun `the stored choice beats the build default`() { + stored = true + assertTrue(experiment(defaultEnabled = false).enabled.value) + stored = false + assertFalse(experiment(defaultEnabled = true).enabled.value) + } + + @Test + fun `turning it off leaves the party once`() { + val toggle = experiment(defaultEnabled = true) + toggle.setEnabled(false) + toggle.setEnabled(false) + toggle.setEnabled(true) + assertEquals(1, departures) + assertEquals(true, stored) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatchTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatchTest.kt new file mode 100644 index 0000000000..300be7bc85 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/watchparty/WatchPartyServerMatchTest.kt @@ -0,0 +1,39 @@ +package org.siloserver.silo.common.watchparty + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartyServerMatchTest { + @Test + fun `an invitation matches the active server by scheme, host, port, and path`() { + assertTrue(watchPartyServerMatches("https://media.example.com", "https://media.example.com")) + assertTrue(watchPartyServerMatches("https://Media.Example.com/", "https://media.example.com")) + assertTrue(watchPartyServerMatches("HTTPS://media.example.com", "https://media.example.com")) + assertTrue(watchPartyServerMatches("https://media.example.com/silo/", "https://media.example.com/silo")) + assertTrue(watchPartyServerMatches("https://media.example.com:443", "https://media.example.com")) + assertTrue(watchPartyServerMatches("http://10.0.0.5:8096", "http://10.0.0.5:8096/")) + assertTrue(watchPartyServerMatches("http://[fd00::5]:8096", "http://[FD00::5]:8096/")) + } + + @Test + fun `a different scheme, port, base path, or address is a different server`() { + assertFalse(watchPartyServerMatches("http://media.example.com", "https://media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com:8443", "https://media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com/silo", "https://media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com/Silo", "https://media.example.com/silo")) + assertFalse(watchPartyServerMatches("http://[fd00::5]:8096", "http://[fd00::5]")) + // A LAN address and a public address for the same server do not match. + assertFalse(watchPartyServerMatches("http://192.168.1.10:8096", "https://media.example.com")) + } + + @Test + fun `no active server, credentials, or an unparseable URL never match`() { + assertFalse(watchPartyServerMatches("https://media.example.com", null)) + assertFalse(watchPartyServerMatches("https://user@media.example.com", "https://media.example.com")) + assertFalse(watchPartyServerMatches("not a url", "https://media.example.com")) + assertFalse(watchPartyServerMatches("media.example.com", "media.example.com")) + assertFalse(watchPartyServerMatches("https://media.example.com:port", "https://media.example.com")) + assertFalse(watchPartyServerMatches("ftp://media.example.com", "ftp://media.example.com")) + } +} diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 387f12726c..8d240797c6 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -117,6 +117,7 @@ kotlin { implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.coil.compose) + implementation(libs.zxing.core) implementation(libs.haze) implementation(libs.coil.network.okhttp) // Ktor client (download worker wiring). Previously satisfied diff --git a/androidApp/gradle.lockfile b/androidApp/gradle.lockfile index 3d2e3117d6..a137d06978 100644 --- a/androidApp/gradle.lockfile +++ b/androidApp/gradle.lockfile @@ -310,6 +310,7 @@ com.google.testing.platform:core-proto:0.0.9-alpha03=_internal-unified-test-plat com.google.testing.platform:core:0.0.9-alpha03=_internal-unified-test-platform-core com.google.testing.platform:launcher:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher com.google.testparameterinjector:test-parameter-injector:1.18=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.zxing:core:3.5.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.ibm.icu:icu4j:77.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp-sse:4.12.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp:4.12.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml index f6ba9840a4..2f4473507f 100644 --- a/androidApp/src/androidMain/AndroidManifest.xml +++ b/androidApp/src/androidMain/AndroidManifest.xml @@ -78,6 +78,8 @@ + + diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt index ce22c6b51a..2ab03aca9e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt @@ -48,6 +48,10 @@ import org.siloserver.silo.android.ui.navigation.hasLocalDownloadsForScope import org.siloserver.silo.android.ui.navigation.inviteClaimRouteOrNull import org.siloserver.silo.android.ui.navigation.notificationNavigationRouteOrNull import org.siloserver.silo.android.ui.navigation.shouldStartOnDownloads +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartyHandoff +import org.siloserver.silo.android.ui.screens.watchparty.watchPartyAppLinkOrNull +import org.siloserver.silo.android.ui.screens.watchparty.watchPartyInviteRoute +import org.siloserver.silo.common.watchparty.WatchPartyExperiment import org.siloserver.silo.android.ui.screens.onboarding.OnboardingTourLocalCache import org.siloserver.silo.android.ui.theme.SiloTheme import org.siloserver.silo.common.network.ServerReachabilityMonitor @@ -346,8 +350,15 @@ class MainActivity : ComponentActivity() { val notificationRoute = notification?.first val contentRoute = contentDeepLinkRouteOrNull(intent?.dataString) val inviteRoute = inviteClaimRouteOrNull(intent?.dataString) - - val route = notificationRoute ?: contentRoute ?: deviceRoute ?: inviteRoute ?: return + // A Watch Party invitation (silo://watch-party). Ignored while Watch + // Party is turned off (D1). The join token stays in memory in + // WatchPartyHandoff; the route, and the consumed-route marker saved + // across process death, carry only an opaque id derived from it. + val partyLink = watchPartyAppLinkOrNull(intent?.dataString) + ?.takeIf { get(WatchPartyExperiment::class.java).enabled.value } + val partyRoute = partyLink?.let(::watchPartyInviteRoute) + + val route = notificationRoute ?: contentRoute ?: deviceRoute ?: inviteRoute ?: partyRoute ?: return if (route == consumedExternalRoute) return val scope = when { @@ -358,12 +369,20 @@ class MainActivity : ComponentActivity() { // produced it, and that requires a complete identity. route === notificationRoute -> checkNotNull(notification).second route === contentRoute -> currentIdentityScope() + // Like an invite claim, a party invitation names its own server + // and usually arrives before sign-in or profile selection, so it + // must NOT be pinned to the identity at arrival: signing in moves + // the identity generation and would drop it. The hub joins only + // when the invitation's server is the active one, under whoever is + // signed in by then; a newer link still replaces it. // An invite claim carries its own target server and is designed to - // work before authentication, so it must NOT be pinned to the - // current identity. + // work before authentication, so it must NOT be pinned either. else -> ExternalRouteScope.Unscoped } + if (route === partyRoute && partyLink != null) { + get(WatchPartyHandoff::class.java).offerInvite(partyLink) + } pendingExternalRouteRequests.value = externalRouteRequestFactory.create(route = route, scope = scope) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt index 180336838b..9c36897a54 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt @@ -15,6 +15,7 @@ import org.siloserver.silo.android.notifications.NotificationsForegroundStarter import org.siloserver.silo.android.push.AndroidPushRegistrationStarter import org.siloserver.silo.common.di.playerInfraModule import org.siloserver.silo.common.ui.marquee.marqueeModule +import org.siloserver.silo.common.di.watchPartyModule import org.siloserver.silo.common.di.playerModule import org.siloserver.silo.common.diagnostics.DiagnosticsCoordinator import org.siloserver.silo.common.diagnostics.DiagnosticsStartup @@ -41,7 +42,7 @@ class SiloApplication : Application(), Configuration.Provider, SingletonImageLoa DiagnosticsStartup.installCrashCapture(this) val koinApp = startKoin { androidContext(this@SiloApplication) - modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + androidModule + diagnosticsModule) + modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + watchPartyModule + androidModule + diagnosticsModule) } DiagnosticsStartup.startCoordinator { koinApp.koin.get() } koinApp.koin.get().start( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 3a5a7fb0f7..0313af4953 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -202,6 +202,20 @@ val androidModule = module { // every collaborator that reports client identity (headers, playback // context, diagnostics) resolves this rather than deriving its own answer. single { SiloClientBuildIdentity(BuildConfig.BUILD_NUMBER, BuildConfig.RELEASE_CHANNEL) } + // Settings → Experimental → Watch Party: on by default only in debug builds. + single { + org.siloserver.silo.common.watchparty.WatchPartyExperiment( + prefs = androidContext().getSharedPreferences( + org.siloserver.silo.common.watchparty.WatchPartyExperiment.PREFS_NAME, + android.content.Context.MODE_PRIVATE, + ), + defaultEnabled = BuildConfig.DEBUG, + onDisabled = { get().depart() }, + ) + } + single { + get() + } single { AndroidDeviceMetadataProvider( androidContext(), @@ -603,13 +617,51 @@ val androidModule = module { savedStateHandle = get(), ) } - viewModel { org.siloserver.silo.android.ui.screens.watchtogether.WatchTogetherEntryViewModel(get()) } - viewModel { org.siloserver.silo.android.ui.screens.watchtogether.SuggestToRoomViewModel(get()) } + // Watch Party (phone). The shared ViewModels own the state; the handoff + // carries invitations and detail-page items to the hub in memory. + single { org.siloserver.silo.android.ui.screens.watchparty.WatchPartyHandoff() } + viewModel { + val registry = get() + org.siloserver.silo.viewmodel.WatchPartyHubViewModel( + repository = get(), + roomSession = get(), + availability = get(), + recents = get(), + isCurrentServer = { url -> + org.siloserver.silo.common.watchparty.watchPartyServerMatches( + linkServerUrl = url, + activeServerUrl = registry.activeEntry.value?.url, + ) + }, + ) + } viewModel { params -> - org.siloserver.silo.android.ui.screens.watchtogether.WatchTogetherLobbyViewModel( + org.siloserver.silo.viewmodel.WatchPartyLobbyViewModel( roomId = params.get(), repository = get(), roomSession = get(), + availability = get(), + ) + } + viewModel { + val catalog = get() + org.siloserver.silo.viewmodel.WatchPartyPickerViewModel( + repository = get(), + availability = get(), + search = { query -> + when (val result = catalog.browse(query = query, limit = 30)) { + is org.siloserver.silo.network.ApiResult.Success -> + org.siloserver.silo.network.ApiResult.Success(result.data.items) + is org.siloserver.silo.network.ApiResult.Error -> result + is org.siloserver.silo.network.ApiResult.NetworkError -> result + } + }, + ) + } + viewModel { + org.siloserver.silo.android.ui.screens.watchparty.WatchPartyDetailViewModel( + repository = get(), + handoff = get(), ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MainAppTopBar.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MainAppTopBar.kt index 95e1a8ecf2..08fa3c1e27 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MainAppTopBar.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MainAppTopBar.kt @@ -42,7 +42,7 @@ fun MainAppTopBar( hazeState: HazeState, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -80,7 +80,7 @@ fun MainAppTopBar( activeProfile = activeProfile, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt index a463e4537b..24e07a007f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt @@ -18,8 +18,8 @@ import org.siloserver.silo.android.ui.theme.SiloDestructive * this. * * Item order and gating are unchanged. A null [onRequestsClick] is a server - * with `requests_enabled` off, and a null [onWatchTogetherClick] is the - * client-side Watch Together gate; neither is ever shown unconditionally, and + * with `requests_enabled` off, and a null [onWatchPartyClick] is the + * Settings → Experimental → Watch Party gate; neither is ever shown unconditionally, and * nothing new was added. Reading/ebooks are phone-only and reached from * Libraries, and Requests keeps its two entry points (this menu and search). * @@ -35,7 +35,7 @@ fun ProfileMenu( onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, - onWatchTogetherClick: (() -> Unit)? = null, + onWatchPartyClick: (() -> Unit)? = null, ) { var confirmSignOut by rememberSaveable { mutableStateOf(false) } @@ -48,7 +48,7 @@ fun ProfileMenu( // jobs and the feature/account split would stop reading as a split. The // old menu drew its divider unconditionally, so a server with requests // disabled opened onto a stray rule above its first item. - val hasFeatureGroup = onRequestsClick != null || onWatchTogetherClick != null + val hasFeatureGroup = onRequestsClick != null || onWatchPartyClick != null SiloDropdownMenu( expanded = expanded, @@ -63,12 +63,12 @@ fun ProfileMenu( }, ) } - if (onWatchTogetherClick != null) { + if (onWatchPartyClick != null) { SiloMenuItem( - label = "Watch together", + label = "Watch Party", onClick = { onDismissRequest() - onWatchTogetherClick() + onWatchPartyClick() }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt index 4e43db6cdd..aa4c65c63c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt @@ -95,7 +95,7 @@ fun TopBarIconButton( fun TopBarProfileMenu( activeProfile: Profile?, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -130,7 +130,7 @@ fun TopBarProfileMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -148,7 +148,7 @@ fun TabTopBarActions( activeProfile: Profile?, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -171,7 +171,7 @@ fun TabTopBarActions( TopBarProfileMenu( activeProfile = activeProfile, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 81991465e6..d3ffd48e0d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -71,8 +71,13 @@ import org.siloserver.silo.android.ui.screens.collections.CollectionsScreen import org.siloserver.silo.android.ui.screens.collections.LibraryCollectionsScreen import org.siloserver.silo.android.ui.screens.detail.ItemDetailScreen import org.siloserver.silo.android.ui.screens.detail.ItemDetailViewModel -import org.siloserver.silo.android.ui.screens.watchtogether.WatchTogetherEntrySheet -import org.siloserver.silo.android.ui.screens.watchtogether.WatchTogetherLobbyScreen +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartyHandoff +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartyHubScreen +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartyLobbyScreen +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartySoloGuardDialog +import org.siloserver.silo.android.ui.screens.watchparty.rememberWatchPartySoloGuard +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.repository.WatchTogetherRepository import org.siloserver.silo.android.ui.screens.people.PersonDetailScreen import org.siloserver.silo.android.ui.screens.people.PersonDetailViewModel import org.siloserver.silo.android.ui.screens.notifications.InboxScreen @@ -186,6 +191,41 @@ fun AppNavigation( var activePlayerTargetProvider by remember { mutableStateOf(null) } + val watchTogetherRepository: WatchTogetherRepository = koinInject() + val watchPartyHandoff: WatchPartyHandoff = koinInject() + // D5: an external link that starts solo playback asks to leave a party first. + val externalSoloGuard = rememberWatchPartySoloGuard() + + // The party player loads from the room itself; the route's content + // arguments are placeholders only. There is one party player at a time: + // when this room's player is already in the back stack, return to it; it + // follows the room's selection on its own. + fun openPartyPlayer(room: RoomSnapshot, replacing: String?) { + val topPlayer = runCatching { navController.getBackStackEntry(Route.Player.ROUTE) }.getOrNull() + if (topPlayer?.arguments?.getString("roomId") == room.roomId) { + navController.popBackStack(Route.Player.ROUTE, inclusive = false) + return + } + navController.navigate( + Route.Player( + contentId = room.selectedContentId?.takeIf { it.isNotBlank() } ?: PARTY_PLAYER_PLACEHOLDER_ID, + fileId = room.selectedFileId, + libraryId = room.selectedLibraryId, + roomId = room.roomId, + ).route, + ) { + if (replacing != null) popUpTo(replacing) { inclusive = true } + } + } + + // Back to the hub below, or replace [replacing] with a new hub. + fun openPartyHub(replacing: String) { + if (!navController.popBackStack(Route.WatchPartyHub.ROUTE, inclusive = false)) { + navController.navigate(Route.WatchPartyHub().route) { + popUpTo(replacing) { inclusive = true } + } + } + } DisposableEffect(siloCastController) { siloCastController.startBrowsing() @@ -205,6 +245,74 @@ fun AppNavigation( } } + fun navigateExternalRoute(route: String) { + // An external link to a TAB (silo://downloads) must switch tabs, + // not push a second copy of that tab. A duplicate tab entry also + // makes the tab anchor ambiguous: popUpTo(route) resolves to the + // NEWEST match, so the older anchor entry would survive and Back + // could loop through a hidden tab. + if (tabForRoute(route) != null) { + // Tear the player down BEFORE the tab switch, and without + // saving it. tabSwitchNavOptions saves state so a tab keeps + // its stack, which is right for a tab — but a saved player + // entry keeps its ViewModelStore alive, so onCleared never + // runs and the playback session it owns is never stopped. + // The save is also keyed to the LOWEST popped destination, + // so a later clearBackStack on the player route would not + // even find it. Popping first means the player's teardown + // runs the ordinary way. + // ONLY when the player is the current destination. An + // inclusive pop also removes everything above its target, + // so a player sitting BELOW other entries — an external + // item link pushed over it, say — would take those with it + // and silently discard state the viewer expected back. + // Leaving that rarer case saved is the pre-existing + // behaviour; destroying history to fix it is worse. + if (shouldPopPlayerBeforeExternalTab( + navController.currentBackStackEntry?.destination?.route, + ) + ) { + navController.popBackStack( + route = Route.Player.ROUTE, + inclusive = true, + saveState = false, + ) + } + navController.navigate(route) { + tabSwitchNavOptions(navController.bottomMostTabRoute()) + } + } else { + val replaceCurrentPlayer = shouldReplaceCurrentPlayer( + currentDestinationRoute = navController.currentBackStackEntry + ?.destination?.route, + targetRoute = route, + ) + // Single-top only when the arguments agree it really is the + // same screen. AndroidX matches the destination NODE, so an + // external link to item B while item A's detail is showing + // reused A's entry and Back skipped A entirely — the same + // defect this branch fixes for in-app navigation. + val useSingleTop = navController.currentBackStackEntry?.arguments?.getString("libraryId") == null && + shouldLaunchExternalRouteSingleTop( + currentDestinationRoute = navController.currentBackStackEntry + ?.destination?.route, + currentContentId = navController.currentBackStackEntry + ?.savedStateHandle?.get(DisplayedDetailContentIdKey) + ?: navController.currentBackStackEntry?.arguments?.getString("contentId"), + targetRoute = route, + ) + navController.navigate(route) { + if (replaceCurrentPlayer) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + // Decided from the finite external-route producer set, + // not punctuation. A route's spelling does not say + // whether its arguments identify a distinct request. + launchSingleTop = useSingleTop + } + } + } + // 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" @@ -224,70 +332,11 @@ fun AppNavigation( ) }, navigate = { route -> - // An external link to a TAB (silo://downloads) must switch tabs, - // not push a second copy of that tab. A duplicate tab entry also - // makes the tab anchor ambiguous: popUpTo(route) resolves to the - // NEWEST match, so the older anchor entry would survive and Back - // could loop through a hidden tab. - if (tabForRoute(route) != null) { - // Tear the player down BEFORE the tab switch, and without - // saving it. tabSwitchNavOptions saves state so a tab keeps - // its stack, which is right for a tab — but a saved player - // entry keeps its ViewModelStore alive, so onCleared never - // runs and the playback session it owns is never stopped. - // The save is also keyed to the LOWEST popped destination, - // so a later clearBackStack on the player route would not - // even find it. Popping first means the player's teardown - // runs the ordinary way. - // ONLY when the player is the current destination. An - // inclusive pop also removes everything above its target, - // so a player sitting BELOW other entries — an external - // item link pushed over it, say — would take those with it - // and silently discard state the viewer expected back. - // Leaving that rarer case saved is the pre-existing - // behaviour; destroying history to fix it is worse. - if (shouldPopPlayerBeforeExternalTab( - navController.currentBackStackEntry?.destination?.route, - ) - ) { - navController.popBackStack( - route = Route.Player.ROUTE, - inclusive = true, - saveState = false, - ) - } - navController.navigate(route) { - tabSwitchNavOptions(navController.bottomMostTabRoute()) - } + // D5: solo playback from a link asks to leave a party first. + if (route.startsWith("player/") && !route.contains("roomId=")) { + externalSoloGuard.run { navigateExternalRoute(route) } } else { - val replaceCurrentPlayer = shouldReplaceCurrentPlayer( - currentDestinationRoute = navController.currentBackStackEntry - ?.destination?.route, - targetRoute = route, - ) - // Single-top only when the arguments agree it really is the - // same screen. AndroidX matches the destination NODE, so an - // external link to item B while item A's detail is showing - // reused A's entry and Back skipped A entirely — the same - // defect this branch fixes for in-app navigation. - val useSingleTop = navController.currentBackStackEntry?.arguments?.getString("libraryId") == null && - shouldLaunchExternalRouteSingleTop( - currentDestinationRoute = navController.currentBackStackEntry - ?.destination?.route, - currentContentId = navController.currentBackStackEntry - ?.savedStateHandle?.get(DisplayedDetailContentIdKey) - ?: navController.currentBackStackEntry?.arguments?.getString("contentId"), - targetRoute = route, - ) - navController.navigate(route) { - if (replaceCurrentPlayer) { - popUpTo(Route.Player.ROUTE) { inclusive = true } - } - // Decided from the finite external-route producer set, - // not punctuation. A route's spelling does not say - // whether its arguments identify a distinct request. - launchSingleTop = useSingleTop - } + navigateExternalRoute(route) } }, isStillValidForScope = { scope -> @@ -1143,7 +1192,6 @@ fun AppNavigation( // Keep the destination scope available to media cards nested in // the detail page so the poster shared-element hand-off still runs. CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) { - var wtTarget by remember { mutableStateOf?>(null) } val initialContentId = backStackEntry.arguments?.getString("contentId").orEmpty() val openingArtworkUrl = remember(initialContentId) { heroSourceHandoff.pendingArtworkUrl } val openingArtworkThumbhash = remember(initialContentId) { @@ -1242,46 +1290,78 @@ fun AppNavigation( onBookReadClick = { contentId, fileId -> navController.navigate(Route.BookReader(contentId, fileId, libraryId).route) }, - onWatchTogether = { contentId, fileId -> wtTarget = contentId to fileId }, + libraryId = libraryId, + onWatchParty = { item -> + navController.navigate(Route.WatchPartyHub(host = watchPartyHandoff.offerHost(item)).route) + }, + onPartySelected = { room -> openPartyPlayer(room, replacing = null) }, onOpenCastRemote = { navController.navigate(Route.SiloCastRemote.route) { launchSingleTop = true } }, viewModel = detailViewModel, ) } - wtTarget?.let { (cid, fid) -> - WatchTogetherEntrySheet( - contentId = cid, - fileId = fid, - onNavigate = { route -> navController.navigate(route) }, - onDismiss = { wtTarget = null }, - ) - } } } } - // ---- Watch Together lobby ---- + // ---- Watch Party ---- composable( - route = Route.WatchTogetherLobby.ROUTE, + route = Route.WatchPartyHub.ROUTE, arguments = listOf( - navArgument(Route.WatchTogetherLobby.ARG_ROOM_ID) { type = NavType.StringType }, + navArgument(Route.WatchPartyHub.ARG_INVITE) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument(Route.WatchPartyHub.ARG_HOST) { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStackEntry -> - val roomId = backStackEntry.arguments - ?.getString(Route.WatchTogetherLobby.ARG_ROOM_ID) - .orEmpty() - WatchTogetherLobbyScreen( - roomId = roomId, - onNavigateToPlayer = { route -> - navController.navigate(route) { - popUpTo(Route.WatchTogetherLobby.ROUTE) { inclusive = true } - } - }, + WatchPartyHubScreen( + inviteId = backStackEntry.arguments?.getString(Route.WatchPartyHub.ARG_INVITE), + hostId = backStackEntry.arguments?.getString(Route.WatchPartyHub.ARG_HOST), onBack = { navController.popBackStack() }, + // The hub stays below the lobby: it owns a create that may + // still be staging the host's item. + onOpenLobby = { roomId -> navController.navigate(Route.WatchPartyLobby(roomId).route) }, + onOpenPlayer = { room -> openPartyPlayer(room, replacing = null) }, ) } + composable( + route = Route.WatchPartyLobby.ROUTE, + arguments = listOf( + navArgument(Route.WatchPartyLobby.ARG_ROOM_ID) { type = NavType.StringType }, + ), + ) { backStackEntry -> + val roomId = backStackEntry.arguments + ?.getString(Route.WatchPartyLobby.ARG_ROOM_ID) + .orEmpty() + // A lobby restored without a live membership (process death): + // the room token was memory-only, so open the hub and its Rejoin + // instead of re-adopting silently. + val live = remember(roomId) { + watchTogetherRepository.roomSnapshot.value?.roomId == roomId || + watchTogetherRepository.ended.value?.roomId == roomId + } + if (!live) { + LaunchedEffect(roomId) { openPartyHub(replacing = Route.WatchPartyLobby.ROUTE) } + } else { + WatchPartyLobbyScreen( + roomId = roomId, + onBack = { navController.popBackStack() }, + onOpenPlayer = { room -> openPartyPlayer(room, replacing = Route.WatchPartyLobby.ROUTE) }, + onPartyEnded = { openPartyHub(replacing = Route.WatchPartyLobby.ROUTE) }, + onLeft = { navController.popBackStack() }, + onOpenDetail = { contentId -> navController.navigate(Route.ItemDetail(contentId).route) }, + ) + } + } + // ---- Audiobook player (audio-only UI) ---- composable( route = Route.AudiobookPlayer.ROUTE, @@ -1566,6 +1646,8 @@ fun AppNavigation( ) } + WatchPartySoloGuardDialog(externalSoloGuard) + // Nearby-TV offer, app-wide once signed in (iOS parity). Not over the // sign-in chain, where the phone has no session to approve with, nor // over playback or reading. @@ -1595,6 +1677,9 @@ fun AppNavigation( } } +/** Route placeholder for a party player; the room, not the route, decides what plays. */ +private const val PARTY_PLAYER_PLACEHOLDER_ID = "watch-party" + /** * Exact player redelivery is idempotent. Navigating the same concrete route * with launchSingleTop replaces the top entry and tears down active playback; @@ -1608,7 +1693,7 @@ private fun NavHostController.isDisplayingExactPlayerRoute( if (entry.destination.route != Route.Player.ROUTE) return false val arguments = entry.arguments ?: return false // A normal silo://play link is a solo-playback request. Never swallow it - // merely because a Watch Together room currently happens to play the same + // merely because a Watch Party room currently happens to play the same // content/file. if (!arguments.getString("roomId").isNullOrBlank()) return false if (arguments.getString("libraryId") != null) return false diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt index 949ad66769..11fd126e98 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt @@ -145,11 +145,11 @@ internal fun shouldPopPlayerBeforeExternalTab(currentDestinationRoute: String?): * Whether AndroidX may reuse the current destination node for [targetRoute]. * * External requests currently produce only Inbox, item detail, player, - * pairing, invitation, and the Downloads tab (handled before this function). - * Inbox has no arguments. Item detail is reusable only for the same decoded - * content id. A player target is replaced explicitly when a player is on top. - * Pairing and invitation routes carry one-shot arguments, so each delivery - * must retain its own entry. + * pairing, invitation, the Watch Party hub, and the Downloads tab (handled + * before this function). Inbox has no arguments. Item detail is reusable only + * for the same decoded content id. A player target is replaced explicitly when + * a player is on top. Pairing, invitation, and Watch Party hub routes carry + * one-shot arguments, so each delivery must retain its own entry. */ internal fun shouldLaunchExternalRouteSingleTop( currentDestinationRoute: String?, @@ -180,7 +180,7 @@ internal fun playerRouteIntentOrNull(route: String): MobilePlayerRouteIntent? { .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. + // handled by Watch Party even if every media choice happens to match. if ("roomId" in query) return null val fileId = query["fileId"]?.toIntOrNull() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt index 23273be2f5..b518e92176 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt @@ -243,10 +243,36 @@ sealed class Route(val route: String) { } } - // --- Watch Together (synchronized playback rooms) --- - data class WatchTogetherLobby(val roomId: String) : Route("watch_together/${Uri.encode(roomId)}") { + // --- Watch Party (synchronized playback rooms) --- + + /** + * The Watch Party hub. [invite] and [host] are opaque handoff ids for a + * pending invitation or item held in memory by `WatchPartyHandoff`; the + * join token itself never appears in a route. + */ + data class WatchPartyHub(val invite: String? = null, val host: String? = null) : Route( + buildString { + append("watch_party") + val params = listOfNotNull( + invite?.takeIf { it.isNotBlank() }?.let { "$ARG_INVITE=${Uri.encode(it)}" }, + host?.takeIf { it.isNotBlank() }?.let { "$ARG_HOST=${Uri.encode(it)}" }, + ) + if (params.isNotEmpty()) { + append("?") + append(params.joinToString("&")) + } + }, + ) { + companion object { + const val ARG_INVITE = "invite" + const val ARG_HOST = "host" + const val ROUTE = "watch_party?$ARG_INVITE={$ARG_INVITE}&$ARG_HOST={$ARG_HOST}" + } + } + + data class WatchPartyLobby(val roomId: String) : Route("watch_party/lobby/${Uri.encode(roomId)}") { companion object { - const val ROUTE = "watch_together/{roomId}" + const val ROUTE = "watch_party/lobby/{roomId}" const val ARG_ROOM_ID = "roomId" } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt index b7584f7df6..ca487c0093 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt @@ -65,9 +65,10 @@ import org.siloserver.silo.android.ui.screens.recommendations.ForYouList import org.siloserver.silo.android.ui.screens.recommendations.RecommendationsScreen import org.siloserver.silo.viewmodel.RecommendationsViewModel import org.siloserver.silo.android.ui.screens.recommendations.headerTitle -import org.siloserver.silo.android.ui.screens.watchtogether.WatchTogetherMenuEntrySheet +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartySoloGuardDialog +import org.siloserver.silo.android.ui.screens.watchparty.rememberWatchPartySoloGuard import org.siloserver.silo.cast.SiloCastPlaybackRequest -import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED +import org.siloserver.silo.model.feature.WatchPartyExposure import org.siloserver.silo.model.navigation.MediaMode import org.siloserver.silo.model.navigation.MediaModeCapabilities import org.siloserver.silo.model.navigation.mobileMediaModeCapabilities @@ -101,9 +102,12 @@ fun MainScreen( val siloCastController: SiloCastController = koinInject() val siloCastState by siloCastController.state.collectAsState() var showSiloCastTargetPicker by rememberSaveable { mutableStateOf(false) } - var showWatchTogetherEntry by rememberSaveable { mutableStateOf(false) } + val watchPartyExposure: WatchPartyExposure = koinInject() + val watchPartyEnabled by watchPartyExposure.enabled.collectAsState() + // D5: quick play from these tabs asks to leave a Watch Party first. + val soloGuard = rememberWatchPartySoloGuard() - fun playVideo(contentId: String, fileId: Int? = null, resumePositionSeconds: Double? = null) { + fun startVideo(contentId: String, fileId: Int?, resumePositionSeconds: Double?) { val launchedRemotely = siloCastController.launchOnConnectedTarget( SiloCastPlaybackRequest( contentId = contentId, @@ -124,6 +128,10 @@ fun MainScreen( ) } } + + fun playVideo(contentId: String, fileId: Int? = null, resumePositionSeconds: Double? = null) = + soloGuard.run { startVideo(contentId, fileId, resumePositionSeconds) } + val librariesViewModel = if (currentTab == Tab.Libraries) { koinViewModel() } else { @@ -312,9 +320,9 @@ fun MainScreen( } else { null } - val watchTogetherMenuAction: (() -> Unit)? = - if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { - { showWatchTogetherEntry = true } + val watchPartyMenuAction: (() -> Unit)? = + if (watchPartyEnabled) { + { navController.navigate(Route.WatchPartyHub().route) { launchSingleTop = true } } } else { null } @@ -423,7 +431,7 @@ fun MainScreen( onRemoteDisconnectClick = { siloCastController.disconnect() }, isRemoteControlActive = siloCastState.hasActiveSession, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -447,7 +455,7 @@ fun MainScreen( onLibrarySelectorClick = { showLibrarySelector = true }, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -480,7 +488,7 @@ fun MainScreen( activeProfile = headerState.activeProfile, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -500,19 +508,21 @@ fun MainScreen( // audiobook UI + offline resume; everything else uses // the video player's offline-first tryLocalPlayback. onItemClick = { item -> - if (item.mediaType == org.siloserver.silo.model.download.DownloadMediaType.Audiobook) { - navController.navigate( - Route.AudiobookPlayer(item.contentId, item.fileId).route, - ) - } else { - // Downloads are explicitly local/offline; - // bypass playVideo's active-cast redirect. - navController.navigate( - Route.Player( - contentId = item.contentId, - fileId = item.fileId, - ).route, - ) + soloGuard.run { + if (item.mediaType == org.siloserver.silo.model.download.DownloadMediaType.Audiobook) { + navController.navigate( + Route.AudiobookPlayer(item.contentId, item.fileId).route, + ) + } else { + // Downloads are explicitly local/offline; + // bypass playVideo's active-cast redirect. + navController.navigate( + Route.Player( + contentId = item.contentId, + fileId = item.fileId, + ).route, + ) + } } }, onReadEbook = { contentId, fileId -> @@ -539,7 +549,7 @@ fun MainScreen( hazeState = hazeState, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onWatchTogetherClick = watchTogetherMenuAction, + onWatchPartyClick = watchPartyMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = ::switchProfileFromMenu, onSwitchServerClick = { @@ -593,16 +603,7 @@ fun MainScreen( ) } - if (showWatchTogetherEntry) { - WatchTogetherMenuEntrySheet( - onNavigate = { route -> - navController.navigate(route) { - launchSingleTop = true - } - }, - onDismiss = { showWatchTogetherEntry = false }, - ) - } + WatchPartySoloGuardDialog(soloGuard) } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index 9fdde5658c..46caf619f5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -66,7 +66,11 @@ import org.siloserver.silo.android.ui.theme.SiloNavPillBorder import org.siloserver.silo.android.cast.SiloCastController import org.siloserver.silo.android.ui.screens.cast.SiloCastTargetPickerSheet import org.siloserver.silo.android.ui.screens.downloads.openDownloadTargetInExternalApp -import org.siloserver.silo.android.ui.screens.watchtogether.SuggestToRoomViewModel +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartySoloGuardDialog +import org.siloserver.silo.android.ui.screens.watchparty.rememberWatchPartyDetailActions +import org.siloserver.silo.android.ui.screens.watchparty.rememberWatchPartySoloGuard +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.viewmodel.WatchPartyItem import org.siloserver.silo.android.ui.util.playbackResumePosition import org.siloserver.silo.common.downloads.DownloadEnqueuer import org.siloserver.silo.common.downloads.DownloadOpenTarget @@ -80,11 +84,9 @@ import org.siloserver.silo.model.ebook.isInAppReadableEbookVersion import org.siloserver.silo.model.ebook.isSupportedEbookVersion import org.siloserver.silo.model.download.DownloadQuality import org.siloserver.silo.model.download.labelFor -import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.network.ServerRegistry import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.metadata.DescriptionTranslationPhase import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.metadata.MetadataAiOnView @@ -127,7 +129,12 @@ fun ItemDetailScreen( onSeriesDetailReplace: (String, Int, String?) -> Unit, onAudiobookPlayClick: (contentId: String, fileId: Int?, fromStart: Boolean, startPosition: Double?) -> Unit = { _, _, _, _ -> }, onBookReadClick: (String, Int?) -> Unit = { _, _ -> }, - onWatchTogether: (String, Int?) -> Unit = { _, _ -> }, + /** The route's library, staged with a Watch Party item. */ + libraryId: Int? = null, + /** Host a Watch Party with this item (opens the hub). */ + onWatchParty: (WatchPartyItem) -> Unit = {}, + /** The host switched a playing party to an item from this page. */ + onPartySelected: (RoomSnapshot) -> Unit = {}, // Auto-presents the cast remote after "Play on device" launches, mirroring // Apple's playOnTV: the connect/handoff handshake renders in the remote. onOpenCastRemote: () -> Unit = {}, @@ -155,18 +162,20 @@ fun ItemDetailScreen( LaunchedEffect(state.selectedEpisodeContentId) { if (state.selectedEpisodeContentId != null) viewModel.ensureSelectedEpisodeDetailLoaded() } - val suggestViewModel: SuggestToRoomViewModel = koinViewModel() - val suggestRoom by suggestViewModel.room.collectAsState() - val suggestState by suggestViewModel.uiState.collectAsState() val context = LocalContext.current - LaunchedEffect(suggestState.notice, suggestState.error) { - val message = suggestState.notice ?: suggestState.error - if (message != null) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - suggestViewModel.consumeNotice() - suggestViewModel.clearError() - } + // D5: while this device is in a Watch Party, detail Play, extras, and + // audiobook play ask to leave the party first (the player is shared). + val soloGuard = rememberWatchPartySoloGuard() + val guardedPlay: (String, Int?, Int?, Int?, Double?) -> Unit = { contentId, fileId, audio, subtitle, resume -> + soloGuard.run { onPlayClick(contentId, fileId, audio, subtitle, resume) } + } + val guardedAudiobookPlay: (String, Int?, Boolean, Double?) -> Unit = { contentId, fileId, fromStart, start -> + soloGuard.run { onAudiobookPlayClick(contentId, fileId, fromStart, start) } } + val (partyActions, partyDialogs) = rememberWatchPartyDetailActions( + onHost = onWatchParty, + onSelectedWhilePlaying = onPartySelected, + ) // Refresh on return (e.g. backing out of the player): the ViewModel loads // once in init, so without this the Play button keeps the resume label @@ -455,18 +464,18 @@ fun ItemDetailScreen( // Resume/Play: no fileId — the player VM resolves // the part from the stored whole-book position. onPlayClick = { - onAudiobookPlayClick(detail.contentId, null, false, null) + guardedAudiobookPlay(detail.contentId, null, false, null) }, onPlayFromStartClick = { - onAudiobookPlayClick(detail.contentId, null, true, null) + guardedAudiobookPlay(detail.contentId, null, true, null) }, // Parts play from a whole-book (global) offset. onPlayFromPositionClick = { startPosition -> - onAudiobookPlayClick(detail.contentId, null, false, startPosition) + guardedAudiobookPlay(detail.contentId, null, false, startPosition) }, // Chapters jump to their global start offset. onChapterClick = { chapter -> - onAudiobookPlayClick(detail.contentId, null, false, chapter.startSeconds) + guardedAudiobookPlay(detail.contentId, null, false, chapter.startSeconds) }, onFavoriteClick = { viewModel.toggleFavorite() }, onWatchlistClick = { viewModel.toggleWatchlist() }, @@ -689,7 +698,7 @@ fun ItemDetailScreen( }, onPlayClick = { selectedEpisode?.let { - onPlayClick( + guardedPlay( it.contentId, selectedEpisodeFileId, state.selectedAudioIndex.takeIf { state.hasExplicitAudioSelection }, @@ -697,8 +706,8 @@ fun ItemDetailScreen( selectedEpisodeResume, ) } ?: nextEpisode?.let { - onPlayClick(it.contentId, null, null, null, playbackResumePosition(it)) - } ?: onPlayClick( + guardedPlay(it.contentId, null, null, null, playbackResumePosition(it)) + } ?: guardedPlay( detail.contentId, null, null, @@ -709,7 +718,7 @@ fun ItemDetailScreen( onPlayFromBeginning = activeSeriesResume?.let { { selectedEpisode?.let { ep -> - onPlayClick( + guardedPlay( ep.contentId, selectedEpisodeFileId, state.selectedAudioIndex.takeIf { state.hasExplicitAudioSelection }, @@ -717,13 +726,13 @@ fun ItemDetailScreen( 0.0, ) } ?: nextEpisode?.let { ep -> - onPlayClick(ep.contentId, null, null, null, 0.0) - } ?: onPlayClick(detail.contentId, null, null, null, 0.0) + guardedPlay(ep.contentId, null, null, null, 0.0) + } ?: guardedPlay(detail.contentId, null, null, null, 0.0) } }, resumeStoppedAtLabel = activeSeriesResume?.let { formatResumeStoppedAt(it) }, onEpisodePlayClick = { contentId, resumePositionSeconds -> - onPlayClick(contentId, null, null, null, resumePositionSeconds) + guardedPlay(contentId, null, null, null, resumePositionSeconds) }, onEpisodeDetailClick = { viewModel.selectSeriesEpisode(it) }, onEpisodeWatchedChange = { episodeContentId, watched -> @@ -739,7 +748,7 @@ fun ItemDetailScreen( onPersonClick = onPersonClick, onItemDetailClick = onItemDetailClick, onPlayExtra = { extra -> - onPlayClick(extra.contentId, extra.fileId, null, null, 0.0) + guardedPlay(extra.contentId, extra.fileId, null, null, 0.0) }, onSeriesDownloadClick = { // Series/season batches are Original-only server-side @@ -783,28 +792,23 @@ fun ItemDetailScreen( } } }, - onSuggestToRoom = if ( - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && - suggestRoom != null && - nextEpisode != null - ) { - { - suggestViewModel.suggest( - contentId = nextEpisode.contentId, + // A series enters a party through the episode Play + // would start: the selected one, else next up. + partyAction = partyActions.actionFor( + (selectedEpisode ?: nextEpisode)?.let { episode -> + WatchPartyItem( + contentId = episode.contentId, contentType = "episode", - title = nextEpisode.title ?: detail.title, - subtitle = detail.title, - posterUrl = nextEpisode.stillUrl ?: detail.posterUrl, + title = episode.title?.takeIf { it.isNotBlank() } + ?: "Episode ${episode.episodeNumber}", + subtitle = "${detail.title} · S${episode.seasonNumber}·E${episode.episodeNumber}", + posterUrl = detail.posterUrl, + fileId = selectedEpisodeVersion?.fileId + ?.takeIf { episode.contentId == selectedEpisode?.contentId }, + libraryId = libraryId, ) - } - } else { - null - }, - onWatchTogether = if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { - { onWatchTogether(nextEpisode?.contentId ?: detail.contentId, null) } - } else { - null - }, + }, + ), ) } @@ -874,7 +878,7 @@ fun ItemDetailScreen( selectedAudioIndex = explicitAudioIndex, selectedSubtitleIndex = explicitSubtitleIndex, onPlayClick = { - onPlayClick( + guardedPlay( detail.contentId, playbackFileId, explicitAudioIndex, @@ -884,7 +888,7 @@ fun ItemDetailScreen( }, onPlayFromBeginning = movieResume?.let { { - onPlayClick( + guardedPlay( detail.contentId, playbackFileId, explicitAudioIndex, @@ -909,7 +913,7 @@ fun ItemDetailScreen( onPersonClick = onPersonClick, onItemDetailClick = onItemDetailClick, onPlayExtra = { extra -> - onPlayClick(extra.contentId, extra.fileId, null, null, 0.0) + guardedPlay(extra.contentId, extra.fileId, null, null, 0.0) }, onSeriesClick = seriesId?.let { resolvedSeriesId -> { onSeriesClick(resolvedSeriesId) } @@ -950,26 +954,29 @@ fun ItemDetailScreen( ) } }, - onSuggestToRoom = if ( - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && suggestRoom != null - ) { - { - suggestViewModel.suggest( + // Movies and episodes stage the displayed edition. + partyAction = partyActions.actionFor( + detail.takeIf { it.type == "movie" || it.type == "episode" }?.let { + WatchPartyItem( contentId = detail.contentId, contentType = detail.type, title = detail.title, - subtitle = detail.seriesTitle?.takeIf { value -> value.isNotBlank() }, - posterUrl = detail.posterUrl, + subtitle = if (detail.type == "episode") { + listOfNotNull( + detail.seriesTitle?.takeIf { value -> value.isNotBlank() }, + detail.seasonNumber?.let { season -> + detail.episodeNumber?.let { episode -> "S$season·E$episode" } + }, + ).joinToString(" · ").ifBlank { null } + } else { + detail.year.takeIf { year -> year > 0 }?.toString() + }, + posterUrl = portraitArtwork.url ?: detail.posterUrl, + fileId = selectedVersion?.fileId, + libraryId = libraryId, ) - } - } else { - null - }, - onWatchTogether = if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { - { onWatchTogether(detail.contentId, explicitFileId) } - } else { - null - }, + }, + ), ) } } @@ -1001,6 +1008,9 @@ fun ItemDetailScreen( ) } + WatchPartySoloGuardDialog(soloGuard) + partyDialogs() + if (showRemoteTargetPicker) { SiloCastTargetPickerSheet( onDismiss = { showRemoteTargetPicker = false }, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt index 1653ed8c19..dce4d472fb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.outlined.AudioFile @@ -98,8 +97,8 @@ fun MovieDetailContent( isDownloaded: Boolean = false, downloadProgress: Float? = null, onDownloadTapped: (() -> Unit)? = null, - onWatchTogether: (() -> Unit)? = null, - onSuggestToRoom: (() -> Unit)? = null, + /** The Watch Party overflow action (host, add, or suggest), when one applies. */ + partyAction: org.siloserver.silo.android.ui.screens.watchparty.DetailPartyAction? = null, translation: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -116,8 +115,7 @@ fun MovieDetailContent( val audioTracks = selectedVersion?.audioTracks.orEmpty() val subtitleTracks = selectedVersion?.subtitleTracks.orEmpty() val hasTrackSelectors = detail.versions.isNotEmpty() - val hasOverflow = onSeriesClick != null || onWatchTogether != null || - onSuggestToRoom != null + val hasOverflow = onSeriesClick != null || partyAction != null val eyebrow = if (detail.type == "episode") { HeroMetadata.episodeEyebrow(detail) @@ -230,27 +228,15 @@ fun MovieDetailContent( }, ) } - if (onSuggestToRoom != null) { + if (partyAction != null) { DropdownMenuItem( - text = { Text("Suggest to Watch Together") }, - leadingIcon = { - Icon(Icons.Filled.Add, contentDescription = null) - }, - onClick = { - dismiss() - onSuggestToRoom() - }, - ) - } - if (onWatchTogether != null) { - DropdownMenuItem( - text = { Text("Watch Together") }, + text = { Text(partyAction.label) }, leadingIcon = { Icon(Icons.Outlined.Groups, contentDescription = null) }, onClick = { dismiss() - onWatchTogether() + partyAction.onClick() }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt index 158d6785a7..1a8271fafe 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt @@ -87,8 +87,8 @@ fun SeriesDetailContent( /** Series-level roll-up across ALL seasons: isDownloaded when every episode * is downloaded, progress = downloaded/total fraction while partial. */ seriesDownloadState: DetailDownloadState = DetailDownloadState(), - onWatchTogether: (() -> Unit)? = null, - onSuggestToRoom: (() -> Unit)? = null, + /** The Watch Party overflow action (host, add, or suggest), when one applies. */ + partyAction: org.siloserver.silo.android.ui.screens.watchparty.DetailPartyAction? = null, translation: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -287,9 +287,7 @@ fun SeriesDetailContent( onToggleFavorite = onFavoriteClick, onToggleWatchlist = onWatchlistClick, onToggleWatched = onToggleWatched, - overflow = if ( - markableSelectedSeason != null || onWatchTogether != null || onSuggestToRoom != null - ) { + overflow = if (markableSelectedSeason != null || partyAction != null) { { dismiss -> if (markableSelectedSeason != null && onSeasonWatchedChange != null) { val seasonWatched = markableSelectedSeason.userData?.played == true @@ -316,27 +314,15 @@ fun SeriesDetailContent( }, ) } - if (onSuggestToRoom != null) { + if (partyAction != null) { DropdownMenuItem( - text = { Text("Suggest to Watch Together") }, + text = { Text(partyAction.label) }, leadingIcon = { Icon(Icons.Outlined.Groups, contentDescription = null) }, onClick = { dismiss() - onSuggestToRoom() - }, - ) - } - if (onWatchTogether != null) { - DropdownMenuItem( - text = { Text("Watch Together") }, - leadingIcon = { - Icon(Icons.Outlined.Groups, contentDescription = null) - }, - onClick = { - dismiss() - onWatchTogether() + partyAction.onClick() }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt index ef1970e29a..50daf9b4ea 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt @@ -127,7 +127,7 @@ fun HomeScreen( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -462,7 +462,7 @@ fun HomeScreen( onRemoteDisconnectClick = onRemoteDisconnectClick, isRemoteControlActive = isRemoteControlActive, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -499,7 +499,7 @@ private fun HomeFloatingChrome( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -519,7 +519,7 @@ private fun HomeFloatingChrome( activeProfile = activeProfile, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index 151d6f61a4..164b464cca 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -795,7 +795,7 @@ fun LibrariesScreen( onLibrarySelectorClick: () -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -926,7 +926,7 @@ fun LibrariesScreen( onTabSelected = viewModel::selectTab, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -1331,7 +1331,7 @@ private fun LibrariesFloatingChrome( onTabSelected: (LibrariesSubtab) -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onWatchTogetherClick: (() -> Unit)?, + onWatchPartyClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -1377,7 +1377,7 @@ private fun LibrariesFloatingChrome( activeProfile = activeProfile, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onWatchTogetherClick = onWatchTogetherClick, + onWatchPartyClick = onWatchPartyClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt index e0bb2e533b..fea59eb8a2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt @@ -31,6 +31,7 @@ import org.siloserver.silo.model.playback.SubtitleIdentity import org.siloserver.silo.model.playback.SubtitleTransitionEvent import org.siloserver.silo.model.playback.SubtitleTransitionState import org.siloserver.silo.model.playback.UpdateAudioPreference +import org.siloserver.silo.model.playback.UpdateQualityPreference import org.siloserver.silo.model.playback.isLocalDownloadedSubtitle import org.siloserver.silo.model.playback.rebaseDownloadedSubtitleUrl import org.siloserver.silo.model.playback.reduceSubtitleTransition @@ -372,6 +373,11 @@ internal class MobileSubtitleTransactionAdapter( mutate(UpdateAudioPreference(audioTrackIndex), explicit = true) } + /** Replans the same file at [qualityPreference] (a protocol-v3 quality label). */ + fun selectQuality(qualityPreference: String?) { + mutate(UpdateQualityPreference(qualityPreference), explicit = true) + } + /** * Records audio that was switched on the player, with no server replan. * diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt index a17bb80024..1ba1b937f5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt @@ -56,6 +56,7 @@ internal data class MobileVideoSessionAllocation( /** `playback.max_bitrate_kbps`; null is uncapped. */ val maxBitrateKbps: Int? = null, val expectedMetadataOwner: org.siloserver.silo.network.AuthScopeSnapshot? = null, + val allowAlternateVersions: Boolean? = null, ) internal fun interface MobileVideoSessionAllocator { @@ -202,13 +203,31 @@ internal class MobileVideoPlaybackStarter( val maxBitrateKbps = playerSettingsStore.maxBitrateKbpsFlow.first() val configuredAudioLanguage = playerSettingsStore.audioLanguageFlow .first().ifBlank { null } - val version = request.preferredFileId - ?.let { id -> watchDetail.versions.firstOrNull { it.fileId == id } } - ?: selectPlaybackVersion( - watchDetail.versions, - watchDetail.userData?.lastFileId, - preferredQuality, - ) + val room = request.room + // A room plays exactly its file; never a preferred or remembered one. + val version = if (room != null) { + watchDetail.versions.firstOrNull { it.fileId == room.fileId } + ?: return failure( + request.contentId, + "The Watch Party's version of this title isn't available to this profile.", + diagnosticsCode = PlaybackDiagnosticsCode.NO_VERSIONS, + ) + } else { + request.preferredFileId + ?.let { id -> watchDetail.versions.firstOrNull { it.fileId == id } } + ?: selectPlaybackVersion( + watchDetail.versions, + watchDetail.userData?.lastFileId, + preferredQuality, + ) + } + // Room starts keep the file through every replan; renewals keep + // whatever the original start chose. + val allowAlternateVersions = if (room != null) { + false + } else { + request.recoveryStartParams?.allowAlternateVersions + } val persistedTrackSelection = if ( userItemStatePort != null && (request.audioTrackIndex == null || request.subtitleTrackIndex == null) @@ -252,11 +271,11 @@ internal class MobileVideoPlaybackStarter( capabilities = capabilities, ) // Skip-back-on-resume: nudge a genuine resume back a few seconds. - // Suppressed for Start Over / retry (request flag) and Watch Together + // Suppressed for Start Over / retry (request flag) and Watch Party // (roomId — all participants must land on the synced anchor). The same // rewound value drives BOTH the server seek and the player start, so // a transcode cut and the player position never disagree. - val suppressRewind = request.suppressResumeRewind || request.roomId != null || + val suppressRewind = request.suppressResumeRewind || request.roomId != null || room != null || org.siloserver.silo.model.playback.isExplicitStartOver(request.resumePositionOverride) // Per-profile setting (default 7; 0 = off). Read once per start. val rewindSeconds = playerSettingsStore.resumeRewindSecondsFlow.first().toDouble() @@ -267,7 +286,9 @@ internal class MobileVideoPlaybackStarter( rewindSeconds = rewindSeconds, ) } - val startRequestPosition = rewound( + // A room starts at the room position, including an explicit zero; + // personal resume never applies. + val startRequestPosition = room?.positionSeconds ?: rewound( resolvePlaybackStartRequestPosition( overridePosition = request.resumePositionOverride, detailPosition = watchDetail.userData?.positionSeconds, @@ -289,6 +310,7 @@ internal class MobileVideoPlaybackStarter( startPosition = startRequestPosition, maxBitrateKbps = maxBitrateKbps, expectedMetadataOwner = expectedMetadataOwner, + allowAlternateVersions = allowAlternateVersions, ), ) ?: playbackSessionManager.startVideoSessionV3( fileId = version.fileId, @@ -301,6 +323,7 @@ internal class MobileVideoPlaybackStarter( startPosition = startRequestPosition, maxBitrateKbps = maxBitrateKbps, expectedMetadataOwner = expectedMetadataOwner, + allowAlternateVersions = allowAlternateVersions, ) ) { is ApiResult.Success -> r.data @@ -322,6 +345,7 @@ internal class MobileVideoPlaybackStarter( request.contentId, serverTerminalUserMessage(v3Start.message), diagnosticsCode = PlaybackDiagnosticsCode.serverTerminal(v3Start.reason), + terminalReason = v3Start.reason, ) VideoSessionStartV3.ServerUpgradeRequired -> return failure( request.contentId, @@ -340,6 +364,15 @@ internal class MobileVideoPlaybackStarter( val effectiveFileId = resolved.mediaFileId.takeIf { it > 0 } ?: readyV3.plan.effectiveMediaFileId ?: version.fileId + if (room != null && effectiveFileId != room.fileId) { + stopAllocatedButUnpublishedSession(allocatedButUnpublishedSessionId) + allocatedButUnpublishedSessionId = null + return failure( + request.contentId, + "The server offered a different version than the Watch Party's.", + diagnosticsCode = PlaybackDiagnosticsCode.START_REQUEST, + ) + } val effectiveVersion = watchDetail.versions.firstOrNull { it.fileId == effectiveFileId } val resolvedDelivery = resolved.resolvedPlaybackDelivery() val resolvedStreamUrl = resolved.playbackPlan?.stream?.url @@ -368,6 +401,7 @@ internal class MobileVideoPlaybackStarter( qualityPreference = playbackQualityIntent, startPosition = sourceStartPos, clientPlaybackContext = readyV3.clientPlaybackContext, + allowAlternateVersions = allowAlternateVersions, ) val adopted = if (sessionAdopter != null) { sessionAdopter.adopt(startParams, resolved) @@ -500,6 +534,7 @@ internal class MobileVideoPlaybackStarter( message: String, cause: Throwable? = null, diagnosticsCode: PlaybackDiagnosticsCode? = null, + terminalReason: String? = null, ): VideoPlaybackStartResult.Error { // Log the throwable here instead of stashing it on the (unread) result — // the message already carries the human-facing detail. @@ -508,6 +543,7 @@ internal class MobileVideoPlaybackStarter( contentId = contentId, message = message, diagnosticsCode = diagnosticsCode, + terminalReason = terminalReason, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt index c87e54c623..4797886470 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt @@ -29,6 +29,12 @@ class PlaybackRealtimeController( private val client: PlaybackRealtimeClient, private val viewModel: PlayerViewModel, private val scope: CoroutineScope, + /** + * Watch Party only: routes a transport command through the room's + * permission decision and says whether the room took it. Stop is not + * transport here; it ends only this device's engagement. + */ + private val roomTransport: ((PlaybackAction) -> Boolean)? = null, ) { private companion object { const val BACKOFF_START_MS = 2_000L @@ -66,14 +72,20 @@ class PlaybackRealtimeController( private suspend fun handleCommand(cmd: PlaybackRealtimeEvent.Command) { client.sendAck(sessionId, cmd.commandId) val action = decidePlaybackAction(cmd) - // Watch Together is authoritative for transport — reject (don't apply) - // transport commands while in a room so an admin can't desync members. - val gated = action.isTransport && viewModel.remoteTransportSuppressed - val status = if (action is PlaybackAction.Reject || gated) STATUS_REJECTED else STATUS_COMPLETED + // A Watch Party is authoritative for transport: play, pause, and seek + // ask the room like any other input and never move this player alone. + // Stop still leaves (see PlayerScreen), ending only this engagement. + val roomRouted = roomTransport != null && action.isTransport && action !is PlaybackAction.Stop + val gated = roomTransport == null && action.isTransport && viewModel.remoteTransportSuppressed + val status = when { + action is PlaybackAction.Reject || gated -> STATUS_REJECTED + roomRouted -> if (requireNotNull(roomTransport)(action)) STATUS_COMPLETED else STATUS_REJECTED + else -> STATUS_COMPLETED + } // Send the result BEFORE applying: a Stop tears the screen down (which // cancels this coroutine), so applying first could drop the result. client.sendResult(sessionId, cmd.commandId, status) - if (!gated) applyAction(action) + if (!gated && !roomRouted) applyAction(action) } private fun applyAction(action: PlaybackAction) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt index bbca218029..24bac871d4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt @@ -107,9 +107,11 @@ fun PlayerControls( orientationLockSupported: Boolean = true, tabletopMode: Boolean = false, playbackSpeed: Double = 1.0, + // False in a Watch Party, where speed is a session-only 1x. + playbackSpeedEnabled: Boolean = true, nextEpisode: PlayerViewModel.NextEpisodeInfo? = null, brightnessFraction: Float = 0.5f, - // Watch Together guest gate: when false the scrubber + skip buttons are + // Watch Party guest gate: when false the scrubber + skip buttons are // inert and dimmed (seek is host-only, so disabled for all guests). // Defaults true for solo playback. seekEnabled: Boolean = true, @@ -246,6 +248,7 @@ fun PlayerControls( progressBar() TabletopUtilityRow( playbackSpeed = playbackSpeed, + playbackSpeedEnabled = playbackSpeedEnabled, nextEpisode = nextEpisode, compact = !showFullUtilityRow, brightnessFraction = brightnessFraction, @@ -447,6 +450,7 @@ private fun PlayerToolbarTitle( @Composable private fun TabletopUtilityRow( playbackSpeed: Double, + playbackSpeedEnabled: Boolean, nextEpisode: PlayerViewModel.NextEpisodeInfo?, compact: Boolean, brightnessFraction: Float, @@ -514,11 +518,13 @@ private fun TabletopUtilityRow( modifier = Modifier.weight(1f), ) brightnessControl(Modifier.weight(1f)) - TabletopActionButton( - icon = Icons.Default.Speed, - label = playbackSpeedLabel(playbackSpeed), - onClick = { onSetPlaybackSpeed(nextTabletopPlaybackSpeed(playbackSpeed)) }, - ) + if (playbackSpeedEnabled) { + TabletopActionButton( + icon = Icons.Default.Speed, + label = playbackSpeedLabel(playbackSpeed), + onClick = { onSetPlaybackSpeed(nextTabletopPlaybackSpeed(playbackSpeed)) }, + ) + } nextEpisode?.let { episode -> TabletopActionButton( icon = Icons.Default.SkipNext, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerGestureHandler.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerGestureHandler.kt index fbe2eb11dc..912d43bb47 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerGestureHandler.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerGestureHandler.kt @@ -67,7 +67,7 @@ fun PlayerGestureHandler( onToggleControls: () -> Unit, onSkipForward: () -> Unit, onSkipBackward: () -> Unit, - // When false (a Watch Together guest without seek authority), the outer + // When false (a Watch Party guest without seek authority), the outer // double-tap skip bands fall through to a controls toggle rather than // firing a gated no-op skip and flashing a "+10s" badge that lies about a // position that never moves. Mirrors PlayerControls' seekEnabled gating. diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index 47a727006c..a971b0eeb7 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -17,18 +17,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.outlined.Bedtime -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -48,7 +47,6 @@ import org.siloserver.silo.common.ui.LanguageNames import org.siloserver.silo.common.player.SessionState import org.siloserver.silo.common.player.SleepTimerState import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPlaybackState import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.watchtogether.RoomTransportIntent import org.siloserver.silo.watchtogether.roomTransportAuthorized @@ -66,6 +64,15 @@ fun PlayerOverlay( state: PlayerViewModel.PlayerUiState, viewModel: PlayerViewModel, roomSnapshot: RoomSnapshot? = null, + // True for the whole life of a Watch Party player, even while the room + // snapshot is briefly unavailable, so its gates never fall back to solo. + inRoom: Boolean = roomSnapshot != null, + // Playback is held locally (audio focus, sleep timer, background): Play + // stays available so the viewer can resume this device. + roomSuspended: Boolean = false, + // The party's current status line (waiting, catching up, host away, + // reconnecting); null shows the member count. + roomStatus: String? = null, isFastForwardHoldActive: Boolean = false, orientationLockSupported: Boolean = true, alwaysShowControls: Boolean = false, @@ -100,24 +107,22 @@ fun PlayerOverlay( var statsSheetVisible by remember { mutableStateOf(false) } var subtitleSearchVisible by remember { mutableStateOf(false) } var aiTranslateVisible by remember { mutableStateOf(false) } - // Host close-room confirm dialog (Watch Together): the host backing out of - // the player tears the room down for everyone, so confirm first. - var showCloseConfirm by remember { mutableStateOf(false) } - - // Watch Together transport gating. Seek is host-only (the server rejects - // guest seeks regardless of policy), so the scrubber / skip affordance is - // disabled for ALL guests — even one under guest_play_pause, who keeps the - // play/pause affordance. Solo playback (no room) enables both. - val inRoom = roomSnapshot != null + + // Watch Party transport gating. Seek is host-only (the server rejects + // guest seeks regardless of policy), so the scrubber, skips, chapters, and + // the intro pill are disabled for ALL guests — even one under + // guest_play_pause, who keeps play/pause. Solo playback enables both. val seekEnabled = !inRoom || roomTransportAuthorized(roomSnapshot, RoomTransportIntent.Seek) - val playPauseEnabled = !inRoom || roomTransportAuthorized(roomSnapshot, RoomTransportIntent.PlayPause) + val playPauseEnabled = !inRoom || + roomSuspended || + roomTransportAuthorized(roomSnapshot, RoomTransportIntent.PlayPause) val isRoomHost = roomSnapshot?.selfRole == MemberRole.Host + // In a party an intro never skips on its own; members who may seek get + // the Skip pill as a room seek, and nobody else sees it (D8). + val introPillAllowed = !inRoom || seekEnabled - // Back intercept: a host in a room confirms the room close; everyone else - // (guest, or solo playback) backs out immediately. - val handleBack: () -> Unit = { - if (inRoom && isRoomHost) showCloseConfirm = true else onBack() - } + // In a party, Back opens the party panel (PlayerScreen); solo backs out. + val handleBack: () -> Unit = onBack val gatedSeek: (Double) -> Unit = { pos -> if (seekEnabled) onSeek(pos) } // Resolved profile-wide video intervals; read at press time so a change // made in settings applies to the next skip without restarting playback. @@ -158,7 +163,7 @@ fun PlayerOverlay( // behaves normally, because by then no pill is showing and this handler is // disabled. The player has no other BackHandler of its own — sheets live in // their own dialog windows, so an open sheet's Back never reaches here. - BackHandler(enabled = introSkipState.isVisible) { viewModel.onDismissIntroPrompt() } + BackHandler(enabled = introSkipState.isVisible && introPillAllowed) { viewModel.onDismissIntroPrompt() } val sleepTimerState by viewModel.sleepTimerState.collectAsState() val sleepTimerDefault by viewModel.sleepTimerDefaultMinutes.collectAsState() val videoGravity by viewModel.videoGravity.collectAsState() @@ -313,10 +318,9 @@ fun PlayerOverlay( } } - // Watch Together room indicator (top-center). Member count + host-offline - // / waiting-barrier state, and the invite code for the host. Stays - // visible regardless of controls visibility so members always know the - // room status. + // Watch Party status (top-center): the room's status line, or the + // member count, and the code for the host. Stays visible regardless of + // controls visibility so members always know the room status. if (roomSnapshot != null) { Box( modifier = Modifier @@ -338,12 +342,14 @@ fun PlayerOverlay( modifier = Modifier.size(14.dp), ) Spacer(modifier = Modifier.width(6.dp)) - val label = when { - roomSnapshot.playbackState == RoomPlaybackState.Waiting -> "Waiting for members…" - !roomSnapshot.hostConnected -> "${roomSnapshot.memberCount} · host offline" - else -> "${roomSnapshot.memberCount} watching" - } - Text(text = label, color = Color.White, fontSize = 13.sp) + val label = roomStatus ?: "${roomSnapshot.memberCount} watching" + Text( + text = label, + color = Color.White, + fontSize = 13.sp, + maxLines = 2, + modifier = Modifier.widthIn(max = 360.dp), + ) if (isRoomHost && roomSnapshot.code.isNotBlank()) { Spacer(modifier = Modifier.width(8.dp)) Text( @@ -380,11 +386,14 @@ fun PlayerOverlay( preview = state.preview, hasChapters = state.chapters.isNotEmpty(), hasTracks = state.subtitleTracks.isNotEmpty() || state.audioTracks.isNotEmpty(), - hasMultipleVersions = state.versions.size > 1, + // A party plays exactly the room's file: no version picker. + hasMultipleVersions = state.versions.size > 1 && !inRoom, isOrientationLocked = isOrientationLocked, orientationLockSupported = orientationLockSupported, tabletopMode = tabletopMode, playbackSpeed = playbackSpeed, + // A party plays at 1x; the saved speed is never changed from it. + playbackSpeedEnabled = !inRoom, nextEpisode = state.nextEpisode.takeUnless { inRoom }, brightnessFraction = brightnessFraction, seekEnabled = seekEnabled, @@ -421,14 +430,21 @@ fun PlayerOverlay( // Intro skip pill (Hidden / Asking / Skipped). // Shares the bottom-end slot with the Up Next card; intro and credits // never overlap in practice, but the card wins the slot if both could show. - if (!state.showUpNext) { + if (!state.showUpNext && introPillAllowed) { Box( modifier = bottomEndSlotModifier, contentAlignment = Alignment.BottomEnd, ) { IntroAutoSkipBanner( state = introSkipState, - onSelect = viewModel::onSelectIntroPrompt, + onSelect = if (inRoom) { + { + val target = viewModel.selectIntroPromptTarget() + if (target != null) gatedSeek(target) + } + } else { + viewModel::onSelectIntroPrompt + }, totalSeconds = viewModel.introSkipTotalSeconds, countdownRun = introSkipCountdownRun, timerRunning = introSkipTimerRunning, @@ -509,26 +525,6 @@ fun PlayerOverlay( } } - // Host close-room confirmation (Watch Together). Closing tears the room - // down for everyone; the actual close + teardown happens in onBack (the - // RoomSyncController.leave(closeRoom = true) path in PlayerScreen). - if (showCloseConfirm) { - AlertDialog( - onDismissRequest = { showCloseConfirm = false }, - title = { Text("Close room for everyone?") }, - text = { Text("Leaving as host ends the Watch Together room for all members.") }, - confirmButton = { - TextButton(onClick = { - showCloseConfirm = false - onBack() - }) { Text("Close room") } - }, - dismissButton = { - TextButton(onClick = { showCloseConfirm = false }) { Text("Cancel") } - }, - ) - } - // Combined audio + subtitle picker — opened from the top-bar // captions.bubble icon (iOS parity). Replaces the previous side-popup // "SettingsPanel" + individual SubtitleSelector / AudioTrackSelector @@ -603,7 +599,7 @@ fun PlayerOverlay( ) } - if (showQualitySelector) { + if (showQualitySelector && !inRoom) { QualitySelector( versions = state.versions, selectedIndex = state.selectedVersionIndex, @@ -631,6 +627,9 @@ fun PlayerOverlay( onSetHdrEnabled = viewModel::onSetHdrEnabled, dolbyVisionEnabled = viewModel.dolbyVisionEnabled.collectAsState().value, onSetDolbyVisionEnabled = viewModel::onSetDolbyVisionEnabled, + // A party hides speed (session-only 1x) and the version picker. + showPlaybackSpeed = !inRoom, + showQuality = !inRoom, qualityLabel = playerQualityLabel(state.versions, state.selectedVersionIndex), onOpenQuality = { settingsSheetVisible = false @@ -684,8 +683,9 @@ fun PlayerOverlay( isVisible = chaptersSheetVisible, chapters = state.chapters, position = state.position, + // A room seek in a party (a guest is told only the host can seek). onSelect = { idx -> - viewModel.onSeekToChapter(idx)?.let { sec -> viewModel.onSeek(sec) } + viewModel.onSeekToChapter(idx)?.let(onSeek) }, onDismiss = { chaptersSheetVisible = false }, tabletopPaneHeight = tabletopPaneHeight, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt index 4bee4be2f6..47d6811919 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt @@ -56,6 +56,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import androidx.media3.common.Player @@ -105,7 +106,30 @@ import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.model.playback.SubtitleIdentity import org.siloserver.silo.model.playback.executableMedia3ClientTransformations import org.siloserver.silo.model.playback.activeOriginalHttpClaims -import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.android.cast.SiloCastState +import org.siloserver.silo.android.ui.navigation.Route +import org.siloserver.silo.common.player.watchparty.IssuedSeekTracker +import org.siloserver.silo.common.player.watchparty.WatchPartyPlayback +import org.siloserver.silo.common.player.watchparty.watchPartyNoticeText +import org.siloserver.silo.android.ui.screens.watchparty.WatchPartyPanelSheet +import org.siloserver.silo.android.ui.screens.watchparty.formatWatchPartyNames +import org.siloserver.silo.android.ui.screens.watchparty.rememberWatchPartyReconnectNotice +import org.siloserver.silo.android.ui.screens.watchparty.watchPartyLeftBehindNames +import org.siloserver.silo.android.ui.screens.watchparty.watchPartyWaitingNames +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.WatchPartyAvailability +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.watchPartyEligibility +import org.siloserver.silo.watchtogether.watchPartyErrorMessage +import org.siloserver.silo.watchtogether.watchPartyInviteUrl +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.saveable.rememberSaveable import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -113,6 +137,10 @@ import kotlinx.coroutines.launch import org.siloserver.silo.android.cast.SiloCastButton import org.siloserver.silo.android.cast.SiloCastOverlay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import kotlin.math.roundToInt import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -121,6 +149,8 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton +import androidx.compose.foundation.layout.Row import androidx.compose.ui.unit.sp private const val TAG = "PlayerScreen" @@ -132,6 +162,18 @@ private const val TrackSelectionSettleMs = 1_500L internal fun shouldClearPlaybackOnControllerDispose(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations +/** A Watch Party player never casts: it sees no Cast session at all. */ +private val NoCastInWatchParty = SiloCastState() + +/** How long a Watch Party player waits for its room before giving up. */ +private const val ROOM_PRESENCE_TIMEOUT_MS = 5_000L + +/** How long a one-time room event ("The party kept playing") stays on screen. */ +private const val ROOM_EVENT_NOTICE_MS = 4_000L + +/** Room position samples: fresh enough that drift decisions (0.35 s deadband) are not skewed. */ +private const val ROOM_ENGINE_SAMPLE_MS = 100L + @Composable private fun PlayerClockScope( viewModel: PlayerViewModel, @@ -203,9 +245,9 @@ fun PlayerScreen( initialAudioTrackIndex: Int? = null, initialSubtitleTrackIndex: Int? = null, resumePositionOverride: Double? = null, - // Watch Together room id, present when this player was launched into a - // synchronized room. When set, a RoomSyncController binds this player to the - // room (clock sync, transport mirroring, gating, room_closed exit). + // Watch Party room id, present when this player was launched into a + // party. When set, a WatchPartyPlayback binds this player to the room and + // the room's playback context, not the route, decides what plays. roomId: String? = null, navController: NavHostController, viewModel: PlayerViewModel = koinViewModel(), @@ -296,7 +338,11 @@ fun PlayerScreen( // "casting to " overlay takes over; on disconnect, local playback // resumes at the remote position (Fix 6). val castManager: org.siloserver.silo.android.cast.SiloCastSessionManager = koinInject() - val castState by castManager.castState.collectAsState() + val inRoom = !roomId.isNullOrBlank() + val liveCastState by castManager.castState.collectAsState() + // Cast stays out of a Watch Party (D7): no button, no pause or resume on + // connect, no takeover overlay. + val castState = if (inRoom) NoCastInWatchParty else liveCastState val castScope = rememberCoroutineScope() var wasCasting by remember { mutableStateOf(false) } val tabletopPaneLayout = remember(tabletopPosture, playerRootBounds, density.density) { @@ -313,19 +359,21 @@ fun PlayerScreen( val useTabletopPlayerLayout = tabletopPaneLayout != null && !isInPictureInPictureMode && !castState.isConnected - // Watch Together binding. Built once per roomId; null for solo playback. - // The process RoomSession owns the WS; this controller owns only the - // screen's RoomSyncEngine and requests durable teardown on explicit leave. - val watchTogetherRepository: org.siloserver.silo.repository.WatchTogetherRepository = koinInject() - val roomSession: org.siloserver.silo.watchtogether.RoomSession = koinInject() + // Watch Party binding. Built once per roomId; null for solo playback. The + // process-wide RoomSession owns the room socket; this screen owns only the + // playback binding, and disposing it never leaves the party. + val watchTogetherRepository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val watchPartyAvailability: WatchPartyAvailabilityRepository = koinInject() val roomScope = rememberCoroutineScope() - val roomController = remember(roomId) { + val watchParty = remember(roomId) { roomId?.takeIf { it.isNotBlank() }?.let { id -> - RoomSyncController( + WatchPartyPlayback( roomId = id, repository = watchTogetherRepository, roomSession = roomSession, - viewModel = viewModel, + availability = watchPartyAvailability, + player = MobileRoomPlayerPort(viewModel), scope = roomScope, ) } @@ -341,20 +389,207 @@ fun PlayerScreen( subtitle = subtitle, ) } - DisposableEffect(roomController) { - roomController?.start() + DisposableEffect(watchParty) { + watchParty?.start() onDispose { - // Cancel only this replaceable controller's collectors. The - // application RoomSession persists until explicit leave. - roomController?.dispose() + // Stops only this screen's binding. The application RoomSession + // keeps the party until an explicit leave. + watchParty?.dispose() + } + } + val roomSnapshot by watchParty?.room.collectOrDefault(null) + val roomCatchingUp by watchParty?.catchingUp.collectOrDefault(false) + val roomOfferLowerQuality by watchParty?.offerLowerQuality.collectOrDefault(false) + val roomSuspended by viewModel.roomSuspended.collectAsState() + + // Leaves the player the way Back does. With nothing behind the player + // (launcher, deep link, or notification open) popBackStack can't land + // anywhere and leaves a blank NavHost, so finish cleanly instead. Runs once. + fun exitPlayer() { + if (exitRequested) return + exitRequested = true + viewModel.onExit() + if (!navController.popBackStack()) activity?.finish() + } + + // Replaces the player with the Watch Party hub: after process death (the + // room token was memory-only, so nothing is re-adopted; the hub offers + // Rejoin) and when the party ended (the hub explains why). + fun replacePlayerWithHub() { + if (exitRequested) return + exitRequested = true + viewModel.onExit() + if (!navController.popBackStack(Route.WatchPartyHub.ROUTE, inclusive = false)) { + navController.navigate(Route.WatchPartyHub().route) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + } + } + + // Every play/pause input in a party. While playback is held locally + // (audio focus, sleep timer, background) and the room plays, Play resumes + // this device alone and the binding re-syncs it; no room request is sent. + fun requestRoomPlayPause(party: WatchPartyPlayback, pause: Boolean) { + if (!pause && viewModel.roomSuspended.value) { + val snapshot = party.room.value + val roomPlaying = snapshot != null && + !snapshot.isPaused && + snapshot.playbackState == RoomPlaybackState.Playing + viewModel.endRoomSuspensions(resumeLocally = roomPlaying) + if (snapshot == null || !snapshot.isPaused) return + } + party.requestPlayPause(pause) + } + + // The room, not the route, decides what plays: each new playback epoch + // (Start, Select, Promote, source fallback) loads the room's exact file, + // paused at the room position. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + party.playbackContext.collect { playback -> + if (playback != null && !exitRequested) viewModel.startRoomPlayback(playback) + } + } + + // Host Stop returns everyone to the retained lobby. An engagement that + // ends or moves elsewhere (another room, sign-out) closes the player. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + val id = roomId ?: return@LaunchedEffect + // A restored party route with no live membership (process death): + // open the hub, which offers Rejoin, instead of re-adopting silently. + if (party.room.value == null && party.ended.value == null) { + replacePlayerWithHub() + return@LaunchedEffect + } + val present = withTimeoutOrNull(ROOM_PRESENCE_TIMEOUT_MS) { + party.room.first { it?.roomId == id } + } + if (present == null) { + if (!exitRequested && party.ended.value == null) { + Toast.makeText(context, "This Watch Party isn't available anymore.", Toast.LENGTH_SHORT).show() + exitPlayer() + } + return@LaunchedEffect + } + party.room.collect { snapshot -> + if (exitRequested) return@collect + when { + // An ended party is explained by the effect below. + snapshot == null || snapshot.roomId != id -> if (party.ended.value == null) exitPlayer() + snapshot.phase == RoomPhase.Lobby -> { + if (snapshot.selfRole != MemberRole.Host) { + Toast.makeText(context, "The host stopped playback", Toast.LENGTH_SHORT).show() + } + exitRequested = true + viewModel.onExit() + navController.navigate(Route.WatchPartyLobby(id).route) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + } + } + } + } + + // The party ended (the host ended it or left, or this profile joined on + // another device): leave the player for the hub, which says why and + // offers Rejoin when the party itself goes on. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + party.ended.filterNotNull().first() + replacePlayerWithHub() + } + + // In-playback status the room implies: who it waits for, whether this + // viewer is catching up, whether the host is away, and a reconnect that + // has lasted two seconds. Events that happen once (the party kept + // playing, it went on without someone) show briefly above it. + val roomConnection by watchTogetherRepository.connectionState.collectAsState() + val showRoomReconnect = rememberWatchPartyReconnectNotice( + disconnected = inRoom && roomSnapshot != null && !roomConnection.writable, + since = roomConnection.disconnectedAtMs, + ) + val roomStatus: String? = roomSnapshot?.let { room -> + val waitingFor = watchPartyWaitingNames(room) + when { + showRoomReconnect -> if (roomConnection.disconnectedAtMs == null) { + "Connecting to the party…" + } else { + "Reconnecting to the party…" + } + !room.hostConnected && room.selfRole != MemberRole.Host -> + "The host lost connection. The party ends in two minutes unless the host returns." + roomCatchingUp -> "Catching up to the party" + room.playbackState == RoomPlaybackState.Waiting -> if (waitingFor.isEmpty()) { + "Syncing playback" + } else { + "Syncing playback · Waiting for ${formatWatchPartyNames(waitingFor)}" + } + else -> null } } - val roomSnapshot by produceRoomSnapshotState(roomController) - val roomClosedReason by produceRoomClosedState(roomController) + var roomEventNotice by remember { mutableStateOf?>(null) } + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + var previous = party.room.value + party.room.collect { next -> + val leftBehind = watchPartyLeftBehindNames(previous, next) + if (leftBehind.isNotEmpty()) { + roomEventNotice = SystemClock.elapsedRealtime() to + "Continuing without ${formatWatchPartyNames(leftBehind)}" + } + previous = next + } + } + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + var wasCatchingUp = party.catchingUp.value + party.catchingUp.collect { catchingUp -> + if (catchingUp && !wasCatchingUp) { + roomEventNotice = SystemClock.elapsedRealtime() to "The party kept playing" + } + wasCatchingUp = catchingUp + } + } + LaunchedEffect(roomEventNotice?.first) { + val shown = roomEventNotice ?: return@LaunchedEffect + delay(ROOM_EVENT_NOTICE_MS) + if (roomEventNotice?.first == shown.first) roomEventNotice = null + } + + // Back in a party opens the party panel over the retained player; the + // panel's own actions decide whether the player goes away. + var partyPanelVisible by rememberSaveable { mutableStateOf(false) } + BackHandler(enabled = inRoom && !partyPanelVisible) { partyPanelVisible = true } + + // Denied, reconnecting, and undelivered inputs each show, repeats included. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + party.notices.collect { notice -> + Toast.makeText(context, watchPartyNoticeText(notice), Toast.LENGTH_SHORT).show() + } + } + + // A server refusal of the room's file goes to the room, which may move + // everyone to another source; the context then changes and playback + // reloads. Otherwise the error stays: this device never picks a file. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + viewModel.roomRefusal.filterNotNull().collect { refusal -> + viewModel.consumeRoomRefusal(refusal) + party.reportRefusal(refusal.context, refusal.fileId, refusal.reason) + } + } + + // A quality change restarts the binding's reload pacing and its offer. + LaunchedEffect(watchParty) { + val party = watchParty ?: return@LaunchedEffect + viewModel.roomQualityChanges.collect { party.binding.onQualityChanged() } + } // Per-session playback control socket (admin remote control). Bound for the // lifetime of a sessionId; the loop reconnects on its own and never - // interrupts playback. Separate from the Watch Together socket. + // interrupts playback. Separate from the Watch Party socket. val playbackRealtimeClient: org.siloserver.silo.network.PlaybackRealtimeClient = koinInject() LaunchedEffect(uiState.sessionId) { val id = uiState.sessionId ?: return@LaunchedEffect @@ -363,45 +598,32 @@ fun PlayerScreen( client = playbackRealtimeClient, viewModel = viewModel, scope = this, // cancelled when sessionId changes / screen leaves + // In a party, remote transport asks the room like any other input. + roomTransport = watchParty?.let { party -> + { action -> routeRemoteTransportToRoom(action, party, viewModel.uiState.value.isPaused) } + }, ).start() } - // Tell the VM about WT membership so the control socket gates transport - // (the room is authoritative); the VM's start request always has roomId=null. + // Mark party membership for the ViewModel's gates before the room's + // playback context arrives. LaunchedEffect(roomId) { - viewModel.setInWatchTogetherRoom(!roomId.isNullOrBlank()) + viewModel.setInWatchTogetherRoom(inRoom) } - // A remote "stop"/"terminate" command tears the screen down like a back press. + // A remote "stop"/"terminate" command tears the screen down like a back + // press. In a party it ends only this device's engagement; the room goes + // on (a host's leave starts the two-minute grace). LaunchedEffect(Unit) { viewModel.remoteStopRequests.collect { - exitRequested = true - roomController?.leave(closeRoom = false) - viewModel.onExit() - // Nothing behind the player (launcher/deep-link/notification open) → - // popBackStack can't land anywhere and leaves a blank NavHost, then - // system back exits from a grey screen. Finish cleanly instead. - if (!navController.popBackStack()) activity?.finish() - } - } - - // room_closed (or server error) → leave the player back to detail. - LaunchedEffect(roomClosedReason) { - if (roomClosedReason != null && roomController != null) { - exitRequested = true - roomController.leave(closeRoom = false) - viewModel.onExit() - // Nothing behind the player (launcher/deep-link/notification open) → - // popBackStack can't land anywhere and leaves a blank NavHost, then - // system back exits from a grey screen. Finish cleanly instead. - if (!navController.popBackStack()) activity?.finish() + watchParty?.leave() + exitPlayer() } } - // Surface transient Watch Together server rejections (e.g. a guest seek the - // server refuses) as a brief Toast. These flow on the repo errors stream and - // do NOT eject the user (room_closed is the only terminal signal). Only + // Surface transient Watch Party server rejections as a brief Toast. These + // flow on the repository's errors stream and never eject the user. Only // collected while bound to a room. - LaunchedEffect(roomController) { - if (roomController != null) { + LaunchedEffect(watchParty) { + if (watchParty != null) { watchTogetherRepository.errors.collect { message -> if (message.isNotBlank()) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() @@ -417,6 +639,18 @@ fun PlayerScreen( val playWhenReadyReconciliationGate = remember(mediaController, roomId) { PlayWhenReadyReconciliationGate() } + // Seeks this screen sends to Media3, so a party can tell a notification, + // headset, or Assistant seek from its own. + val issuedSeeks = remember { IssuedSeekTracker() } + val sampleRoomEngine: (Player) -> Unit = { player -> + viewModel.onRoomEngineSample( + positionMs = player.currentPosition, + bufferedPositionMs = player.bufferedPosition, + playWhenReady = player.playWhenReady, + isPlaying = player.isPlaying, + playbackState = player.playbackState, + ) + } // service. The video PlayerView binds its SurfaceView directly to this (NOT the // MediaController) so the engine receives proper surface-lifecycle callbacks // (surfaceCreated/Changed/Destroyed) and recovers across seek/recreate/rotation @@ -587,9 +821,14 @@ fun PlayerScreen( // with hold-to-2x as a transient override that never writes the setting. // Re-runs whenever the controller binds, the setting changes, or the user // presses/releases the phone player's fast-forward hold gesture. - LaunchedEffect(mediaController, preferredPlaybackSpeed, fastForwardHoldActive) { + // A party plays at 1x apart from the binding's temporary catch-up rate: the + // saved speed and hold-to-2x do not apply, and nothing is saved. + val roomCorrectionRate by viewModel.roomCorrectionRate.collectAsState() + LaunchedEffect(mediaController, preferredPlaybackSpeed, fastForwardHoldActive, inRoom, roomCorrectionRate) { val controller = mediaController ?: return@LaunchedEffect - if (fastForwardHoldActive) { + if (inRoom) { + controller.setPlaybackSpeed((roomCorrectionRate ?: 1.0).toFloat()) + } else if (fastForwardHoldActive) { controller.setPlaybackSpeed(2.0f) } else { controller.setPlaybackSpeed(preferredPlaybackSpeed.toFloat()) @@ -598,6 +837,9 @@ fun PlayerScreen( // Load content on first composition LaunchedEffect(contentId, initialFileId, initialQuality, initialAudioTrackIndex, initialSubtitleTrackIndex, resumePositionOverride) { + // A party starts from the room's playback context (above), never from + // the route, so nothing plays solo first. + if (inRoom) return@LaunchedEffect if (!viewModel.claimInitialRouteLoad()) return@LaunchedEffect viewModel.loadContent( libraryId = libraryId, @@ -608,8 +850,6 @@ fun PlayerScreen( initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, routeResumePositionSeconds = resumePositionOverride, - // Watch Together's synced anchor must land exactly — don't nudge it back. - suppressResumeRewind = !roomId.isNullOrBlank(), ) } @@ -941,23 +1181,51 @@ fun PlayerScreen( val provenance = playWhenReadyReconciliationGate .onPlayWhenReadyChanged(playWhenReady, reason) provenance.followUpProgrammaticValue?.let { controller.playWhenReady = it } - if (!provenance.shouldReconcile) return - roomController - ?.onExternalPlayWhenReadyChanged(playWhenReady) - ?.let { authoritative -> - if (controller.playWhenReady != authoritative) { - if ( - playWhenReadyReconciliationGate - .requestProgrammaticChange(authoritative) - ) { - controller.playWhenReady = authoritative - } - } - } + val party = watchParty ?: return + // Audio focus loss and a noisy route pause this device + // only. The room hears nothing until the viewer resumes. + if (!playWhenReady && + (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS || + reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY) + ) { + sampleRoomEngine(controller) + viewModel.beginRoomSuspension(PlayerViewModel.RoomSuspensionReason.AudioFocus) + return + } + if (!provenance.shouldReconcile) { + sampleRoomEngine(controller) + return + } + // A notification, headset, or Assistant play/pause: ask + // the room, then restore the room's state locally until + // its command arrives. + requestRoomPlayPause(party, pause = !playWhenReady) + val authoritative = !viewModel.uiState.value.isPaused + if (controller.playWhenReady != authoritative && + playWhenReadyReconciliationGate.requestProgrammaticChange(authoritative) + ) { + controller.playWhenReady = authoritative + } + // Sample only after the restore (the controller masks it + // at once), so a report never carries the outside change. + sampleRoomEngine(controller) + } + + override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) { + if (watchParty == null) return + val reason = PlayerViewModel.RoomSuspensionReason.TransientAudioFocus + if (playbackSuppressionReason == + Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS + ) { + viewModel.beginRoomSuspension(reason) + } else { + viewModel.endRoomSuspension(reason) + } } override fun onIsPlayingChanged(isPlaying: Boolean) { viewModel.onPlayingChanged(isPlaying) + if (watchParty != null) sampleRoomEngine(controller) val live = viewModel.uiState.value val key = live.sessionId?.let { sessionId -> "$sessionId:${live.streamUrl}:${live.playbackPlan?.planId.orEmpty()}:" + @@ -978,6 +1246,7 @@ fun PlayerScreen( override fun onPlaybackStateChanged(playbackState: Int) { viewModel.onBufferingChanged(playbackState == Player.STATE_BUFFERING) + if (watchParty != null) sampleRoomEngine(controller) if (playbackState == Player.STATE_ENDED) { viewModel.onPlayingChanged(false) // F2 fallback: surface (or upgrade) the Up Next card if @@ -991,6 +1260,24 @@ fun PlayerScreen( newPosition: Player.PositionInfo, reason: Int, ) { + // Undo outside room seeks before publishing the engine + // position. applyRoomSeek marks the restore pending + // synchronously, so a host cannot report the unaccepted + // target while the Media3 seek travels back to the service. + val party = watchParty + if (party != null && + reason == Player.DISCONTINUITY_REASON_SEEK && + issuedSeeks.isExternal(newPosition.positionMs) && + !viewModel.isRoomMediaMounting() + ) { + party.onExternalSeek( + fromSeconds = viewModel.sourceSecondsForPlayerMs(oldPosition.positionMs), + toSeconds = viewModel.sourceSecondsForPlayerMs(newPosition.positionMs), + ) { restoreSeconds -> viewModel.applyRoomSeek(restoreSeconds) } + } + // Publish the actual engine position before the display + // update can clear seekPending for a landed restore. + if (party != null) sampleRoomEngine(controller) // Media3 emits this for completed seeks even while paused, // when onIsPlayingChanged and playback-state callbacks can // remain silent. Publish the settled engine position now. @@ -1148,10 +1435,10 @@ fun PlayerScreen( renderedOutputBufferCount = decoderCounters?.renderedOutputBufferCount, )) { PostResumeVideoStallDetector.Signal.SeekBack -> { - controller.seekTo( - (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) - .coerceAtLeast(0L), - ) + val target = (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) + .coerceAtLeast(0L) + issuedSeeks.note(target) + controller.seekTo(target) viewModel.onRuntimeCorrection( "runtime_correction_applied", "client_post_resume_video_recovery_v1", @@ -1163,6 +1450,7 @@ fun PlayerScreen( val resume = controller.playWhenReady controller.stop() controller.prepare() + issuedSeeks.note(position) controller.seekTo(position) if (resume) controller.play() viewModel.onRuntimeCorrection( @@ -1214,16 +1502,62 @@ fun PlayerScreen( LaunchedEffect(mediaController) { val controller = mediaController ?: return@LaunchedEffect viewModel.seekRequests.collect { posSec -> - controller.seekTo((posSec * 1000).toLong()) + val targetMs = (posSec * 1000).toLong() + issuedSeeks.note(targetMs) + controller.seekTo(targetMs) } } - // Room-driven corrective seeks remain on a separate channel so sync - // corrections can stay unconditional and easy to reason about. + // Room and remote seeks remain on a separate channel so they stay + // unconditional and easy to reason about. LaunchedEffect(mediaController) { val controller = mediaController ?: return@LaunchedEffect viewModel.immediateSeeks.collect { posSec -> - controller.seekTo((posSec * 1000).toLong()) + val targetMs = (posSec * 1000).toLong() + issuedSeeks.note(targetMs) + controller.seekTo(targetMs) + } + } + + // Party only: sample the engine often enough that the room sees where + // the player really is. The display ticker above is too coarse for the + // binding's 0.35 s drift decisions and stays as it is. + LaunchedEffect(mediaController, lifecycleOwner, watchParty) { + val controller = mediaController ?: return@LaunchedEffect + if (watchParty == null) return@LaunchedEffect + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + if (controller.mediaItemCount > 0) sampleRoomEngine(controller) + delay(ROOM_ENGINE_SAMPLE_MS) + } + } + } + + // Party only: leaving the app is a local suspension. Playback stops, the + // room hears nothing, and returning re-syncs to the room. + DisposableEffect(lifecycleOwner, watchParty, mediaController, playWhenReadyReconciliationGate) { + if (watchParty == null) { + onDispose { } + } else { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> if (activity?.isChangingConfigurations != true) { + viewModel.beginRoomSuspension(PlayerViewModel.RoomSuspensionReason.Background) + mediaController?.let { controller -> + if (controller.playWhenReady && + playWhenReadyReconciliationGate.requestProgrammaticChange(false) + ) { + controller.playWhenReady = false + } + } + } + Lifecycle.Event.ON_START -> + viewModel.endRoomSuspension(PlayerViewModel.RoomSuspensionReason.Background) + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } } @@ -1286,7 +1620,8 @@ fun PlayerScreen( activity = activity, surface = SiloPictureInPictureSurface.Mobile, state = SiloPictureInPicturePlaybackState( - enabled = pictureInPictureEnabled, + // A party never enters picture-in-picture (D7). + enabled = pictureInPictureEnabled && !inRoom, videoActive = uiState.streamUrl != null && uiState.error == null, isPlaying = uiState.isPlaying && !uiState.isPaused, videoWidth = pictureInPictureVideoWidth, @@ -1548,12 +1883,7 @@ fun PlayerScreen( onSelectSubtitle = { castManager.selectSubtitleTrack(it) }, onStopCasting = { castManager.disconnect() }, onSeek = { castManager.seekTo(it) }, - onBack = { - exitRequested = true - roomController?.leave(closeRoom = roomSnapshot?.isHost == true) - viewModel.onExit() - if (!navController.popBackStack()) activity?.finish() - }, + onBack = { exitPlayer() }, ) } @@ -1571,6 +1901,9 @@ fun PlayerScreen( state = uiState.withPlaybackClock(clock), viewModel = viewModel, roomSnapshot = roomSnapshot, + inRoom = inRoom, + roomSuspended = roomSuspended, + roomStatus = roomStatus, orientationLockSupported = orientationLockSupported && activeTabletopPaneLayout == null, alwaysShowControls = activeTabletopPaneLayout != null, @@ -1591,45 +1924,41 @@ fun PlayerScreen( }, showBufferingIndicator = activeTabletopPaneLayout == null, castSlot = { - SiloCastButton( - castManager = castManager, - onStartCast = { - castScope.launch { - val spec = viewModel.prepareGoogleCastMedia() - if (spec != null) castManager.prepareMedia(spec) - } - }, - ) + // No Cast in a party (D7). + if (!inRoom) { + SiloCastButton( + castManager = castManager, + onStartCast = { + castScope.launch { + val spec = viewModel.prepareGoogleCastMedia() + if (spec != null) castManager.prepareMedia(spec) + } + }, + ) + } }, isFastForwardHoldActive = fastForwardHoldActive, onBack = { - // In-room exit: leave the room (host close confirm is handled - // by the overlay before this fires). The controller resets the - // repo + engine; solo playback just pops. - exitRequested = true - roomController?.leave(closeRoom = roomSnapshot?.isHost == true) - viewModel.onExit() - // Nothing behind the player (launcher/deep-link/notification - // open) → popBackStack can't land anywhere and leaves a blank - // NavHost, then system back exits from a grey screen. Finish - // cleanly instead. - if (!navController.popBackStack()) activity?.finish() + // In a party, Back opens the party panel over the + // retained player instead of leaving. + if (inRoom) partyPanelVisible = true else exitPlayer() }, onPlayPause = { - // In a room, route through transport_request (gated to - // controllers); solo playback toggles locally. - if (roomController != null) roomController.onUserPlayPause() - else viewModel.onPlayPause() - }, - onSeek = { position -> - if (roomController != null) { - // Guest seeks are no-ops in the controller; host seeks - // round-trip through the room and re-apply via a command. - roomController.onUserSeek(position) + // In a party every play/pause asks the room; the player + // changes when the room's command arrives. + val party = watchParty + if (party != null) { + requestRoomPlayPause(party, pause = !viewModel.uiState.value.isPaused) } else { - viewModel.onSeek(position) + viewModel.onPlayPause() } }, + onSeek = { position -> + // Host seeks round-trip through the room and land with + // its command; guests' seek controls are disabled. + val party = watchParty + if (party != null) party.requestSeek(position) else viewModel.onSeek(position) + }, onToggleControls = { viewModel.onToggleControls() }, onFastForwardHold = { active -> fastForwardHoldActive = active }, onSelectSubtitle = { viewModel.onSelectSubtitle(it) }, @@ -1641,6 +1970,68 @@ fun PlayerScreen( } } + // Party only: after repeated stalls, offer one step down the same + // file's quality ladder. Hidden when no lower rung exists. + var lowerQualityDeclined by remember { mutableStateOf(false) } + LaunchedEffect(roomOfferLowerQuality) { + if (!roomOfferLowerQuality) lowerQualityDeclined = false + } + if (roomOfferLowerQuality && + !lowerQualityDeclined && + uiState.versionSwitchMessage == null && + viewModel.lowerRoomQualityLabel() != null + ) { + Surface( + color = Color.Black.copy(alpha = 0.85f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 16.dp) + .widthIn(max = 420.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 14.dp, end = 4.dp), + ) { + Text( + text = "Playback keeps stopping. Lower the quality?", + color = Color.White, + fontSize = 13.sp, + modifier = Modifier.weight(1f, fill = false), + ) + TextButton(onClick = { + lowerQualityDeclined = true + viewModel.lowerRoomQuality() + }) { Text("Lower") } + TextButton(onClick = { lowerQualityDeclined = true }) { Text("Not now") } + } + } + } + + // Party only: a one-time room event (the party kept playing, or went + // on without someone), shown briefly below the room status. + roomEventNotice?.let { (_, text) -> + if (!isInPictureInPictureMode) { + Surface( + color = Color.Black.copy(alpha = 0.78f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 64.dp, start = 16.dp, end = 16.dp) + .widthIn(max = 420.dp), + ) { + Text( + text = text, + color = Color.White, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + ) + } + } + } + // Non-fatal quality/version-switch message: a dismissable pill over the // still-playing video, not a fatal full-screen error. uiState.versionSwitchMessage?.let { message -> @@ -1664,27 +2055,65 @@ fun PlayerScreen( } } } + + // The party panel (D2) over the retained player. Dismissing it never + // touches playback; only Leave and End take the player away. + val party = watchParty + if (party != null && partyPanelVisible) { + val availability by watchPartyAvailability.availability.collectAsState() + val serverRegistry: ServerRegistry = koinInject() + val activeServer by serverRegistry.activeEntry.collectAsState() + // Actions are not hidden while another request finishes; the room + // owner runs them one at a time. + val eligibility = watchPartyEligibility( + room = roomSnapshot, + features = (availability as? WatchPartyAvailability.Available)?.features, + busy = false, + personalVotesKnown = true, + ) + WatchPartyPanelSheet( + room = roomSnapshot, + eligibility = eligibility, + inviteUrl = roomSnapshot + ?.takeIf { it.selfRole == MemberRole.Host } + ?.let { room -> activeServer?.url?.let { watchPartyInviteUrl(it, room.invitePath) } }, + onReturnToLobby = { + partyPanelVisible = false + roomScope.launch { + val result = watchTogetherRepository.stopPlayback() + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't return everyone to the lobby."), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + onEndForEveryone = { + partyPanelVisible = false + party.endForEveryone() + exitPlayer() + }, + onLeave = { + partyPanelVisible = false + party.leave() + exitPlayer() + }, + onDismiss = { partyPanelVisible = false }, + ) + } } /** - * Collects the room snapshot from [controller] when present, else holds null. - * A nullable StateFlow can't be `collectAsState`d directly, so this bridges to - * a stable [State] for both the in-room and solo cases. + * Collects a party-only flow, or holds [default] for solo playback. A nullable + * StateFlow can't be `collectAsState`d directly, so this bridges both cases to + * a stable [State]. */ @Composable -private fun produceRoomSnapshotState( - controller: RoomSyncController?, -): State { - val soloRoom = remember { MutableStateFlow(null) } - return (controller?.room ?: soloRoom).collectAsState() -} - -@Composable -private fun produceRoomClosedState( - controller: RoomSyncController?, -): State { - val soloClosedReason = remember { MutableStateFlow(null) } - return (controller?.closedReason ?: soloClosedReason).collectAsState() +private fun StateFlow?.collectOrDefault(default: T): State { + val solo = remember { MutableStateFlow(default) } + return (this ?: solo).collectAsState() } private fun PlaybackExecutionPlan?.validatedPassthroughCodecs(): List { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt index a516f9489c..b54277e3db 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt @@ -111,6 +111,10 @@ fun PlayerSettingsSheet( onSetHdrEnabled: (Boolean) -> Unit, dolbyVisionEnabled: Boolean, onSetDolbyVisionEnabled: (Boolean) -> Unit, + // False in a Watch Party: speed is a session-only 1x and the version + // picker is off, since the room plays exactly its own file. + showPlaybackSpeed: Boolean = true, + showQuality: Boolean = true, // Root-list values. The gear now reports the same three things the // toolbar buttons open, so the menu answers "what am I watching with?" // without opening anything. @@ -172,11 +176,13 @@ fun PlayerSettingsSheet( ) { when (route) { SettingsRoute.Root -> { - ValueRow( - label = "Quality", - value = qualityLabel, - onClick = { leaveFor(onOpenQuality) }, - ) + if (showQuality) { + ValueRow( + label = "Quality", + value = qualityLabel, + onClick = { leaveFor(onOpenQuality) }, + ) + } ValueRow( label = "Subtitles", value = subtitleLabel, @@ -195,11 +201,13 @@ fun PlayerSettingsSheet( } SettingsRoute.PlaybackOptions -> { - ValueRow( - label = "Speed", - value = "${formatPlaybackSpeed(playbackSpeed)}×", - onClick = { routeOrdinal = SettingsRoute.Speed.ordinal }, - ) + if (showPlaybackSpeed) { + ValueRow( + label = "Speed", + value = "${formatPlaybackSpeed(playbackSpeed)}×", + onClick = { routeOrdinal = SettingsRoute.Speed.ordinal }, + ) + } ValueRow( label = "Picture size", value = pictureSizeLabel(videoGravity), @@ -252,7 +260,7 @@ fun PlayerSettingsSheet( ) } - SettingsRoute.Speed -> { + SettingsRoute.Speed -> if (showPlaybackSpeed) { listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0).forEach { value -> CheckRow( label = "${formatPlaybackSpeed(value)}×", diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index c1616d6e60..027540a8d2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -121,6 +121,9 @@ import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.SubtitlesRepository import org.siloserver.silo.repository.port.PlaybackWriteScope import org.siloserver.silo.repository.port.TrackSelectionFingerprintUpdate +import org.siloserver.silo.watchtogether.RoomPlayerObservation +import org.siloserver.silo.watchtogether.RoomPlayerState +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -143,6 +146,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -329,6 +333,7 @@ class PlayerViewModel( val initialSubtitleTrackIndex: Int?, val resumePositionOverride: Double?, val suppressResumeRewind: Boolean, + val room: WatchPartyPlaybackContext? = null, ) internal fun currentExternalRouteTarget(): MobilePlayerRouteTarget? = @@ -582,7 +587,7 @@ class PlayerViewModel( /** * Unconditional seek channel for room-driven corrective seeks. The normal * position mirror in PlayerScreen applies a 2.0s deadband (to avoid feedback - * loops between playback-progress updates and user scrubs), but Watch Together + * loops between playback-progress updates and user scrubs), but Watch Party * corrective seeks can be as small as the engine's 0.35s drift threshold and * MUST always reach the player. PlayerScreen collects this and calls * `mediaController.seekTo` with no deadband. See [seekImmediate]. @@ -669,7 +674,7 @@ class PlayerViewModel( /** * Video relative-seek intervals every phone surface uses (buttons, - * double-tap, Watch Together skips): the profile-wide values on a + * double-tap, Watch Party skips): the profile-wide values on a * revision-9 server, otherwise the phone's pre-revision-9 10s/10s. Read at * press time, so a settings change applies to the next skip mid-playback. */ @@ -1019,6 +1024,7 @@ class PlayerViewModel( suppressResumeRewind = true, preserveRouteIntent = true, recoveryStartParams = params, + room = roomRestartContext(renewal.positionSeconds), ) } } @@ -1042,6 +1048,9 @@ class PlayerViewModel( // (which is a *toggle* and would inadvertently resume a paused player). sleepTimer.configure { _uiState.update { it.copy(isPaused = true) } + // In a Watch Party the timer pauses only this device; the room + // keeps playing and hears nothing until the viewer resumes. + if (inRoom) beginRoomSuspension(RoomSuspensionReason.SleepTimer) } viewModelScope.launch { @@ -1104,7 +1113,7 @@ class PlayerViewModel( // 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 + // True for Watch Party (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. suppressResumeRewind: Boolean = false, @@ -1117,7 +1126,32 @@ class PlayerViewModel( libraryId: Int? = browseLibraryId, // Exact capability/context snapshot used only for a 404 renewal. recoveryStartParams: StartParams? = null, + // Watch Party: the room's exact file and position. Defaults to the + // current room context so every in-place restart keeps the room's + // file, never plays a download, and starts paused. + room: WatchPartyPlaybackContext? = currentRoomContext, ) { + if (room != null && + (contentId != room.contentId || preferredFileId != room.fileId || libraryId != room.libraryId) + ) { + // A room plays exactly its own content and file; no caller can override that. + loadContent( + contentId = room.contentId, + preferredFileId = room.fileId, + preferredQuality = preferredQuality, + initialAudioTrackIndex = initialAudioTrackIndex, + initialSubtitleTrackIndex = initialSubtitleTrackIndex, + resumePositionOverride = resumePositionOverride, + routeResumePositionSeconds = routeResumePositionSeconds, + suppressResumeRewind = suppressResumeRewind, + force = force, + preserveRouteIntent = preserveRouteIntent, + libraryId = room.libraryId, + recoveryStartParams = recoveryStartParams, + room = room, + ) + return + } browseLibraryId = libraryId aiPlaybackGeneration++ val normalizedPreferredQuality = VideoPlayerRouteArgs.normalizeQuality(preferredQuality) @@ -1153,7 +1187,9 @@ class PlayerViewModel( initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, suppressResumeRewind = suppressResumeRewind, + room = room, ) + if (room != null) beginRoomLoad(room) // A fresh load resets any in-flight intro countdown / cancellation memory. introAutoSkipController.reset() // New item: re-arm the once-per-episode auto-advance trigger. (The @@ -1187,8 +1223,9 @@ class PlayerViewModel( // this contentId AND its bytes are still on disk, hand the // player a file:// URI without touching the server at all. // Title + duration are best-effort — we attempt the watch - // detail fetch but tolerate failure. - val localPlaybackStarted = tryLocalPlayback( + // detail fetch but tolerate failure. A Watch Party always + // streams the room's file, so it never plays a download. + val localPlaybackStarted = room == null && tryLocalPlayback( contentId = contentId, preferredFileId = preferredFileId, resumePositionOverride = resumePositionOverride, @@ -1215,13 +1252,14 @@ class PlayerViewModel( contentId = contentId, preferredFileId = preferredFileId, preferredQualityOverride = effectivePreferredQuality, - roomId = null, + roomId = room?.roomId, resumePositionOverride = resumePositionOverride, audioTrackIndex = initialAudioTrackIndex, subtitleTrackIndex = initialSubtitleTrackIndex, suppressResumeRewind = suppressResumeRewind, force = force, recoveryStartParams = recoveryStartParams, + room = room, ), )) { is VideoPlayerUiState.Ready -> { @@ -1247,6 +1285,7 @@ class PlayerViewModel( isSessionRenewal = recoveryStartParams != null, loadOwner = loadOwner, watchOwner = readyWatchOwner, + room = room, ) unpublishedReadySessionId = null } @@ -1261,6 +1300,11 @@ class PlayerViewModel( isNextUpTransitioning = false, ) } + // The room decides whether another file is tried; + // this device never picks one itself. + if (room != null) { + publishRoomRefusal(room, room.fileId, playbackState.terminalReason) + } } } is VideoPlayerUiState.ServerUnreachable -> { @@ -1302,6 +1346,7 @@ class PlayerViewModel( loadJob = newLoadJob newLoadJob.invokeOnCompletion { if (loadJob === newLoadJob) loadJob = null + refreshRoomObservation() } } @@ -1336,9 +1381,22 @@ class PlayerViewModel( suppressResumeRewind = args.suppressResumeRewind, force = force, preserveRouteIntent = true, + room = args.room?.let(::roomRetryContext), ) } + /** + * The room context for a retry of [original]. Within the same epoch it + * resumes where this player was, like an in-place restart; before + * anything played it keeps the room position. A retry never carries an + * older epoch's position. + */ + private fun roomRetryContext(original: WatchPartyPlaybackContext): WatchPartyPlaybackContext { + val current = currentRoomContext ?: return original + if (current.roomId != original.roomId || current.selectionRevision != original.selectionRevision) return original + return roomRestartContext(_uiState.value.position.takeIf { it > 0.0 }) ?: original + } + private fun ownsLoad(owner: MobilePlayerLoadOwner): Boolean = loadOwners.owns(owner) private suspend fun stopStaleReadySession(sessionId: String?) { @@ -1360,6 +1418,7 @@ class PlayerViewModel( isSessionRenewal: Boolean, loadOwner: MobilePlayerLoadOwner, watchOwner: org.siloserver.silo.network.AuthScopeSnapshot?, + room: WatchPartyPlaybackContext? = null, ) { val watchMetadata = ReadyWatchMetadata( catalogRepository, watchOwner, playbackState.contentId, playbackState.serverUrl, browseLibraryId, @@ -1528,8 +1587,9 @@ class PlayerViewModel( // not declare one; neither catalog nor Media3 may substitute it. duration = playbackState.durationSeconds?.takeIf { it > 0.0 } ?: 0.0, serverDuration = playbackState.durationSeconds?.takeIf { it > 0.0 } ?: 0.0, - isPlaying = true, - isPaused = false, + // A Watch Party prepares paused; only an accepted room command plays. + isPlaying = room == null, + isPaused = room != null, subtitleTracks = mountedSubtitles, audioTracks = version?.audioTracks ?: emptyList(), selectedAudioIndex = selectedAudioOrdinal, @@ -1615,6 +1675,10 @@ class PlayerViewModel( } private fun startIntroAutoSkipObserver() { + // In a Watch Party an intro never skips on its own: the mode is pinned + // to Ask, so the controller only offers the pill and the screen sends + // its Skip as a room seek (or hides it from members who cannot seek). + val introMode = if (inRoom) flowOf(IntroSkipMode.ASK) else playerSettingsStore.introSkipModeFlow introObserverJob?.cancel() introObserverJob = introAutoSkipController.observe( position = _uiState @@ -1623,7 +1687,7 @@ class PlayerViewModel( introRange = _uiState .map { it.intro } .distinctUntilChanged(), - mode = playerSettingsStore.introSkipModeFlow, + mode = introMode, introKey = _uiState .map { state -> state.intro?.let { intro -> @@ -1994,6 +2058,21 @@ class PlayerViewModel( val effectiveFileId = decision.session.mediaFileId.takeIf { it > 0 } ?: decision.plan.effectiveMediaFileId ?: fileId + val roomFileId = currentRoomContext?.fileId + if (roomFileId != null && effectiveFileId != roomFileId) { + // The room pins its file through every replan; a plan + // for another file is refused, never played. + playbackSessionManager.abandonActiveVideoSessionAsync(decision.session.sessionId) + nextUpTransitionGate.cancel() + _uiState.update { + it.copy( + error = "The server offered a different version than the Watch Party's.", + isLoading = false, + isBuffering = false, + ) + } + return@launch + } val catalogVersionIndex = state.versions .indexOfFirst { it.fileId == effectiveFileId } val effectiveVersions = if (catalogVersionIndex >= 0) { @@ -2169,6 +2248,7 @@ class PlayerViewModel( streamUrl = null, ) } + currentRoomContext?.let { room -> publishRoomRefusal(room, fileId, decision.reason) } } VideoSessionStartV3.ServerUpgradeRequired -> { nextUpTransitionGate.cancel() @@ -2192,6 +2272,7 @@ class PlayerViewModel( // queue; only a completed flight re-drives a queued user selection. job.invokeOnCompletion { cause -> if (cause == null) redriveQueuedInvalidationReplan() + refreshRoomObservation() } } } @@ -2325,6 +2406,7 @@ class PlayerViewModel( awaitingMediaMountGeneration = null subtitleRefreshGate.reset() positionReportsBlockedForPendingLoad = false + if (inRoom) resetRoomEngineToMount() pendingNativeSeekAfterMount?.let { (targetSeconds, immediate) -> pendingNativeSeekAfterMount = null // The load may have resolved to an online V3 plan after the @@ -2450,6 +2532,7 @@ class PlayerViewModel( // reporter does nothing on the offline-download path (no session). Throttled // by content-time delta so it fires ~every 10s of playback, not per tick. maybeRecordPosition(positionSec, _uiState.value.duration) + refreshRoomObservation() } private var lastRecordedKey: String? = null @@ -2488,6 +2571,7 @@ class PlayerViewModel( * otherwise a buffering glitch flips the pause icon and defeats scheduleControlsHide. */ fun onPlayingChanged(isPlaying: Boolean) { + if (inRoom) roomEngineIsPlaying = isPlaying _uiState.update { it.copy(isPlaying = isPlaying) } if (isPlaying && !_uiState.value.isPaused && _uiState.value.showControls) { scheduleControlsHide() @@ -2605,11 +2689,10 @@ class PlayerViewModel( } /** - * Immediate, deadband-free seek for room-driven corrective seeks - * (RoomSyncController.applyDecision). Updates `uiState.position` like - * [onSeek] AND emits on [immediateSeeks] so PlayerScreen drives the - * MediaController unconditionally — bypassing the 2.0s position-mirror - * deadband that would otherwise swallow sub-2s sync corrections. + * Immediate, deadband-free seek for remote and Watch Party seeks + * ([applyRoomSeek]). Updates `uiState.position` like [onSeek] AND emits on + * [immediateSeeks] so PlayerScreen drives the MediaController + * unconditionally, without counting as the viewer's own interaction. */ fun seekImmediate(position: Double) { cancelPendingQuickSkip() @@ -2940,11 +3023,16 @@ class PlayerViewModel( request = request, recoveryGeneration = recoveryGeneration, ) - is VideoSessionStartV3.Terminal -> publishSeekFailure( - request, - recoveryGeneration, - "Unable to seek (${decision.reason}): ${decision.message}", - ) + is VideoSessionStartV3.Terminal -> { + publishSeekFailure( + request, + recoveryGeneration, + "Unable to seek (${decision.reason}): ${decision.message}", + ) + currentRoomContext?.let { room -> + publishRoomRefusal(room, room.fileId, decision.reason) + } + } VideoSessionStartV3.ServerUpgradeRequired -> publishSeekFailure( request, recoveryGeneration, @@ -2984,6 +3072,7 @@ class PlayerViewModel( // can run now that the in-flight guard is released. redriveQueuedInvalidationReplan() } + refreshRoomObservation() } } @@ -3137,12 +3226,301 @@ class PlayerViewModel( // ---- Remote-control adapters (PlaybackRealtimeController calls these) ---- // Thin wrappers over existing transport; no new playback logic. - // The VM's start request always carries roomId=null, so WT membership is - // set by the screen (which owns roomId) for remote-transport gating. + // The screen marks Watch Party membership from its route, so the room + // gates hold before the room's playback context has arrived. private var inWatchTogetherRoom = false fun setInWatchTogetherRoom(value: Boolean) { inWatchTogetherRoom = value } - /** True while in a Watch Together room — remote transport is gated (the room is authoritative). */ - val remoteTransportSuppressed: Boolean get() = inWatchTogetherRoom + /** True in a Watch Party: the room, not this device, decides transport. */ + val remoteTransportSuppressed: Boolean get() = inRoom + + // ---- Watch Party ------------------------------------------------------------ + // + // The room binding (WatchPartyPlayback / RoomPlaybackBinding) decides + // attach, reports, readiness, corrections and pacing. This ViewModel only + // observes the player truthfully and applies what the binding asks, + // through paths that never count as the viewer's own intent. + + /** A server refusal of the room's file, for the room to decide on a fallback. */ + data class RoomRefusal( + val context: WatchPartyPlaybackContext, + val fileId: Int, + val reason: String, + ) + + /** A local reason playback is held. Nothing is reported to the room while any lasts. */ + enum class RoomSuspensionReason { AudioFocus, TransientAudioFocus, SleepTimer, Background } + + private var currentRoomContext: WatchPartyPlaybackContext? = null + private val inRoom: Boolean get() = inWatchTogetherRoom || currentRoomContext != null + private val roomSuspensions = mutableSetOf() + private val _roomSuspended = MutableStateFlow(false) + + /** Playback is held for a local reason; tapping play resumes this device only. */ + val roomSuspended: StateFlow = _roomSuspended.asStateFlow() + private val _roomCorrectionRate = MutableStateFlow(null) + + /** The binding's temporary catch-up rate; null is exactly 1x. Never a user speed. */ + val roomCorrectionRate: StateFlow = _roomCorrectionRate.asStateFlow() + private val _roomRefusal = MutableStateFlow(null) + + /** The latest server refusal of the room's file, until the screen takes it to the room. */ + val roomRefusal: StateFlow = _roomRefusal.asStateFlow() + private val _roomQualityChanges = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Emits when this viewer's quality changed, so the binding restarts its reload pacing. */ + val roomQualityChanges: SharedFlow = _roomQualityChanges.asSharedFlow() + private val _roomObservation = MutableStateFlow(RoomPlayerObservation()) + + /** What the player is actually doing, for the room binding. */ + val roomObservation: StateFlow = _roomObservation.asStateFlow() + private var roomObservationJob: Job? = null + + // Media3 facts. Player-timeline seconds unless the name says source. + private var roomEnginePlayerSec = 0.0 + private var roomEngineBufferedPlayerSec = 0.0 + private var roomEngineSourceSec = 0.0 + private var roomEnginePlayWhenReady = false + private var roomEngineIsPlaying = false + private var roomEngineState = RoomPlayerState.Idle + + // The session the current room epoch committed. Cleared at every room + // load so the binding never attaches the outgoing session under the new + // selection revision; set again when a session is published. + private var roomObservedSessionId: String? = null + private var roomLastUiSessionId: String? = null + + // Quality chosen during this room epoch (the lower-quality offer). + private var sessionQualityOverride: String? = null + + /** + * Prepares the room's playback epoch. A context for the epoch already + * loading or playing (the screen collecting again after recreation) is a + * no-op; a new selection revision or file restarts playback. + */ + fun startRoomPlayback(context: WatchPartyPlaybackContext) { + val current = currentRoomContext + if (current != null && + current.roomId == context.roomId && + current.selectionRevision == context.selectionRevision && + current.fileId == context.fileId + ) { + return + } + loadContent( + contentId = context.contentId, + preferredFileId = context.fileId, + libraryId = context.libraryId, + room = context, + ) + } + + private fun beginRoomLoad(room: WatchPartyPlaybackContext) { + val previous = currentRoomContext + if (previous == null || + previous.roomId != room.roomId || + previous.selectionRevision != room.selectionRevision + ) { + sessionQualityOverride = null + } + currentRoomContext = room + _roomRefusal.value = null + roomObservedSessionId = null + roomLastUiSessionId = _uiState.value.sessionId + // Room playback always prepares paused, and the outgoing media stops + // now rather than playing on while the new file loads. + _uiState.update { it.copy(isPaused = true) } + if (roomObservationJob == null) { + roomObservationJob = viewModelScope.launch { + _uiState.collect { refreshRoomObservation() } + } + } + refreshRoomObservation() + } + + /** The room context for an in-place restart at [sourcePositionSeconds]: same epoch and file, paused. */ + private fun roomRestartContext(sourcePositionSeconds: Double?): WatchPartyPlaybackContext? { + val room = currentRoomContext ?: return null + val position = sourcePositionSeconds?.takeIf { it.isFinite() && it >= 0.0 } ?: return room + return room.copy(positionSeconds = position, paused = true) + } + + private fun publishRoomRefusal(room: WatchPartyPlaybackContext, fileId: Int, reason: String?) { + val normalized = reason?.trim()?.takeIf { it.isNotEmpty() } ?: return + _roomRefusal.value = RoomRefusal(room, fileId, normalized) + } + + /** The screen has taken [refusal] to the room. */ + fun consumeRoomRefusal(refusal: RoomRefusal) { + _roomRefusal.compareAndSet(refusal, null) + } + + /** Applies a room seek. Never user intent: no pass-out reset, no Up Next dismissal. */ + fun applyRoomSeek(sourceSeconds: Double) { + if (!inRoom || !sourceSeconds.isFinite() || sourceSeconds < 0.0) return + // A restart in flight starts at the room position and re-attaches, + // which brings a fresh command. Seeking the outgoing session now would + // only re-anchor media that is being replaced. + if (loadJob?.isActive == true) { + Log.i(TAG, "room_seek action=ignore_during_load target_source_seconds=$sourceSeconds") + return + } + seekImmediate(sourceSeconds) + } + + /** Applies the room's play or pause. A local suspension holds a Play until the viewer resumes. */ + fun applyRoomPlaying(playing: Boolean) { + if (!inRoom) return + if (playing && roomSuspensions.any { it != RoomSuspensionReason.TransientAudioFocus }) return + _uiState.update { it.copy(isPaused = !playing) } + if (playing && _uiState.value.showControls) scheduleControlsHide() + } + + fun setRoomCorrectionRate(rate: Double?) { + _roomCorrectionRate.value = rate?.takeIf { it.isFinite() && it > 0.0 } + } + + /** Whether reaching [sourceSeconds] needs no new media. False whenever that is uncertain. */ + fun isRoomPositionBuffered(sourceSeconds: Double): Boolean { + if (!inRoom) return false + val state = _uiState.value + if (roomMediaTransitioning(state) || roomEngineState == RoomPlayerState.Idle) return false + return roomTargetBufferedLocally( + timeline = state.playbackPlan?.timeline, + targetSourceSeconds = sourceSeconds, + playerPositionSeconds = roomEnginePlayerSec, + bufferedPlayerSeconds = roomEngineBufferedPlayerSec, + ) + } + + /** A Media3 sample while in a room: position, buffer, and play state as the engine reports them. */ + fun onRoomEngineSample( + positionMs: Long, + bufferedPositionMs: Long, + playWhenReady: Boolean, + isPlaying: Boolean, + playbackState: Int, + ) { + if (!inRoom) return + roomEnginePlayWhenReady = playWhenReady + roomEngineIsPlaying = isPlaying + roomEngineState = roomPlayerState(playbackState) + // Samples from media that is being replaced say nothing about the + // stream the room will see. + if (positionMs >= 0L && !positionReportsBlockedForPendingLoad && awaitingMediaMountGeneration == null) { + val state = _uiState.value + val playerSec = positionMs / 1000.0 + val serverDuration = state.serverDuration.takeIf { it > 0.0 } + roomEnginePlayerSec = playerSec + roomEngineBufferedPlayerSec = bufferedPositionMs.coerceAtLeast(0L) / 1000.0 + // onPositionChanged's source mapping, without the presentation + // guard that holds the scrubber on a pending seek target. + roomEngineSourceSec = (state.playbackPlan?.timeline?.sourcePositionForPlayer(playerSec) ?: playerSec) + .let { position -> serverDuration?.let { position.coerceAtMost(it) } ?: position } + } + refreshRoomObservation() + } + + private fun resetRoomEngineToMount() { + val state = _uiState.value + roomEnginePlayerSec = state.startPosition + roomEngineBufferedPlayerSec = state.startPosition + roomEngineSourceSec = state.position + refreshRoomObservation() + } + + /** Source seconds for a position on the mounted player's timeline. */ + fun sourceSecondsForPlayerMs(positionMs: Long): Double { + val playerSec = positionMs.coerceAtLeast(0L) / 1000.0 + return _uiState.value.playbackPlan?.timeline?.sourcePositionForPlayer(playerSec) ?: playerSec + } + + /** True while media is loading or mounting, when Media3 callbacks describe outgoing media. */ + fun isRoomMediaMounting(): Boolean = + loadJob?.isActive == true || awaitingMediaMountGeneration != null || positionReportsBlockedForPendingLoad + + fun beginRoomSuspension(reason: RoomSuspensionReason) { + if (!inRoom) return + roomSuspensions += reason + // Media3 lifts a transient focus loss by itself, so only the other + // reasons hold the local play state. + if (reason != RoomSuspensionReason.TransientAudioFocus) { + _uiState.update { it.copy(isPaused = true) } + } + refreshRoomObservation() + } + + fun endRoomSuspension(reason: RoomSuspensionReason) { + if (!roomSuspensions.remove(reason)) return + refreshRoomObservation() + } + + /** + * The viewer asked to play while playback was held locally. The held + * reasons clear. With [resumeLocally] this device plays again at once and + * the binding re-syncs it to the room; no room request is sent. + */ + fun endRoomSuspensions(resumeLocally: Boolean) { + roomSuspensions.removeAll { it != RoomSuspensionReason.TransientAudioFocus } + if (resumeLocally) _uiState.update { it.copy(isPaused = false) } + refreshRoomObservation() + } + + /** One rung down the current file's quality ladder, or null when there is none. */ + fun lowerRoomQualityLabel(): String? { + if (!inRoom) return null + return lowerQualityRung( + available = _uiState.value.playbackPlan?.availableQualities.orEmpty(), + selectedLabel = currentMobileQualityPreference(), + )?.label + } + + /** Accepts the lower-quality offer: replans the same file one rung down. */ + fun lowerRoomQuality(): Boolean { + val label = lowerRoomQualityLabel() ?: return false + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(_uiState.value)) + mobileSubtitleTransactions.selectQuality(label) + return true + } + + private fun noteRoomQualityCommitted(committed: String?) { + if (committed == null || committed == currentMobileQualityPreference()) return + sessionQualityOverride = committed + _roomQualityChanges.tryEmit(Unit) + } + + private fun roomMediaTransitioning(state: PlayerUiState): Boolean = + state.isLoading || + loadJob?.isActive == true || + recoveryJob?.isActive == true || + serverSeekRecoveryInFlight || + activeSeekTargetSec != null || + awaitingMediaMountGeneration != null || + positionReportsBlockedForPendingLoad + + private fun refreshRoomObservation() { + if (!inRoom) return + _roomSuspended.value = roomSuspensions.isNotEmpty() + val room = currentRoomContext ?: return + val state = _uiState.value + if (state.sessionId != roomLastUiSessionId) { + roomLastUiSessionId = state.sessionId + roomObservedSessionId = state.sessionId + } + _roomObservation.value = RoomPlayerObservation( + sessionId = roomObservedSessionId, + selectionRevision = room.selectionRevision, + sourcePositionSeconds = roomEngineSourceSec, + durationSeconds = state.duration, + playWhenReady = roomEnginePlayWhenReady, + isPlaying = roomEngineIsPlaying, + state = roomEngineState, + seekPending = roomMediaTransitioning(state), + suspended = roomSuspensions.isNotEmpty(), + ) + } fun remotePause() { _uiState.update { it.copy(isPaused = true) } } fun remoteUnpause() { _uiState.update { it.copy(isPaused = false) } } @@ -3262,7 +3640,7 @@ class PlayerViewModel( ) private fun currentMobileQualityPreference(): String? = - routeIntentState.current?.quality ?: lastLoadArgs?.preferredQuality + sessionQualityOverride ?: routeIntentState.current?.quality ?: lastLoadArgs?.preferredQuality private fun applyMobileSubtitleSnapshot(snapshot: MobileSubtitleTransactionSnapshot) { _uiState.update { state -> @@ -3291,6 +3669,7 @@ class PlayerViewModel( snapshot.failureMessage?.let { showVersionSwitchMessage("Couldn't apply subtitles — playback continues unchanged.") } + if (inRoom) noteRoomQualityCommitted(snapshot.transition.committed.qualityPreference) if (!mobileSubtitleTransactions.hasActiveTransaction) { redriveQueuedInvalidationReplan() } @@ -3308,6 +3687,10 @@ class PlayerViewModel( val effectiveFileId = ready.session.mediaFileId.takeIf { it > 0 } ?: ready.plan.effectiveMediaFileId ?: predecessorFileId + // A Watch Party pins its file; a replan onto another one is abandoned. + if (currentRoomContext?.fileId?.let { it != effectiveFileId } == true) { + return MobileSubtitleAdoptionResult.Superseded + } val catalogVersionIndex = before.versions.indexOfFirst { it.fileId == effectiveFileId } val effectiveVersions = if (catalogVersionIndex >= 0) { before.versions @@ -3429,6 +3812,7 @@ class PlayerViewModel( resumePositionOverride = state.position, suppressResumeRewind = true, preserveRouteIntent = true, + room = roomRestartContext(state.position), ) Log.w(TAG, "Subtitle committed-playback adoption failed: $detail") } @@ -4114,6 +4498,13 @@ class PlayerViewModel( onSeek(target) } + /** + * The intro pill's Select in a Watch Party: resolves the pill and returns + * where to go, so the screen can send it as a room seek instead of moving + * this player alone. + */ + fun selectIntroPromptTarget(): Double? = introAutoSkipController.select() + /** * System back while the intro pill is showing: take it down and resolve the * intro without moving playback. True when a pill was actually dismissed, @@ -4159,6 +4550,8 @@ class PlayerViewModel( /** On Deck tap — an explicit choice: reset the pass-out streak and load * the picked item in place (resuming its saved position, iOS parity). */ fun playOnDeckItemNow(contentId: String) { + // Only the room chooses what a Watch Party plays. + if (inRoom) return autoPlayGuard.recordUserAction() upNextCountdownJob?.cancel() upNextCountdownJob = null @@ -4235,7 +4628,7 @@ class PlayerViewModel( */ fun onApproachingEnd(videoEnded: Boolean = false) { if (nextUpTransitionGate.isActive) return - // Watch Together is authoritative — never auto-advance a room member. + // Watch Party is authoritative — never auto-advance a room member. if (remoteTransportSuppressed) return if (autoAdvanceHandled) { if (videoEnded) { @@ -4515,6 +4908,8 @@ class PlayerViewModel( * flag, not by a sentinel. */ private fun startVersionPlayback(index: Int, isRecovery: Boolean = false) { + // A Watch Party plays exactly the room's file; switching versions is refused. + if (inRoom) return val state = _uiState.value val version = state.versions.getOrNull(index) ?: return if (!isRecovery && index == state.selectedVersionIndex) return diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncController.kt deleted file mode 100644 index f995e97ae0..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncController.kt +++ /dev/null @@ -1,423 +0,0 @@ -package org.siloserver.silo.android.ui.screens.player - -import android.os.SystemClock -import org.siloserver.silo.RoomSyncEngine -import org.siloserver.silo.SyncDecision -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPlaybackState -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.TransportAction -import org.siloserver.silo.repository.PongSample -import org.siloserver.silo.repository.ScheduledTransportCommand -import org.siloserver.silo.repository.WatchTogetherRepository -import org.siloserver.silo.watchtogether.RoomTransportIntent -import org.siloserver.silo.watchtogether.RoomSession -import org.siloserver.silo.watchtogether.RoomCommandDispatcher -import org.siloserver.silo.watchtogether.RoomControllerLifetime -import org.siloserver.silo.watchtogether.RoomPendingExecutions -import org.siloserver.silo.watchtogether.externalPlayPauseDecision -import org.siloserver.silo.watchtogether.roomTransportAuthorized -import org.siloserver.silo.watchtogether.scheduledCommandStillCurrent -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.time.Instant - -/** - * Binds a Watch Together room to the mobile [PlayerViewModel] for the lifetime - * of a synced-player screen. Active ONLY when the player route carries a roomId. - * - * The controller OWNS a [RoomSyncEngine] (the shared engine is a pure class that - * lives in the binding, not the repo). It: - * - adopts the application-scoped room session and attaches the player's - * playback session id once it resolves; - * - drives a ping loop so pongs flow, stamps `clientReceivedMs` on each pong and - * feeds [RoomSyncEngine.recordPongSample] to keep the clock offset fresh; - * - feeds each [ScheduledTransportCommand] into [RoomSyncEngine.decide] and - * applies the resulting [SyncDecision] (seek / setPlaying) at the engine- - * scheduled local delay, auto-emitting `ready` on the waiting barrier; - * - emits `state_report` on a ~1.5s cadence, suppressed around a pending execute - * (the only unit-tested decision — see [shouldEmitStateReport]); - * - emits `ready`/`buffering` on buffer transitions while the room is waiting; - * - routes user play/pause/seek through `transport_request`, gated on - * `self_can_control_transport`; - * - surfaces `room_closed` so the screen exits. - * - * ## Clock domains (critical) - * - WALL CLOCK ([System.currentTimeMillis]) is used for everything the engine - * sees: the ping `client_sent_at` RFC3339 string, the `clientReceivedMs` - * pong stamp, and `nowLocalMs` passed to [RoomSyncEngine.decide]. The engine - * derives its offset as `server - clientWallClock`, and the server timestamps - * are wall-clock epoch millis, so these MUST share the same clock. - * - MONOTONIC ([SystemClock.elapsedRealtime]) is used only for the local - * state-report cadence/suppression gate — pure local timing that must not - * jump if the wall clock is adjusted. - */ -class RoomSyncController( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, - private val viewModel: PlayerViewModel, - private val scope: CoroutineScope, - private val engine: RoomSyncEngine = RoomSyncEngine(), -) { - companion object { - private const val REPORT_CADENCE_MS = 1_500L - private const val SUPPRESS_WINDOW_MS = 250L - private const val REPORT_TICK_MS = 250L - private const val PING_INTERVAL_MS = 15_000L - } - - /** Live room snapshot for the overlay. */ - val room: StateFlow get() = repository.roomSnapshot - - /** - * Non-null once the server CLOSES the room (host left / explicit close) — the - * screen observes and exits. Transient server `error` frames do NOT set this - * (they would wrongly eject the user); they flow on [WatchTogetherRepository.errors]. - */ - val closedReason: StateFlow get() = repository.roomClosedReason - - // Monotonic-domain state-report cadence bookkeeping. - @Volatile private var lastReportMs = 0L - private val lifetime = RoomControllerLifetime(scope) - private val controllerScope = lifetime.scope - private val deliveryLatch = repository.roomDeliveryLatch - private val commandDispatcher = RoomCommandDispatcher(controllerScope) - private val pendingExecutions = RoomPendingExecutions() - - fun start() { - // Install every inbound/outbound collector before adopting the socket. - // UNDISTPATCHED closes the initial-snapshot race when adopt opens - // synchronously on this same UI turn. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> deliveryLatch.keyOrNull(connection, sessionId) } - .distinctUntilChanged() - .collectLatest { key -> - key ?: return@collectLatest - while (isActive && deliveryLatch.needsAttach(key)) { - val delivered = repository.attachSession(key.playbackSessionId) - deliveryLatch.recordAttach(key, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - // Clock-sync: drive pings (once on start + every PING_INTERVAL_MS) and - // fold pongs into the engine's offset estimate. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> - deliveryLatch.keyOrNull(connection, sessionId) to connection.writable - } - .distinctUntilChanged() - .collectLatest { (key, writable) -> - if (!writable || key == null) return@collectLatest - while (!deliveryLatch.isAttached(key)) delay(10) - while (isActive) { - repository.ping(clientSentAt = nowWallClockRfc3339()) - delay(PING_INTERVAL_MS) - } - } - } - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.pongs.collect { pong -> recordPong(pong) } - } - - // Apply engine decisions for each scheduled transport command. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.transportCommands.collect { scheduled -> handleCommand(scheduled) } - } - - // Drift reporting loop (suppressed around a pending execute). - controllerScope.launch { - while (isActive) { - val state = viewModel.uiState.value - val sessionId = state.sessionId - val deliveryKey = deliveryLatch.keyOrNull( - repository.connectionState.value, - sessionId, - ) - val now = monotonicMs() - if (deliveryKey != null && - deliveryLatch.isServerAttached( - deliveryKey, - repository.roomDeliveryEcho.value, - ) && - shouldEmitStateReport( - now, - lastReportMs, - REPORT_CADENCE_MS, - pendingExecutions.nearestTo(now), - SUPPRESS_WINDOW_MS, - ) - ) { - lastReportMs = now - repository.stateReport( - sessionId = deliveryKey.playbackSessionId, - positionSeconds = state.position, - isPaused = state.isPaused, - ) - } - delay(REPORT_TICK_MS) - } - } - - // ready / buffering during the waiting barrier. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState - .map { it.sessionId to it.isBuffering } - .distinctUntilChanged(), - repository.roomSnapshot - .map { it?.playbackState } - .distinctUntilChanged(), - repository.connectionState, - ) { readiness, playbackState, connection -> Triple(readiness, playbackState, connection) } - .collectLatest { (readiness, playbackState, connection) -> - val (sessionId, buffering) = readiness - val key = deliveryLatch.keyOrNull(connection, sessionId) - if (playbackState != RoomPlaybackState.Waiting || key == null) { - return@collectLatest - } - while ( - !deliveryLatch.isServerAttached( - key, - repository.roomDeliveryEcho.value, - ) - ) { - delay(10) - } - while (isActive && deliveryLatch.needsReadiness(key, buffering)) { - val currentState = viewModel.uiState.value - val delivered = if (buffering) { - repository.buffering( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } else { - repository.ready( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } - deliveryLatch.recordReadiness(key, buffering, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - // Lobby and player adopt the same application-scoped connection; route - // disposal does not tear down or duplicate the socket. - roomSession.adopt(roomId) - } - - /** - * Stamp `clientReceivedMs` (wall clock) at collection time and feed the NTP - * sample to the engine. Null guards: a sample with any missing server - * timestamp can't update the offset, so we drop it. - */ - private fun recordPong(pong: PongSample) { - val clientSent = pong.clientSentMs ?: return - val serverReceived = pong.serverReceivedMs ?: return - val serverSent = pong.serverSentMs ?: return - engine.recordPongSample( - clientSentMs = clientSent, - serverReceivedMs = serverReceived, - serverSentMs = serverSent, - clientReceivedMs = System.currentTimeMillis(), - ) - } - - private fun handleCommand(scheduled: ScheduledTransportCommand) { - if (!scheduled.connection.writable || - scheduled.connection != repository.connectionState.value - ) { - return - } - val command = scheduled.command - val state = viewModel.uiState.value - val snapshot = repository.roomSnapshot.value - // executeAtMs null (malformed wire timestamp) → apply immediately; pass - // nowWallClock so the engine computes a 0 delay. - val nowWall = System.currentTimeMillis() - val serverExecuteAtMs = scheduled.executeAtMs ?: nowWall - val decision = engine.decide( - command = command, - serverExecuteAtMs = serverExecuteAtMs, - currentLocalPositionMs = (state.position * 1000.0).toLong(), - currentIsPlaying = !state.isPaused, - nowLocalMs = nowWall, - currentRevision = snapshot?.selectionRevision ?: command.selectionRevision, - attachedSessionId = snapshot?.attachedSessionId ?: state.sessionId, - ) ?: return // duplicate / revision / session gate → ignore - - commandDispatcher.dispatch { - applyDecision( - decision = decision, - sessionId = state.sessionId, - command = command, - acceptedConnection = scheduled.connection, - ) - } - } - - private suspend fun applyDecision( - decision: SyncDecision, - sessionId: String?, - command: org.siloserver.silo.model.watchtogether.TransportCommand, - acceptedConnection: org.siloserver.silo.repository.WatchTogetherConnectionState, - ) { - val delayMs = decision.localExecuteDelayMs.coerceAtLeast(0L) - // Record the pending execute against the MONOTONIC clock for the - // state-report suppression gate, then wait the engine-computed delay. - val pendingToken = pendingExecutions.record(monotonicMs() + delayMs) - try { - if (delayMs > 0) delay(delayMs) - if ( - !scheduledCommandStillCurrent( - command = command, - acceptedPlaybackSessionId = sessionId, - acceptedRoomId = roomId, - acceptedConnection = acceptedConnection, - currentPlaybackSessionId = viewModel.uiState.value.sessionId, - currentRoom = repository.roomSnapshot.value, - currentConnection = repository.connectionState.value, - ) - ) { - return - } - - // Use the deadband-free immediate-seek path: room corrective seeks can be - // as small as the engine's 0.35s DRIFT_THRESHOLD_MS, well under the 2.0s - // position-mirror deadband in PlayerScreen, so onSeek() alone would never - // reach the MediaController. - decision.seekToMs?.let { ms -> viewModel.seekImmediate(ms / 1000.0) } - // onPlayPause() is a TOGGLE — only flip when the desired state differs - // from current intent. - if (viewModel.uiState.value.isPaused == decision.setPlaying) { - viewModel.onPlayPause() - } - } finally { - pendingExecutions.finish(pendingToken) - } - - // The epoch/session-keyed readiness collector owns ready delivery and - // retries. Do not advance an untracked one-shot latch here. - } - - // ---- User-initiated transport: route through the room instead of local apply ---- - - fun onUserPlayPause() { - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (!roomTransportAuthorized(snapshot, RoomTransportIntent.PlayPause)) return - val state = viewModel.uiState.value - // isPaused is current intent; tapping toggles it. The request carries the - // target action and the new paused state. - val willPause = !state.isPaused - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (willPause) TransportAction.Pause.wire else TransportAction.Play.wire, - positionSeconds = state.position, - isPaused = willPause, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - fun onUserSeek(positionSeconds: Double) { - // Seek is host-only (the server rejects guest seeks regardless of policy). - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (!roomTransportAuthorized(snapshot, RoomTransportIntent.Seek)) return - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.Seek, - action = TransportAction.Seek.wire, - positionSeconds = positionSeconds, - isPaused = state.isPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - /** - * Reconcile a direct MediaSession play/pause change with room authority. - * - * Returns the authoritative `playWhenReady` value when the caller must - * immediately undo a local-only change. Authorized attempts are also sent - * through the room and take effect later via the scheduled room command. - */ - fun onExternalPlayWhenReadyChanged(localPlayWhenReady: Boolean): Boolean? { - val authorization = repository.currentTransportAuthorization() - val snapshot = authorization?.snapshot - val decision = externalPlayPauseDecision( - snapshot = snapshot, - localPlayWhenReady = localPlayWhenReady, - ) ?: return null - if (authorization == null) return decision.restorePlayWhenReady - val requestIsPaused = decision.requestIsPaused - if (requestIsPaused != null) { - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (requestIsPaused) { - TransportAction.Pause.wire - } else { - TransportAction.Play.wire - }, - positionSeconds = state.position, - isPaused = requestIsPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - return decision.restorePlayWhenReady - } - - /** Leave the room. Host close tears the room down for everyone; both reset local state. */ - fun leave(closeRoom: Boolean) { - engine.reset() - roomSession.depart(closeRoom) - } - - /** Cancel this screen binding without ending the process-owned room lease. */ - fun dispose() { - engine.reset() - lifetime.dispose() - } - - /** Wall-clock RFC3339 string for the ping `client_sent_at`. */ - private fun nowWallClockRfc3339(): String = Instant.ofEpochMilli(System.currentTimeMillis()).toString() - - /** Monotonic clock for local-only cadence/scheduling. */ - private fun monotonicMs(): Long = SystemClock.elapsedRealtime() -} - -/** Convenience: is the local member the room host? */ -val RoomSnapshot.isHost: Boolean get() = selfRole == MemberRole.Host diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGate.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGate.kt deleted file mode 100644 index c7ffb2b524..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGate.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.siloserver.silo.android.ui.screens.player - -import kotlin.math.abs - -/** - * Pure state-report cadence/suppression decision used by [RoomSyncController]. - * We emit a `state_report` once per [cadenceMs], EXCEPT inside a +/- [suppressWindowMs] - * window around a pending transport command's local execute time — reporting our - * position right as we are about to seek/play would feed the server a stale - * pre-execute sample and fight the sync barrier. - * - * @param nowMs current local (monotonic) clock in ms. - * @param lastReportMs local time the last state_report was emitted. - * @param cadenceMs minimum spacing between reports. - * @param pendingExecuteAtMs local (monotonic) execute time of the next command, or null. - * @param suppressWindowMs half-width of the +/- suppression window around a pending execute. - */ -fun shouldEmitStateReport( - nowMs: Long, - lastReportMs: Long, - cadenceMs: Long, - pendingExecuteAtMs: Long?, - suppressWindowMs: Long, -): Boolean { - if (nowMs - lastReportMs < cadenceMs) return false - if (pendingExecuteAtMs != null && abs(nowMs - pendingExecuteAtMs) <= suppressWindowMs) return false - return true -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupport.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupport.kt new file mode 100644 index 0000000000..4bec293c13 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupport.kt @@ -0,0 +1,92 @@ +package org.siloserver.silo.android.ui.screens.player + +import androidx.media3.common.Player +import org.siloserver.silo.common.player.seek.PlaybackSeekDecision +import org.siloserver.silo.common.player.seek.decideSeek +import org.siloserver.silo.common.player.watchparty.WatchPartyPlayback +import org.siloserver.silo.model.playback.PlaybackAvailableQualityV3 +import org.siloserver.silo.model.playback.PlaybackTimeline +import org.siloserver.silo.playback.PlaybackAction +import org.siloserver.silo.watchtogether.RoomPlayerObservation +import org.siloserver.silo.watchtogether.RoomPlayerPort +import org.siloserver.silo.watchtogether.RoomPlayerState +import org.siloserver.silo.watchtogether.RoomTransportResult +import kotlinx.coroutines.flow.StateFlow + +/** + * The phone player's side of the Watch Party binding. It reports what + * [PlayerViewModel] observes from Media3 and applies what the binding asks, + * through paths that never count as the viewer's own transport intent. + */ +internal class MobileRoomPlayerPort(private val viewModel: PlayerViewModel) : RoomPlayerPort { + override val observations: StateFlow = viewModel.roomObservation + + override fun seekTo(sourceSeconds: Double) = viewModel.applyRoomSeek(sourceSeconds) + + override fun setPlaying(playing: Boolean) = viewModel.applyRoomPlaying(playing) + + override fun setCorrectionRate(rate: Double?) = viewModel.setRoomCorrectionRate(rate) + + override fun isBuffered(sourceSeconds: Double): Boolean = viewModel.isRoomPositionBuffered(sourceSeconds) +} + +internal fun roomPlayerState(playbackState: Int): RoomPlayerState = when (playbackState) { + Player.STATE_BUFFERING -> RoomPlayerState.Buffering + Player.STATE_READY -> RoomPlayerState.Ready + Player.STATE_ENDED -> RoomPlayerState.Ended + else -> RoomPlayerState.Idle +} + +/** + * Whether the mounted stream can reach [targetSourceSeconds] without loading + * new media: the target maps natively onto the current plan's timeline (no + * re-anchor) and lands between just behind the playhead and the buffered + * position. False whenever that cannot be shown. + */ +internal fun roomTargetBufferedLocally( + timeline: PlaybackTimeline?, + targetSourceSeconds: Double, + playerPositionSeconds: Double, + bufferedPlayerSeconds: Double, + backwardSlackSeconds: Double = 1.0, +): Boolean { + if (timeline == null) return false + if (!targetSourceSeconds.isFinite() || targetSourceSeconds < 0.0) return false + if (!playerPositionSeconds.isFinite() || !bufferedPlayerSeconds.isFinite()) return false + if (bufferedPlayerSeconds < playerPositionSeconds) return false + val native = timeline.decideSeek(targetSourceSeconds) as? PlaybackSeekDecision.NativeSeek ?: return false + val target = native.targetPlayerPositionSeconds + return target >= playerPositionSeconds - backwardSlackSeconds && target <= bufferedPlayerSeconds +} + +/** + * One rung below the current one on the same file's quality ladder, or null + * when nothing lower exists. An unmatched [selectedLabel] (Auto, Original, or + * none) is treated as the source rung. + */ +internal fun lowerQualityRung( + available: List, + selectedLabel: String?, +): PlaybackAvailableQualityV3? { + val rungs = available.filter { it.height > 0 } + val current = rungs.firstOrNull { it.label == selectedLabel } + ?: rungs.firstOrNull { it.preservesSource } + ?: rungs.maxByOrNull { it.height } + ?: return null + return rungs + .filter { !it.preservesSource && it.height < current.height } + .maxByOrNull { it.height } +} + +/** Routes a playback-control socket transport command through the room's permission decision. */ +internal fun routeRemoteTransportToRoom( + action: PlaybackAction, + party: WatchPartyPlayback, + locallyPaused: Boolean, +): Boolean = when (action) { + PlaybackAction.Pause -> party.requestPlayPause(pause = true) + PlaybackAction.Unpause -> party.requestPlayPause(pause = false) + PlaybackAction.TogglePlayPause -> party.requestPlayPause(pause = !locallyPaused) + is PlaybackAction.SeekTo -> party.requestSeek(action.positionSeconds) + else -> RoomTransportResult.Ignored +} == RoomTransportResult.Sent diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index e8dfadd180..8adb7c421f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.ArrowCircleDown import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.CollectionsBookmark @@ -111,6 +112,9 @@ fun SettingsScreen( // The large title scrolls away and the bar takes the title over, the way // a large navigation title collapses to an inline one. val titleScrolledAway by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } + val watchPartyExperiment: org.siloserver.silo.common.watchparty.WatchPartyExperiment = + org.koin.compose.koinInject() + val watchPartyEnabled by watchPartyExperiment.enabled.collectAsState() LaunchedEffect(state.loggedOut) { if (state.loggedOut) { @@ -151,7 +155,9 @@ fun SettingsScreen( // External sign-in (OIDC/LDAP): shown only on servers that have it. val matchesSignIn = signInState.visible && search.matches("sign-in", "sign in", "account", "provider", "connect", "disconnect", "sso") - val matchesExperimental = search.matches("experimental", "beta", "testing", "audiobooks", "navigation") + val matchesExperimental = search.matches( + "experimental", "beta", "testing", "audiobooks", "navigation", "watch party", "party", + ) val matchesAbout = search.matches("about", "version", versionLabel, "privacy", "policy", "information") val matchesSignOut = search.matches("sign out", "account") @@ -371,6 +377,14 @@ fun SettingsScreen( checked = state.showAudiobooks, onCheckedChange = viewModel::setShowAudiobooks, ) + // Device-local; never synced with the server's settings. + SettingsSwitchRow( + label = "Watch Party", + description = "Try Watch Party before it's finished. Turning it off leaves any party you're in.", + icon = Icons.Filled.Groups, + checked = watchPartyEnabled, + onCheckedChange = watchPartyExperiment::setEnabled, + ) } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyComponents.kt new file mode 100644 index 0000000000..34afebb5df --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyComponents.kt @@ -0,0 +1,121 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.siloserver.silo.android.ui.components.SiloConfirmDialog +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.RoomSession + +/** A reconnect shorter than this is routine (socket rotation) and stays silent. */ +internal const val WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS = 2_000L + +/** + * True once [disconnected] has held for two seconds; false again the moment + * it clears. [since] identifies one disconnection, so a new one restarts the + * wait. + */ +@Composable +internal fun rememberWatchPartyReconnectNotice(disconnected: Boolean, since: Long?): Boolean { + var show by remember { mutableStateOf(false) } + LaunchedEffect(disconnected, since) { + show = false + if (disconnected) { + delay(WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS) + show = true + } + } + return show +} + +/** Confirms ending the party for everyone (host). */ +@Composable +internal fun WatchPartyEndDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + SiloConfirmDialog( + title = "End the party for everyone?", + body = "Everyone leaves the Watch Party, and it can't be rejoined.", + confirmLabel = "End party", + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} + +/** Explains a host's Leave before it happens: the room outlives them for two minutes. */ +@Composable +internal fun WatchPartyHostLeaveDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + SiloConfirmDialog( + title = "Leave the party?", + body = "You're the host. The party ends for everyone two minutes after you leave, unless you rejoin.", + confirmLabel = "Leave", + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} + +/** + * The D5 guard: while this device is in a Watch Party, starting anything + * else on the shared player first asks the user to leave the party. + */ +@Stable +class WatchPartySoloGuard internal constructor( + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val scope: CoroutineScope, +) { + internal var pending by mutableStateOf<(() -> Unit)?>(null) + internal var pendingFromHost by mutableStateOf(false) + + /** Runs [start] now, or after the user agrees to leave the party. */ + fun run(start: () -> Unit) { + val room = repository.roomSnapshot.value + if (room == null) { + start() + } else { + pendingFromHost = room.selfRole == MemberRole.Host + pending = start + } + } + + internal fun leaveAndRun() { + val start = pending ?: return + pending = null + scope.launch { + roomSession.depart().join() + start() + } + } +} + +@Composable +fun rememberWatchPartySoloGuard(): WatchPartySoloGuard { + val repository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val scope = rememberCoroutineScope() + return remember(repository, roomSession, scope) { WatchPartySoloGuard(repository, roomSession, scope) } +} + +@Composable +fun WatchPartySoloGuardDialog(guard: WatchPartySoloGuard) { + if (guard.pending == null) return + SiloConfirmDialog( + title = "Leave the Watch Party to play this?", + body = if (guard.pendingFromHost) { + "You're the host. The party ends for everyone two minutes after you leave, unless you rejoin." + } else { + "You'll leave the party and play this on your own." + }, + confirmLabel = "Leave and play", + onConfirm = guard::leaveAndRun, + onDismiss = { guard.pending = null }, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyDetailActions.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyDetailActions.kt new file mode 100644 index 0000000000..0fb613ae0a --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyDetailActions.kt @@ -0,0 +1,198 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +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.platform.LocalContext +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.ui.components.SiloConfirmDialog +import org.siloserver.silo.model.feature.WatchPartyExposure +import org.siloserver.silo.model.watchtogether.AddSuggestionRequest +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.watchtogether.WatchPartyAvailability +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.newWatchPartyId +import org.siloserver.silo.watchtogether.watchPartyEligibility +import org.siloserver.silo.watchtogether.watchPartyErrorMessage +import org.siloserver.silo.watchtogether.watchPartyHostingOffered + +/** The detail page's one Watch Party overflow action. */ +data class DetailPartyAction(val label: String, val onClick: () -> Unit) + +/** + * The detail page's party operations (D9): a guest suggests, a host in the + * lobby stages, and a host whose party is playing switches everyone to the + * item through the selection operation. A suggestion whose outcome is unknown + * keeps its id, so tapping again for the same title replays it rather than + * adding a second one. + */ +class WatchPartyDetailViewModel( + private val repository: WatchTogetherRepository, + private val handoff: WatchPartyHandoff, +) : ViewModel() { + private val _messages = MutableSharedFlow(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val messages: SharedFlow = _messages.asSharedFlow() + private var suggestionDraft: AddSuggestionRequest? = null + + fun stage(item: WatchPartyItem) { + handoff.rememberPreview(item) + viewModelScope.launch { + val result = repository.stageSelection(SetSelectionRequest(item.contentId, item.fileId, item.libraryId)) + _messages.tryEmit( + if (result is ApiResult.Success) { + "Added ${item.title} to the party." + } else { + watchPartyErrorMessage(result, "Couldn't add ${item.title}.") + }, + ) + } + } + + fun select(item: WatchPartyItem, onSelected: (RoomSnapshot) -> Unit) { + handoff.rememberPreview(item) + viewModelScope.launch { + when (val result = repository.setSelection(SetSelectionRequest(item.contentId, item.fileId, item.libraryId))) { + is ApiResult.Success -> onSelected(result.data.room) + else -> _messages.tryEmit(watchPartyErrorMessage(result, "Couldn't switch to ${item.title}.")) + } + } + } + + fun suggest(item: WatchPartyItem) { + val request = suggestionDraft?.takeIf { it.contentId == item.contentId } ?: AddSuggestionRequest( + suggestionId = newWatchPartyId(), + contentId = item.contentId, + contentType = item.contentType, + title = item.title, + subtitle = item.subtitle, + posterUrl = item.posterUrl, + ) + // Held before sending, so a second tap while this one is in flight + // resends the same suggestion id instead of adding a duplicate. + suggestionDraft = request + viewModelScope.launch { + when (val result = repository.addSuggestion(request)) { + is ApiResult.Success -> { + if (suggestionDraft == request) suggestionDraft = null + _messages.tryEmit("Suggested ${item.title} to the party.") + } + is ApiResult.NetworkError -> { + suggestionDraft = request + _messages.tryEmit("Couldn't confirm your suggestion. Try again.") + } + is ApiResult.Error -> { + if (suggestionDraft == request) suggestionDraft = request.takeIf { result.error == "invalid_response" } + _messages.tryEmit(watchPartyErrorMessage(result, "Couldn't suggest ${item.title}.")) + } + } + } + } +} + +/** + * Builds the detail page's party action for an item. Made by + * [rememberWatchPartyDetailActions], which also returns the dialogs to compose. + */ +@Stable +class WatchPartyDetailActions internal constructor( + private val enabled: Boolean, + private val room: RoomSnapshot?, + private val canStage: Boolean, + private val canSuggest: Boolean, + private val canHost: Boolean, + private val onHost: (WatchPartyItem) -> Unit, + private val viewModel: WatchPartyDetailViewModel, + private val confirmSelect: (WatchPartyItem) -> Unit, +) { + /** The action for [item], or null when Watch Party is off or nothing applies. */ + fun actionFor(item: WatchPartyItem?): DetailPartyAction? { + if (!enabled || item == null) return null + val party = room ?: return if (canHost) DetailPartyAction("Watch Party") { onHost(item) } else null + val host = party.selfRole == MemberRole.Host && party.selfCanManageRoom + return when { + host && party.phase == RoomPhase.Lobby && canStage -> + DetailPartyAction("Add to party") { viewModel.stage(item) } + // The server refuses a direct selection in a voting room; those suggest instead. + host && party.phase == RoomPhase.Playing && party.selectionMode == RoomSelectionMode.HostPick -> + DetailPartyAction("Play for everyone") { confirmSelect(item) } + canSuggest -> DetailPartyAction("Suggest to Party") { viewModel.suggest(item) } + else -> null + } + } +} + +@Composable +fun rememberWatchPartyDetailActions( + onHost: (WatchPartyItem) -> Unit, + onSelectedWhilePlaying: (RoomSnapshot) -> Unit, +): Pair Unit> { + val exposure: WatchPartyExposure = koinInject() + val repository: WatchTogetherRepository = koinInject() + val availability: WatchPartyAvailabilityRepository = koinInject() + val viewModel: WatchPartyDetailViewModel = koinViewModel() + val enabled by exposure.enabled.collectAsState() + val room by repository.roomSnapshot.collectAsState() + val available by availability.availability.collectAsState() + val context = LocalContext.current + var selecting by remember { mutableStateOf(null) } + + LaunchedEffect(viewModel) { + viewModel.messages.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + } + + // Not gated on an action in flight: the repository runs one at a time, + // and the menu item should not flicker while another request finishes. + val eligibility = watchPartyEligibility( + room = room, + features = (available as? WatchPartyAvailability.Available)?.features, + busy = false, + personalVotesKnown = true, + ) + val actions = WatchPartyDetailActions( + enabled = enabled, + room = room, + canStage = eligibility.canStage, + canSuggest = eligibility.canSuggest, + canHost = watchPartyHostingOffered(available), + onHost = onHost, + viewModel = viewModel, + confirmSelect = { selecting = it }, + ) + val dialogs: @Composable () -> Unit = { + selecting?.let { item -> + SiloConfirmDialog( + title = "Play this for everyone?", + body = "The party switches to it now.", + confirmLabel = "Play for everyone", + destructive = false, + onConfirm = { + selecting = null + viewModel.select(item, onSelectedWhilePlaying) + }, + onDismiss = { selecting = null }, + ) + } + } + return actions to dialogs +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHandoff.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHandoff.kt new file mode 100644 index 0000000000..b4273fb2ef --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHandoff.kt @@ -0,0 +1,71 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import java.util.UUID +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.watchtogether.WatchPartyInvite + +/** + * Process-memory handoffs into the Watch Party hub, keyed by opaque ids that + * routes may carry: + * - an invitation link, whose join token is a secret and must never reach a + * route string, saved state, or logs; + * - the item a detail page asked to host with. + * + * Each slot holds one entry; a newer offer replaces the older one, and a take + * consumes it, so recreation or a restored route never replays it. After + * process death the slots are empty and the hub simply opens. + * + * It also remembers title previews for staged items so the lobby can lay out + * a staged title before any catalog read. + */ +class WatchPartyHandoff { + private val lock = Any() + private var invite: Pair? = null + private var host: Pair? = null + private val previews = LinkedHashMap() + + fun offerInvite(link: WatchPartyInvite.Link): String = synchronized(lock) { + val id = watchPartyInviteHandoffId(link) + invite = id to link + id + } + + fun takeInvite(id: String?): WatchPartyInvite.Link? = synchronized(lock) { + val pending = invite?.takeIf { id != null && it.first == id } ?: return@synchronized null + invite = null + pending.second + } + + fun offerHost(item: WatchPartyItem): String = synchronized(lock) { + val id = UUID.randomUUID().toString() + host = id to item + rememberPreviewLocked(item) + id + } + + fun takeHost(id: String?): WatchPartyItem? = synchronized(lock) { + val pending = host?.takeIf { id != null && it.first == id } ?: return@synchronized null + host = null + pending.second + } + + fun rememberPreview(item: WatchPartyItem) = synchronized(lock) { rememberPreviewLocked(item) } + + fun preview(contentId: String?): WatchPartyItem? = synchronized(lock) { + contentId?.let { previews[it] } + } + + private fun rememberPreviewLocked(item: WatchPartyItem) { + previews.remove(item.contentId) + previews[item.contentId] = item + while (previews.size > MAX_PREVIEWS) previews.remove(previews.keys.first()) + } + + private companion object { + const val MAX_PREVIEWS = 24 + } +} + +/** The hub route that joins [link] once handed over. It carries only an opaque id, never the token. */ +internal fun watchPartyInviteRoute(link: WatchPartyInvite.Link): String = + org.siloserver.silo.android.ui.navigation.Route.WatchPartyHub(invite = watchPartyInviteHandoffId(link)).route diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHubScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHubScreen.kt new file mode 100644 index 0000000000..88cd3c5d52 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyHubScreen.kt @@ -0,0 +1,463 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import android.widget.Toast +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +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.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.ContentPaste +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.saveable.rememberSaveable +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.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.cast.SiloCastSessionManager +import org.siloserver.silo.android.ui.components.SiloConfirmDialog +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.model.feature.WatchPartyExposure +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.repository.WatchPartyPendingAction +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.viewmodel.WatchPartyHubViewModel +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.WatchPartyAvailability +import org.siloserver.silo.watchtogether.WatchPartyDestination + +/** + * The Watch Party hub, opened from the profile menu, a detail page's Watch + * Party action, or an invitation. It shows whether this server supports + * Watch Party, returns to the live party, rejoins the recent one, and hosts + * or joins. + * + * [inviteId] and [hostId] name one-shot handoffs held by [WatchPartyHandoff]; + * each is taken once, so recreation or a restored route never replays it. + */ +@Composable +fun WatchPartyHubScreen( + inviteId: String?, + hostId: String?, + onBack: () -> Unit, + onOpenLobby: (roomId: String) -> Unit, + onOpenPlayer: (RoomSnapshot) -> Unit, + viewModel: WatchPartyHubViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val handoff: WatchPartyHandoff = koinInject() + val repository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val exposure: WatchPartyExposure = koinInject() + val enabled by exposure.enabled.collectAsState() + val castManager: SiloCastSessionManager = koinInject() + val castState by castManager.castState.collectAsState() + val casting = castState.isConnected + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var code by rememberSaveable { mutableStateOf("") } + var dismissedEnded by rememberSaveable { mutableStateOf(null) } + var confirmHostLeave by rememberSaveable { mutableStateOf(false) } + + val available = state.availability is WatchPartyAvailability.Available + // Keep the handoff until this server supports room and playback + // capabilities, including while a failed probe waits for Retry. + // A forced re-check keeps the old answer published, so wait for it: the + // server may have turned Watch Party off since that answer. + LaunchedEffect(inviteId, available, enabled, casting, state.checking) { + if (!available || !enabled || casting || state.checking) return@LaunchedEffect + handoff.takeInvite(inviteId)?.let(viewModel::joinInvite) + } + + // A detail page's "Watch Party": host with that item once the server is + // known to support hosting. Unsupported servers show why instead. + LaunchedEffect(hostId, available, enabled, casting, state.checking) { + if (!available || !enabled || casting || state.checking) return@LaunchedEffect + handoff.takeHost(hostId)?.let(viewModel::hostWithItem) + } + + LaunchedEffect(state.destination, casting) { + if (casting) return@LaunchedEffect + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + when (destination) { + is WatchPartyDestination.Lobby -> onOpenLobby(destination.roomId) + is WatchPartyDestination.Player -> { + val room = repository.roomSnapshot.value?.takeIf { it.roomId == destination.roomId } + if (room != null) onOpenPlayer(room) else onOpenLobby(destination.roomId) + } + } + } + + val busy = state.busy != null + val current = state.current + + // Probe again whenever the hub is shown outside a party, as Apple does: + // the server can turn Watch Party off at any time, and Rejoin should + // reflect a party that just ended. A failed probe keeps the last answer. + LaunchedEffect(current?.roomId, state.ended) { + if (current == null) viewModel.refresh(force = true) + } + val join: () -> Unit = { + if (!busy) viewModel.join(code.trim()) + } + + // A party uses this device's player. Wait for the receiver to disconnect + // before joining, creating, or consuming a detail/invitation handoff. + if (enabled && casting) { + SiloConfirmDialog( + title = "Stop casting to use Watch Party?", + body = "Casting will stop. Watch Party plays on this device.", + confirmLabel = "Stop casting", + onConfirm = castManager::disconnect, + onDismiss = onBack, + ) + return + } + + val canEnter = enabled && available && !busy + val canHost = (state.availability as? WatchPartyAvailability.Available)?.features?.stagedSelection == true + Box(Modifier.fillMaxSize()) { + WatchPartyBackdrop(url = null) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + WatchPartyTopBar(onBack = onBack) + Column( + verticalArrangement = Arrangement.spacedBy(28.dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .navigationBarsPadding() + .padding(horizontal = WatchPartyMetrics.pageInset) + .padding(bottom = 40.dp), + ) { + HubHeadline(Modifier.padding(top = 24.dp)) + + // Why the last party ended; Rejoin when the party itself goes on. + val ended = state.ended + val endedKey = ended?.let { "${it.roomId}:${it.reason}" } + val showEnded = ended != null && current == null && dismissedEnded != endedKey + val endedRejoin = showEnded && enabled && available && ended != null && + watchPartyEndedOffersRejoin(ended.reason) && state.recent?.roomId == ended.roomId + val banners = showEnded || !enabled || !available || state.error != null || busy + if (banners) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (showEnded && ended != null) { + WatchPartyBanner( + text = watchPartyEndedMessage(ended.reason), + action = { + if (endedRejoin) { + TextButton(onClick = viewModel::rejoinRecent, enabled = !busy) { + Text("Rejoin", color = SiloOnSurface) + } + } + DismissButton { dismissedEnded = endedKey } + }, + ) + } + if (!enabled) { + WatchPartyBanner("Watch Party is turned off. Turn it on in Settings, under Experimental.") + } else { + when (state.availability) { + null -> WatchPartyBanner("Checking Watch Party support…") + is WatchPartyAvailability.Unsupported -> + WatchPartyBanner("This server doesn't support Watch Party yet.") + WatchPartyAvailability.NotAllowed -> + WatchPartyBanner("Your account can't use Watch Party.") + is WatchPartyAvailability.ProbeFailed -> WatchPartyBanner( + "Couldn't check whether this server supports Watch Party.", + tone = WatchPartyBannerTone.Warning, + ) + is WatchPartyAvailability.Available -> Unit + } + } + // Errors stay inline; an uncertain create keeps its room id for Retry. + state.error?.let { message -> + WatchPartyBanner( + text = message, + tone = WatchPartyBannerTone.Warning, + action = { + if (state.createRetryable) { + TextButton(onClick = viewModel::retryCreate, enabled = !busy) { + Text("Retry", color = SiloOnSurface) + } + } + DismissButton(viewModel::clearError) + }, + ) + } + if (busy) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + color = SiloSecondaryText, + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + when (state.busy) { + WatchPartyPendingAction.Create -> "Starting your party…" + WatchPartyPendingAction.Join -> "Joining…" + WatchPartyPendingAction.Stage -> "Adding your title…" + else -> "Working…" + }, + fontSize = WatchPartyMetrics.BODY.sp, + color = SiloSecondaryText, + ) + } + } + } + } + + if (!enabled) return@Column + + if (current != null) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyEyebrow("You're in a Watch Party") + if (current.code.isNotBlank()) { + Text( + current.code, + fontSize = WatchPartyMetrics.CODE.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = (WatchPartyMetrics.CODE * 0.18f).sp, + color = SiloOnSurface, + ) + } + WatchPartyButton( + text = "Return to party", + icon = Icons.Filled.PlayArrow, + onClick = viewModel::returnToParty, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) + WatchPartyButton( + text = "Leave party", + kind = WatchPartyButtonKind.Secondary, + onClick = { + if (current.selfRole == MemberRole.Host) confirmHostLeave = true else roomSession.depart() + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Leave this party to host or join another one.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = SiloSecondaryText, + ) + } + return@Column + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // Hosting needs staged selection; a server without it can still be joined. + if (!available || canHost) { + WatchPartyButton( + text = "Start a party", + icon = Icons.Filled.Add, + onClick = { viewModel.host() }, + enabled = canEnter, + modifier = Modifier.fillMaxWidth(), + ) + WatchPartyButton( + text = "Start a party and let everyone vote", + kind = WatchPartyButtonKind.Secondary, + onClick = { viewModel.host(RoomSelectionMode.Vote) }, + enabled = canEnter, + modifier = Modifier.fillMaxWidth(), + ) + } else { + WatchPartyBanner(text = "This server can't host a Watch Party. You can still join one.") + } + state.recent?.takeIf { !endedRejoin }?.let { recent -> + WatchPartyButton( + text = recent.title?.takeIf { it.isNotBlank() }?.let { "Rejoin $it" } + ?: "Rejoin recent party", + icon = Icons.Filled.Refresh, + kind = WatchPartyButtonKind.Outlined, + onClick = viewModel::rejoinRecent, + enabled = canEnter, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyEyebrow("Have a code?") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyCodeField( + value = code, + onValueChange = { code = it }, + enabled = !busy, + onGo = { if (code.isNotBlank() && canEnter) join() }, + modifier = Modifier.weight(1f), + ) + WatchPartyCircleButton( + icon = Icons.Outlined.ContentPaste, + contentDescription = "Paste", + size = 52.dp, + shape = RoundedCornerShape(14.dp), + enabled = !busy, + onClick = { + val pasted = clipboard.getText()?.text?.trim().orEmpty() + if (pasted.isEmpty()) { + Toast.makeText(context, "Copy an invitation or party code first.", Toast.LENGTH_SHORT).show() + } else { + code = pasted + } + }, + ) + } + WatchPartyButton( + text = "Join party", + kind = WatchPartyButtonKind.Secondary, + onClick = join, + enabled = canEnter && code.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) + if (state.availability != null && !available) { + WatchPartyButton( + text = "Check again", + kind = WatchPartyButtonKind.Secondary, + onClick = { viewModel.refresh(force = true) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + + if (confirmHostLeave) { + WatchPartyHostLeaveDialog( + onConfirm = { + confirmHostLeave = false + roomSession.depart() + }, + onDismiss = { confirmHostLeave = false }, + ) + } +} + +@Composable +private fun HubHeadline(modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.BODY * 0.6f).dp)) { + WatchPartyEyebrow("Watch Party") + WatchPartyHeroTitle("Watch something\ntogether") + Text( + "Pick a title, share the code, and everyone's playback stays in step.", + fontSize = WatchPartyMetrics.BODY.sp, + color = SiloSecondaryText, + ) + } +} + +@Composable +private fun DismissButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = SiloSecondaryText) + } +} + +/** "Party code or invite link": monospaced, in the lobby's chrome. */ +@Composable +private fun WatchPartyCodeField( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + onGo: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(14.dp) + val style = TextStyle( + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = SiloOnSurface, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = true, + textStyle = style, + cursorBrush = SolidColor(SiloOnSurface), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { onGo() }), + modifier = modifier + .height(52.dp) + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape) + .semantics { contentDescription = "Party code or invite link" }, + decorationBox = { inner -> + Box(Modifier.fillMaxSize().padding(horizontal = 14.dp), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text("Party code or invite link", style = style.copy(color = SiloSecondaryText.copy(alpha = 0.5f)), maxLines = 1) + } + inner() + } + }, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteSheet.kt new file mode 100644 index 0000000000..f92f447a71 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteSheet.kt @@ -0,0 +1,210 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import android.content.Intent +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +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 com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText + +/** + * The invitation, after Apple's `WatchPartyInviteView` phone layout: the + * code, a QR of the invitation link, Share and Copy. A guest has no link + * (the server gives the invite path to the host only), so a guest shares or + * copies the code alone. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WatchPartyInviteSheet( + code: String, + inviteUrl: String?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(null) } + + fun share(text: String) { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Join my Silo Watch Party") + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(send, "Share Watch Party invitation")) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color.Black, + contentColor = SiloOnSurface, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.BODY * 1.4f).dp), + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp) + .padding(bottom = 24.dp) + .navigationBarsPadding(), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + WatchPartyEyebrow("Invite friends") + Text( + "Join with this code", + fontSize = (WatchPartyMetrics.HERO_TITLE * 0.8f).sp, + fontWeight = FontWeight.Bold, + color = SiloOnSurface, + textAlign = TextAlign.Center, + ) + Text( + "Anyone with a profile on this Silo server can enter it from Watch Party.", + fontSize = WatchPartyMetrics.BODY.sp, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + ) + } + Text( + code, + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 8.sp, + color = SiloOnSurface, + maxLines = 1, + modifier = Modifier + .padding(start = 8.dp) + .semantics { contentDescription = "Party code ${code.toList().joinToString(", ")}" }, + ) + if (inviteUrl != null) { + WatchPartyQrCode( + content = inviteUrl, + size = 200.dp, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(12.dp) + .semantics { contentDescription = "Scan to join this party" }, + ) + Text( + "Or scan to open the invitation on a phone.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 8.dp)) { + WatchPartyButton( + text = if (inviteUrl != null) "Share invitation" else "Share code", + icon = Icons.Outlined.IosShare, + onClick = { share(watchPartyShareText(code, inviteUrl)) }, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (inviteUrl != null) { + WatchPartyButton( + text = if (copied == "link") "Copied" else "Copy link", + icon = if (copied == "link") Icons.Filled.Check else Icons.Outlined.Link, + kind = WatchPartyButtonKind.Secondary, + onClick = { + clipboard.setText(AnnotatedString(inviteUrl)) + copied = "link" + }, + modifier = Modifier.weight(1f), + ) + } + WatchPartyButton( + text = if (copied == "code") "Copied" else "Copy code", + icon = if (copied == "code") Icons.Filled.Check else Icons.Outlined.ContentCopy, + kind = WatchPartyButtonKind.Secondary, + onClick = { + clipboard.setText(AnnotatedString(code)) + copied = "code" + }, + modifier = Modifier.weight(1f), + ) + } + } + WatchPartyButton( + text = "Done", + kind = WatchPartyButtonKind.Secondary, + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } +} + +/** [content] as a QR code, one square per module, at error correction M. */ +@Composable +internal fun WatchPartyQrCode(content: String, size: Dp, modifier: Modifier = Modifier) { + val matrix = remember(content) { watchPartyQrMatrix(content) } + Canvas(modifier.size(size)) { + val count = matrix.width + val module = this.size.minDimension / count + for (y in 0 until count) { + for (x in 0 until count) { + if (matrix.get(x, y)) { + // A hair of overlap keeps sub-pixel seams out of dark runs. + drawRect(Color.Black, Offset(x * module, y * module), Size(module + 0.5f, module + 0.5f)) + } + } + } + } +} + +private fun watchPartyQrMatrix(content: String): BitMatrix = + // Width and height 0 return the bare module grid; the white card is the quiet zone. + QRCodeWriter().encode( + content, + BarcodeFormat.QR_CODE, + 0, + 0, + mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M, EncodeHintType.MARGIN to 0), + ) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyLobbyScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyLobbyScreen.kt new file mode 100644 index 0000000000..ae3333dc29 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyLobbyScreen.kt @@ -0,0 +1,262 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import android.widget.Toast +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.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.model.watchtogether.GuestControlPolicy +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.viewmodel.WatchPartyConnectionStatus +import org.siloserver.silo.viewmodel.WatchPartyLobbyViewModel +import org.siloserver.silo.watchtogether.WatchPartyDestination +import org.siloserver.silo.watchtogether.watchPartyInviteUrl + +/** + * The Watch Party lobby, after Apple's phone lobby: the staged film's artwork + * fills the page under the code pill, the hero, the ballot or suggestions, + * the seats, and one pinned action. The room's phase alone opens the player. + */ +@Composable +fun WatchPartyLobbyScreen( + roomId: String, + onBack: () -> Unit, + onOpenPlayer: (RoomSnapshot) -> Unit, + /** The party ended; the hub explains why and offers Rejoin when it can. */ + onPartyEnded: () -> Unit, + /** This device left (Leave, End, or elsewhere). */ + onLeft: () -> Unit, + onOpenDetail: (contentId: String) -> Unit, + viewModel: WatchPartyLobbyViewModel = koinViewModel(parameters = { parametersOf(roomId) }), +) { + val state by viewModel.state.collectAsState() + val destination by viewModel.destination.collectAsState() + val repository: WatchTogetherRepository = koinInject() + val handoff: WatchPartyHandoff = koinInject() + val serverRegistry: ServerRegistry = koinInject() + val activeServer by serverRegistry.activeEntry.collectAsState() + val context = LocalContext.current + + val room = state.room?.takeIf { it.roomId == roomId } + val eligibility = state.eligibility + val isHost = room?.selfRole == MemberRole.Host + var leaving by remember { mutableStateOf(false) } + var seenRoom by remember { mutableStateOf(false) } + var pickerMode by rememberSaveable { mutableStateOf(null) } + var inviteOpen by rememberSaveable { mutableStateOf(false) } + var confirmEnd by rememberSaveable { mutableStateOf(false) } + var confirmHostLeave by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(viewModel) { + viewModel.messages.collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + } + + // Only the room's phase opens the player. + LaunchedEffect(destination) { + if (destination !is WatchPartyDestination.Player || leaving) return@LaunchedEffect + val playing = repository.roomSnapshot.value?.takeIf { it.roomId == roomId } ?: return@LaunchedEffect + onOpenPlayer(playing) + } + + LaunchedEffect(room != null) { + if (room != null) seenRoom = true + } + LaunchedEffect(state.ended) { + val ended = state.ended ?: return@LaunchedEffect + if (ended.roomId == roomId && !leaving) { + leaving = true + onPartyEnded() + } + } + LaunchedEffect(room == null, seenRoom, state.ended) { + if (room == null && seenRoom && state.ended == null && !leaving) { + leaving = true + onLeft() + } + } + + fun leaveNow() { + leaving = true + viewModel.leave() + onLeft() + } + + fun endNow() { + leaving = true + viewModel.endForEveryone() + onLeft() + } + + val connection = state.connection + val reconnectSince = (connection as? WatchPartyConnectionStatus.Reconnecting)?.sinceMs + val showReconnect = rememberWatchPartyReconnectNotice( + disconnected = room != null && reconnectSince != null, + since = reconnectSince, + ) + val staged = rememberWatchPartyStagedTitle(room, state.suggestions) + + if (room == null) { + Box(Modifier.fillMaxSize()) { + WatchPartyBackdrop(url = null) + Column(Modifier.fillMaxSize()) { + WatchPartyTopBar(onBack = onBack) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.align(Alignment.Center), + ) { + CircularProgressIndicator(color = SiloSecondaryText) + Text("Connecting to the party…", color = SiloSecondaryText) + } + } + return + } + + val actions = object : WatchPartyRoomActions { + override fun invite() { + inviteOpen = true + } + + override fun chooseTitle() { + pickerMode = WatchPartyPickMode.Stage + } + + override fun suggest() { + pickerMode = WatchPartyPickMode.Suggest + } + + override fun start() = viewModel.start().let { } + + override fun setLobbyReady(ready: Boolean) = viewModel.setLobbyReady(ready) + + override fun returnToPlayback() { + repository.roomSnapshot.value?.takeIf { it.roomId == roomId }?.let(onOpenPlayer) + } + + override fun setMode(mode: RoomSelectionMode) { + if (room.selectionMode != mode) viewModel.setMode(mode) + } + + override fun setPolicy(policy: GuestControlPolicy) { + if (room.guestControlPolicy != policy) viewModel.setGuestPolicy(policy) + } + + override fun leave() { + if (isHost) confirmHostLeave = true else leaveNow() + } + + override fun end() { + confirmEnd = true + } + + override fun vote(suggestion: Suggestion) = viewModel.vote(suggestion).let { } + + override fun promote(suggestion: Suggestion) = viewModel.promote(suggestion).let { } + + override fun queue(suggestion: Suggestion) { + handoff.rememberPreview(suggestion.toPartyItem()) + viewModel.queue(suggestion) + } + + override fun remove(suggestion: Suggestion) = viewModel.remove(suggestion) + } + + WatchPartyRoomContent( + room = room, + staged = staged, + suggestions = state.suggestions, + personalVotesKnown = state.personalVotesKnown, + features = state.features, + eligibility = eligibility, + connected = connection is WatchPartyConnectionStatus.Connected, + showReconnect = showReconnect, + firstConnect = reconnectSince == 0L, + actions = actions, + onBack = onBack, + extraBanner = state.suggestionRetry?.let { draft -> + { + WatchPartyBanner( + text = "Couldn't confirm your suggestion of ${draft.title}.", + tone = WatchPartyBannerTone.Warning, + action = { + TextButton(onClick = viewModel::retrySuggestion) { Text("Try again", color = SiloOnSurface) } + IconButton(onClick = viewModel::dismissSuggestionRetry) { + Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = SiloSecondaryText) + } + }, + ) + } + }, + ) + + if (inviteOpen) { + WatchPartyInviteSheet( + code = room.code, + inviteUrl = if (isHost) activeServer?.url?.let { watchPartyInviteUrl(it, room.invitePath) } else null, + onDismiss = { inviteOpen = false }, + ) + } + + pickerMode?.let { mode -> + WatchPartyPicker( + mode = mode, + roomMembers = room.members, + memberState = viewModel::memberState, + onPick = { item -> + handoff.rememberPreview(item) + if (mode == WatchPartyPickMode.Stage) viewModel.stage(item) else viewModel.suggest(item) + }, + onOpenSeries = onOpenDetail, + onDismiss = { pickerMode = null }, + ) + } + + if (confirmEnd) { + WatchPartyEndDialog( + onConfirm = { + confirmEnd = false + endNow() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmHostLeave) { + WatchPartyHostLeaveDialog( + onConfirm = { + confirmHostLeave = false + leaveNow() + }, + onDismiss = { confirmHostLeave = false }, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPanelSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPanelSheet.kt new file mode 100644 index 0000000000..0777e43ecd --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPanelSheet.kt @@ -0,0 +1,141 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import android.widget.Toast +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.siloserver.silo.model.watchtogether.GuestControlPolicy +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.WatchPartyAvailability +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.WatchPartyEligibility +import org.siloserver.silo.watchtogether.watchPartyErrorMessage + +/** + * The party panel over a retained party player (Back opens it). It is the + * room itself, as Apple's `WatchPartyRoomPanel` reopens the lobby over the + * player: "Now watching · together", the seats with playback states, the + * code pill and "···" options, and Return to playback as the primary action. + * Closing it only closes the panel; the player keeps playing underneath. + */ +@Composable +internal fun WatchPartyPanelSheet( + room: RoomSnapshot?, + eligibility: WatchPartyEligibility, + inviteUrl: String?, + onReturnToLobby: () -> Unit, + onEndForEveryone: () -> Unit, + onLeave: () -> Unit, + onDismiss: () -> Unit, +) { + val repository: WatchTogetherRepository = koinInject() + val availabilityRepository: WatchPartyAvailabilityRepository = koinInject() + val availability by availabilityRepository.availability.collectAsState() + val suggestions by repository.suggestions.collectAsState() + val personalVotesKnown by repository.personalVotesKnown.collectAsState() + val connection by repository.connectionState.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val isHost = room?.selfRole == MemberRole.Host + var inviteOpen by rememberSaveable { mutableStateOf(false) } + var confirmEnd by rememberSaveable { mutableStateOf(false) } + var confirmHostLeave by rememberSaveable { mutableStateOf(false) } + + if (room == null) { + LaunchedEffect(Unit) { onDismiss() } + return + } + + val staged = rememberWatchPartyStagedTitle(room, suggestions) + val reconnectSince = connection.disconnectedAtMs.takeIf { !connection.writable } + val showReconnect = rememberWatchPartyReconnectNotice( + disconnected = !connection.writable, + since = reconnectSince, + ) + + val actions = object : WatchPartyRoomActions { + override fun invite() { + inviteOpen = true + } + + override fun returnToPlayback() = onDismiss() + + override fun setPolicy(policy: GuestControlPolicy) { + if (room.guestControlPolicy == policy) return + scope.launch { + val result = repository.updatePolicy(policy) + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't change who can play and pause."), + Toast.LENGTH_SHORT, + ).show() + } + } + } + + override fun returnEveryoneToLobby() = onReturnToLobby() + + override fun leave() { + if (isHost) confirmHostLeave = true else onLeave() + } + + override fun end() { + confirmEnd = true + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + WatchPartyRoomContent( + room = room, + staged = staged, + suggestions = suggestions, + personalVotesKnown = personalVotesKnown, + features = (availability as? WatchPartyAvailability.Available)?.features, + eligibility = eligibility, + connected = connection.writable, + showReconnect = showReconnect, + firstConnect = reconnectSince == null || reconnectSince == 0L, + actions = actions, + onBack = null, + ) + } + + if (inviteOpen) { + WatchPartyInviteSheet(code = room.code, inviteUrl = inviteUrl, onDismiss = { inviteOpen = false }) + } + if (confirmEnd) { + WatchPartyEndDialog( + onConfirm = { + confirmEnd = false + onEndForEveryone() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmHostLeave) { + WatchPartyHostLeaveDialog( + onConfirm = { + confirmHostLeave = false + onLeave() + }, + onDismiss = { confirmHostLeave = false }, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt new file mode 100644 index 0000000000..cd2a1c8fad --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt @@ -0,0 +1,729 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import androidx.compose.animation.ExperimentalSharedTransitionApi +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.filled.Search +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.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +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.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.android.ui.components.BackdropCard +import org.siloserver.silo.android.ui.components.MediaCard +import org.siloserver.silo.android.ui.navigation.LocalHeroSourceHandoff +import org.siloserver.silo.android.ui.navigation.LocalSharedTransitionScope +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloPageBackground +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.watchtogether.ItemMemberState +import org.siloserver.silo.model.watchtogether.MemberWatchState +import org.siloserver.silo.model.watchtogether.PickerEntry +import org.siloserver.silo.model.watchtogether.PickerNextUp +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.viewmodel.WatchPartyPickerViewModel + +/** What choosing a title in the picker does. */ +internal enum class WatchPartyPickMode(val title: String, val eyebrow: String, val confirmLabel: String) { + /** Host Picks host: stage it as the next title. */ + Stage("Choose a title", "Watch together", "Choose for the party"), + + /** Everyone else, and vote rooms: suggest it. */ + Suggest("Suggest a title", "Suggest to the party", "Add suggestion"), +} + +/** + * The room picker, after Apple's `WatchPartyMediaPicker`: full screen, search + * on top, then Home-style shelves (the group's rows, this profile's resume + * row, Home's discovery rows, and the newest movies and series). Choosing a + * title opens a confirmation page with who in the room has seen it. A series + * with a group next-up episode offers that episode; any other series opens + * its detail page, where the party action applies. Picking never starts + * playback. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun WatchPartyPicker( + mode: WatchPartyPickMode, + roomMembers: List, + memberState: suspend (String) -> ItemMemberState?, + onPick: (WatchPartyItem) -> Unit, + onOpenSeries: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchPartyPickerViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsState() + val shelves by rememberPickerHomeShelves() + var choice by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { viewModel.loadRows() } + + val entries = remember(state.rows) { + state.rows?.let { it.continueTogether + it.watchlistUnion }.orEmpty().associateBy { it.item.contentId } + } + + fun openSeriesPage(seriesId: String) { + onDismiss() + onOpenSeries(seriesId) + } + + fun open(item: SectionItem) { + when (item.type) { + "episode" -> choice = WatchPartyChoice.episode(item) + "series" -> { + val nextUp = entries[item.contentId]?.nextUp + if (nextUp != null) choice = WatchPartyChoice.nextUp(item, nextUp) else openSeriesPage(item.contentId) + } + else -> choice = WatchPartyChoice.title(item) + } + } + + Dialog( + // Back on the confirmation page returns to the shelves. + onDismissRequest = { if (choice != null) choice = null else onDismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + // Cards here have no detail hero to morph into. + CompositionLocalProvider(LocalSharedTransitionScope provides null, LocalHeroSourceHandoff provides null) { + val current = choice + if (current == null) { + PickerShelvesPage( + mode = mode, + state = state, + shelves = shelves, + roomMembers = roomMembers, + onQuery = viewModel::onQuery, + onOpen = ::open, + onClose = onDismiss, + ) + } else { + PickerChoicePage( + mode = mode, + choice = current, + roomMembers = roomMembers, + memberState = memberState, + onConfirm = { + onPick(current.lobbyItem) + onDismiss() + }, + onSeeAllEpisodes = current.seriesId?.let { id -> { openSeriesPage(id) } }, + onBack = { choice = null }, + ) + } + } + } +} + +// MARK: Data + +/** A title on the confirmation page, and how the lobby should show it once chosen. */ +private data class WatchPartyChoice( + val contentId: String, + val title: String, + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String?, + val backdropUrl: String?, + val backdropThumbhash: String?, + val overview: String?, + val facts: List, + /** The series to browse from here, for an episode. */ + val seriesId: String?, + val lobbyItem: WatchPartyItem, +) { + companion object { + fun title(item: SectionItem) = WatchPartyChoice( + contentId = item.contentId, + title = item.title, + subtitle = null, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = item.overview, + facts = listOfNotNull( + item.year.takeIf { it > 0 }?.toString(), + watchPartyRuntime(item.runtime), + item.contentRating?.takeIf { it.isNotBlank() }, + item.genres.firstOrNull(), + ), + seriesId = null, + lobbyItem = WatchPartyItem( + contentId = item.contentId, + contentType = item.type, + title = item.title, + subtitle = item.year.takeIf { it > 0 }?.toString(), + posterUrl = item.posterUrl, + ), + ) + + /** A resume-row episode: series title on top, the episode underneath. */ + fun episode(item: SectionItem): WatchPartyChoice { + val series = item.seriesTitle?.takeIf { it.isNotBlank() } + val season = item.seasonNumber + val number = item.episodeNumber + val code = if (season != null && number != null) "S$season · E$number" else null + return WatchPartyChoice( + contentId = item.contentId, + title = series ?: item.title, + subtitle = listOfNotNull(code, item.title.takeIf { series != null }).joinToString(" · ").ifBlank { null }, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = item.overview, + facts = listOfNotNull(watchPartyRuntime(item.runtime)), + seriesId = item.seriesId?.takeIf { it.isNotBlank() }, + lobbyItem = WatchPartyItem( + contentId = item.contentId, + contentType = "episode", + title = item.title, + subtitle = if (series != null && season != null && number != null) { + "$series · S$season:E$number" + } else { + series + }, + posterUrl = item.posterUrl, + ), + ) + } + + /** A series whose group next-up episode the server named. */ + fun nextUp(series: SectionItem, next: PickerNextUp): WatchPartyChoice { + val episodeTitle = next.title.ifBlank { "Episode ${next.episodeNumber}" } + return WatchPartyChoice( + contentId = next.contentId, + title = series.title, + subtitle = "S${next.seasonNumber} · E${next.episodeNumber}" + + next.title.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty(), + posterUrl = series.posterUrl, + posterThumbhash = series.posterThumbhash, + backdropUrl = series.backdropUrl, + backdropThumbhash = series.backdropThumbhash, + overview = null, + facts = emptyList(), + seriesId = series.contentId, + lobbyItem = WatchPartyItem( + contentId = next.contentId, + contentType = "episode", + title = episodeTitle, + subtitle = "${series.title} · S${next.seasonNumber}:E${next.episodeNumber}", + posterUrl = series.posterUrl, + ), + ) + } + } +} + +private fun BrowseItem.toSectionItem() = SectionItem( + contentId = contentId, + type = type, + title = title, + year = year, + genres = genres, + contentRating = contentRating, + runtime = runtime, + overview = overview, + posterUrl = posterUrl, + posterThumbhash = posterThumbhash, + backdropUrl = backdropUrl, + backdropThumbhash = backdropThumbhash, + userState = userState, + overlaySummary = overlaySummary, +) + +private val playableTypes = setOf("movie", "series", "episode") + +/** Home's rows for the shelf, loaded once per open. */ +private data class PickerHomeShelves( + val loading: Boolean = true, + val resume: List = emptyList(), + val discovery: List>> = emptyList(), + val recentMovies: List = emptyList(), + val recentSeries: List = emptyList(), +) + +/** + * Home rows the shelf skips, as on Apple and the web picker: personal rows are + * covered by the group rows or would leak one member's history, and + * per-library recently-added rows are replaced by the shelf's own. + */ +private val skippedHomeSectionTypes = setOf( + "recently_added", "recently_released", "new_to_library", "continue_watching", "in_progress", "next_up", + "next_in_series", "watchlist", "favorites", "profile_activity_feed", "because_you_watched", + "recommended_for_you", "similar_users_liked", "taste_match", "forgotten_favorites", +) +private const val MAX_HOME_SECTIONS = 4 + +@Composable +private fun rememberPickerHomeShelves(): androidx.compose.runtime.State { + val sections: SectionRepository = koinInject() + val catalog: CatalogRepository = koinInject() + return produceState(PickerHomeShelves()) { + suspend fun recent(type: String): List = + when (val result = catalog.browse(mediaType = type, sort = "added_at", order = "desc", limit = 24)) { + is ApiResult.Success -> result.data.items.filter { it.type in playableTypes }.map { it.toSectionItem() } + else -> emptyList() + } + coroutineScope { + val home = async { + val owner = sections.captureHomeAuthority() ?: return@async null + (sections.getHomeSections(owner) as? ApiResult.Success)?.data?.sections + } + val movies = async { recent("movie") } + val series = async { recent("series") } + val homeSections = home.await().orEmpty() + value = PickerHomeShelves( + loading = false, + resume = homeSections + .firstOrNull { it.sectionType == "continue_watching" || it.sectionType == "in_progress" } + ?.items?.filter { it.type == "movie" || it.type == "episode" }.orEmpty(), + discovery = homeSections + .filter { it.sectionType.lowercase() !in skippedHomeSectionTypes } + .mapNotNull { section -> + val items = section.items.filter { it.type == "movie" || it.type == "series" } + if (items.isEmpty()) null else section.title to items + } + .take(MAX_HOME_SECTIONS), + recentMovies = movies.await(), + recentSeries = series.await(), + ) + } + } +} + +private data class PickerShelf( + val id: String, + val title: String, + val items: List, + val stills: Boolean = false, + val captions: Map = emptyMap(), + val progress: Map = emptyMap(), +) + +private fun memberNames(entry: PickerEntry): String? = + entry.members.map { it.displayName.ifBlank { "Someone" } }.takeIf { it.isNotEmpty() }?.let(::formatWatchPartyNames) + +// MARK: Shelves page + +@Composable +private fun PickerShelvesPage( + mode: WatchPartyPickMode, + state: WatchPartyPickerViewModel.PickerState, + shelves: PickerHomeShelves, + roomMembers: List, + onQuery: (String) -> Unit, + onOpen: (SectionItem) -> Unit, + onClose: () -> Unit, +) { + val rows = state.rows + val list = buildList { + rows?.continueTogether?.takeIf { it.isNotEmpty() }?.let { entries -> + add( + PickerShelf( + id = "together", + title = "Continue Together", + items = entries.map { it.item.toSectionItem() }, + captions = entries.mapNotNull { entry -> + val caption = entry.nextUp?.let { "Next: S${it.seasonNumber} · E${it.episodeNumber}" } ?: memberNames(entry) + caption?.let { entry.item.contentId to it } + }.toMap(), + progress = entries.mapNotNull { entry -> + val position = entry.members.mapNotNull { it.positionSeconds }.maxOrNull() + val duration = entry.members.mapNotNull { it.durationSeconds }.maxOrNull() + if (position != null && duration != null && duration > 0) { + entry.item.contentId to (position / duration).toFloat().coerceIn(0f, 1f) + } else { + null + } + }.toMap(), + ), + ) + } + if (shelves.resume.isNotEmpty()) add(PickerShelf("resume", "Continue Watching", shelves.resume, stills = true)) + rows?.watchlistUnion?.takeIf { it.isNotEmpty() }?.let { entries -> + add( + PickerShelf( + id = "watchlist", + title = "On Your Watchlists", + items = entries.map { it.item.toSectionItem() }, + captions = entries.mapNotNull { entry -> memberNames(entry)?.let { entry.item.contentId to it } }.toMap(), + ), + ) + } + shelves.discovery.forEachIndexed { index, (title, items) -> add(PickerShelf("home-$index", title, items)) } + if (shelves.recentMovies.isNotEmpty()) add(PickerShelf("movies", "Recently Added Movies", shelves.recentMovies)) + if (shelves.recentSeries.isNotEmpty()) add(PickerShelf("series", "Recently Added Series", shelves.recentSeries)) + } + val searching = state.query.isNotBlank() + + Column( + Modifier + .fillMaxSize() + .background(SiloPageBackground) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp)) { + WatchPartyCircleButton( + icon = Icons.Filled.Close, + contentDescription = "Close", + onClick = onClose, + size = 44.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + Text( + mode.title, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + color = SiloOnSurface, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + PickerSearchField( + query = state.query, + onQuery = onQuery, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + state.error?.let { + WatchPartyBanner(it, tone = WatchPartyBannerTone.Warning, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + when { + searching -> PickerSearchResults(state, onOpen) + list.isEmpty() && (shelves.loading || state.rowsLoading) -> PickerProgress() + list.isEmpty() -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 80.dp, start = 32.dp, end = 32.dp), + ) { + Text("Nothing to pick from yet", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + Text( + "Search for a title, or add something to a watchlist.", + fontSize = WatchPartyMetrics.BODY.sp, + color = SiloSecondaryText, + ) + } + else -> LazyColumn( + verticalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp), + modifier = Modifier.fillMaxSize().navigationBarsPadding(), + ) { + items(list, key = { it.id }) { shelf -> PickerShelfRow(shelf, onOpen) } + } + } + } +} + +@Composable +private fun PickerSearchField(query: String, onQuery: (String) -> Unit, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(12.dp) + val style = TextStyle(fontSize = 16.sp, color = SiloOnSurface) + BasicTextField( + value = query, + onValueChange = onQuery, + singleLine = true, + textStyle = style, + cursorBrush = SolidColor(SiloOnSurface), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + modifier = modifier + .fillMaxWidth() + .height(44.dp) + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape), + decorationBox = { inner -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 12.dp, end = 4.dp)) { + Icon(Icons.Filled.Search, contentDescription = null, tint = SiloSecondaryText, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Box(Modifier.weight(1f)) { + if (query.isEmpty()) { + Text("Search movies and series", style = style.copy(color = SiloSecondaryText), maxLines = 1) + } + inner() + } + if (query.isNotEmpty()) { + IconButton(onClick = { onQuery("") }, modifier = Modifier.size(36.dp)) { + Icon(Icons.Filled.Close, contentDescription = "Clear search", tint = SiloSecondaryText, modifier = Modifier.size(18.dp)) + } + } + } + }, + ) +} + +@Composable +private fun PickerShelfRow(shelf: PickerShelf, onOpen: (SectionItem) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Home's row heading: 20sp, with the resume row's play glyph. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + if (shelf.stills) { + Icon(Icons.Filled.PlayCircle, contentDescription = null, tint = SiloOnSurface, modifier = Modifier.size(20.dp)) + } + Text(shelf.title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(shelf.items.distinctBy { it.contentId }, key = { it.contentId }) { item -> + if (shelf.stills) { + val position = item.positionSeconds + val duration = item.durationSeconds + BackdropCard( + title = item.title, + backdropUrl = item.backdropUrl ?: item.posterUrl, + backdropThumbhash = item.backdropThumbhash ?: item.posterThumbhash, + seriesTitle = item.seriesTitle, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + progress = if (position != null && duration != null && duration > 0) { + (position / duration).toFloat().coerceIn(0f, 1f) + } else { + null + }, + userState = item.userState, + onClick = { onOpen(item) }, + ) + } else { + MediaCard( + title = item.title, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + year = item.year, + subtitle = shelf.captions[item.contentId], + type = item.type, + userState = item.userState, + progress = shelf.progress[item.contentId], + onClick = { onOpen(item) }, + ) + } + } + } + } +} + +@Composable +private fun PickerSearchResults(state: WatchPartyPickerViewModel.PickerState, onOpen: (SectionItem) -> Unit) { + when { + state.results.isNotEmpty() -> LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp), + modifier = Modifier.fillMaxSize().navigationBarsPadding(), + ) { + items(state.results, key = { it.contentId }) { result -> + val item = result.toSectionItem() + MediaCard( + title = item.title, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + year = item.year, + type = item.type, + userState = item.userState, + onClick = { onOpen(item) }, + modifier = Modifier.fillMaxWidth(), + width = androidx.compose.ui.unit.Dp.Unspecified, + ) + } + } + state.searching || state.query.trim().length < 2 -> PickerProgress() + else -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 80.dp), + ) { + Text("No results", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + Text("Try a different search term.", fontSize = WatchPartyMetrics.BODY.sp, color = SiloSecondaryText) + } + } +} + +@Composable +private fun PickerProgress() { + Box(Modifier.fillMaxWidth().padding(top = 120.dp), contentAlignment = Alignment.TopCenter) { + CircularProgressIndicator(color = SiloSecondaryText, modifier = Modifier.size(28.dp), strokeWidth = 2.dp) + } +} + +// MARK: Confirmation page + +@Composable +private fun PickerChoicePage( + mode: WatchPartyPickMode, + choice: WatchPartyChoice, + roomMembers: List, + memberState: suspend (String) -> ItemMemberState?, + onConfirm: () -> Unit, + onSeeAllEpisodes: (() -> Unit)?, + onBack: () -> Unit, +) { + var loadingHistory by remember(choice.contentId) { mutableStateOf(true) } + var history by remember(choice.contentId) { mutableStateOf(null) } + LaunchedEffect(choice.contentId) { + history = memberState(choice.contentId) + loadingHistory = false + } + Box(Modifier.fillMaxSize()) { + WatchPartyBackdrop( + url = choice.backdropUrl ?: choice.posterUrl, + thumbhash = if (choice.backdropUrl != null) choice.backdropThumbhash else choice.posterThumbhash, + isPoster = choice.backdropUrl == null, + ) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp)) { + WatchPartyCircleButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + onClick = onBack, + size = 44.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.BODY * 1.4f).dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = WatchPartyMetrics.pageInset) + // Let the backdrop show above the poster, as in the lobby. + .padding(top = 150.dp, bottom = WatchPartyMetrics.pageInset), + ) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(WatchPartyMetrics.BODY.dp)) { + WatchPartyHeroPoster(choice.posterUrl, choice.posterThumbhash, 120.dp) + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { + WatchPartyEyebrow(mode.eyebrow) + WatchPartyHeroTitle(choice.title, size = WatchPartyMetrics.HERO_TITLE * 0.9f) + val facts = listOfNotNull(choice.subtitle) + choice.facts + if (facts.isNotEmpty()) { + Text(facts.joinToString(" · "), fontSize = WatchPartyMetrics.BODY.sp, color = SiloSecondaryText) + } + } + } + choice.overview?.takeIf { it.isNotBlank() }?.let { + Text(it, fontSize = WatchPartyMetrics.BODY.sp, color = SiloSecondaryText, maxLines = 6) + } + WatchPartyButton( + text = mode.confirmLabel, + icon = if (mode == WatchPartyPickMode.Suggest) Icons.Filled.Add else Icons.Filled.Check, + onClick = onConfirm, + modifier = Modifier.fillMaxWidth(), + ) + if (onSeeAllEpisodes != null) { + WatchPartyButton( + text = "See all episodes", + icon = Icons.AutoMirrored.Filled.List, + kind = WatchPartyButtonKind.Secondary, + onClick = onSeeAllEpisodes, + modifier = Modifier.fillMaxWidth(), + ) + } + // Hidden when the server has no member-state read. + if (loadingHistory || history != null) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WatchPartyEyebrow("Who's seen it") + if (loadingHistory) { + Text("Checking viewing history…", fontSize = WatchPartyMetrics.CAPTION.sp, color = SiloSecondaryText) + } else { + val states = history?.members.orEmpty() + roomMembers.forEach { member -> + val watch = states.firstOrNull { it.userId == member.userId && it.profileId == member.profileId } + Row { + Text( + watchPartyMemberName(member), + fontSize = WatchPartyMetrics.BODY.sp, + color = SiloOnSurface, + modifier = Modifier.weight(1f), + ) + Text(watchHistoryLabel(watch), fontSize = WatchPartyMetrics.BODY.sp, color = SiloSecondaryText) + } + } + } + } + } + } + } + } +} + +private fun watchHistoryLabel(state: MemberWatchState?): String { + state ?: return "No viewing history" + val label = when (state.state) { + "watched", "completed", "played" -> "Watched" + "in_progress", "inprogress" -> "In progress" + else -> "Not watched" + } + return if (state.onWatchlist) "$label · Watchlist" else label +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt new file mode 100644 index 0000000000..9aeb3142c6 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt @@ -0,0 +1,969 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.HighlightOff +import androidx.compose.material.icons.outlined.PanTool +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.siloserver.silo.android.ui.components.SiloDropdownMenu +import org.siloserver.silo.android.ui.theme.SiloDestructive +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.watchtogether.GuestControlPolicy +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.watchtogether.WatchPartyConnectionTone +import org.siloserver.silo.watchtogether.WatchPartyEligibility +import org.siloserver.silo.watchtogether.WatchPartyFeatures +import org.siloserver.silo.watchtogether.WatchPartyPrimaryAction +import org.siloserver.silo.watchtogether.WatchPartySecondaryAction +import org.siloserver.silo.watchtogether.canRemoveSuggestion +import org.siloserver.silo.watchtogether.roomVoteWinner +import org.siloserver.silo.watchtogether.watchPartyConnectionLabel +import org.siloserver.silo.watchtogether.watchPartyLobbyBanner +import org.siloserver.silo.watchtogether.watchPartyLobbyHero +import org.siloserver.silo.watchtogether.watchPartyLobbyHint +import org.siloserver.silo.watchtogether.watchPartyPrimaryAction +import org.siloserver.silo.watchtogether.watchPartySecondaryAction + +/* + * The room itself, shared by the lobby screen and the party panel over the + * player (Apple's `WatchPartyLobbyView`, which `WatchPartyRoomPanel` reuses). + * The staged film's artwork fills the page; the code pill, connection dot and + * options sit on top, the hero and seats in the middle, and the one + * foregrounded action is pinned to the bottom. + */ + +/** Everything the room can ask its host screen to do. */ +internal interface WatchPartyRoomActions { + fun invite() + fun chooseTitle() {} + fun suggest() {} + fun start() {} + fun setLobbyReady(ready: Boolean) {} + fun returnToPlayback() + fun setMode(mode: RoomSelectionMode) {} + fun setPolicy(policy: GuestControlPolicy) + fun returnEveryoneToLobby() {} + fun leave() + fun end() + fun vote(suggestion: Suggestion) {} + fun promote(suggestion: Suggestion) {} + fun queue(suggestion: Suggestion) {} + fun remove(suggestion: Suggestion) {} +} + +/** The staged title as the room shows it: a caller's preview until the catalog read lands. */ +internal data class WatchPartyStagedTitle( + val title: String?, + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String?, + val backdropUrl: String?, + val backdropThumbhash: String?, + val facts: List, + val chips: List, + val overview: String?, + /** The catalog refused this profile the title (403/404). */ + val unavailable: Boolean, +) + +@Composable +internal fun rememberWatchPartyStagedTitle(room: RoomSnapshot?, suggestions: List): WatchPartyStagedTitle? { + val handoff: WatchPartyHandoff = koinInject() + val catalog: CatalogRepository = koinInject() + val stagedId = room?.selectedContentId?.takeIf { it.isNotBlank() } + val libraryId = room?.selectedLibraryId + var detail by remember(stagedId) { mutableStateOf(null) } + var unavailable by remember(stagedId) { mutableStateOf(false) } + LaunchedEffect(stagedId, libraryId) { + val id = stagedId ?: return@LaunchedEffect + when (val result = catalog.getItemDetail(id, libraryId)) { + is ApiResult.Success -> detail = result.data + is ApiResult.Error -> unavailable = result.code == 403 || result.code == 404 + is ApiResult.NetworkError -> Unit + } + } + stagedId ?: return null + val preview = handoff.preview(stagedId) ?: suggestions.firstOrNull { it.contentId == stagedId }?.toPartyItem() + val loaded = detail + if (loaded == null) { + return WatchPartyStagedTitle( + title = preview?.title, + subtitle = preview?.subtitle, + posterUrl = preview?.posterUrl, + posterThumbhash = null, + backdropUrl = null, + backdropThumbhash = null, + facts = emptyList(), + chips = emptyList(), + overview = null, + unavailable = unavailable, + ) + } + val episode = loaded.type == "episode" + val versions = loaded.versions + val version = versions.firstOrNull { it.fileId == room.selectedFileId } ?: versions.singleOrNull() + return WatchPartyStagedTitle( + title = loaded.title, + subtitle = if (episode) loaded.episodeSubtitle() else null, + posterUrl = loaded.posterUrl ?: preview?.posterUrl, + posterThumbhash = loaded.posterThumbhash, + backdropUrl = loaded.backdropUrl, + backdropThumbhash = loaded.backdropThumbhash, + facts = listOfNotNull( + loaded.year.takeIf { it > 0 && !episode }?.toString(), + watchPartyRuntime(loaded.runtime), + ), + chips = watchPartyQualityChips(version), + overview = loaded.overview?.takeIf { it.isNotBlank() }, + unavailable = false, + ) +} + +private fun ItemDetail.episodeSubtitle(): String? { + val series = seriesTitle?.takeIf { it.isNotBlank() } ?: return null + val season = seasonNumber + val episode = episodeNumber + return if (season != null && episode != null) "$series · S$season:E$episode" else series +} + +internal fun Suggestion.toPartyItem() = WatchPartyItem( + contentId = contentId, + contentType = contentType, + title = title, + subtitle = subtitle.ifBlank { null }, + posterUrl = posterUrl.ifBlank { null }, +) + +@Composable +internal fun WatchPartyRoomContent( + room: RoomSnapshot, + staged: WatchPartyStagedTitle?, + suggestions: List, + personalVotesKnown: Boolean, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + /** The room socket is writable. */ + connected: Boolean, + /** A disconnection has lasted long enough to mention (two seconds). */ + showReconnect: Boolean, + /** Still connecting for the first time rather than reconnecting. */ + firstConnect: Boolean, + actions: WatchPartyRoomActions, + onBack: (() -> Unit)?, + modifier: Modifier = Modifier, + extraBanner: (@Composable () -> Unit)? = null, +) { + val lobby = room.phase == RoomPhase.Lobby + val voteLobby = lobby && room.selectionMode == RoomSelectionMode.Vote + val unavailable = staged?.unavailable == true + val winner = roomVoteWinner(suggestions) + // The film is the room: its backdrop, else its poster, else a voting + // room's leader, blurred. + val leaderPoster = if (room.selectionMode == RoomSelectionMode.Vote) { + (winner ?: suggestions.firstOrNull())?.posterUrl?.takeIf { it.isNotBlank() } + } else { + null + } + val backdrop = staged?.backdropUrl ?: staged?.posterUrl ?: leaderPoster + val backdropIsPoster = staged?.backdropUrl == null + + val primary = watchPartyPrimaryAction(room, features, suggestions, unavailable) + val secondary = watchPartySecondaryAction(room, primary) + val hint = watchPartyLobbyHint(room, suggestions) + val banner = watchPartyLobbyBanner(room, unavailable, reconnecting = showReconnect)?.let { + if (showReconnect && firstConnect && it == "Reconnecting to the party…") "Connecting to the party…" else it + } + + val header: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + WatchPartyCodePill(code = room.code, onClick = actions::invite) + Spacer(Modifier.width(10.dp)) + ConnectionDot(room, connected, reconnecting = !connected && !firstConnect) + Spacer(Modifier.weight(1f)) + WatchPartyOptionsMenu(room, features, eligibility, actions) + } + } + val middle: @Composable ColumnScope.(compact: Boolean) -> Unit = { compact -> + RoomHero(room, staged, suggestions, compact) + if (banner != null) { + val warning = banner != "Reconnecting to the party…" && banner != "Connecting to the party…" + WatchPartyBanner(banner, tone = if (warning) WatchPartyBannerTone.Warning else WatchPartyBannerTone.Neutral) + } + extraBanner?.invoke() + if (voteLobby) { + WatchPartyBallot(room, suggestions, personalVotesKnown, eligibility, connected, actions) + } else if (lobby && room.selectionMode == RoomSelectionMode.HostPick && suggestions.isNotEmpty()) { + WatchPartyHostPickSuggestions(room, suggestions, eligibility, connected, actions) + } + WatchPartySeatsRow(members = room.members, phase = room.phase, onInvite = actions::invite) + } + val buttons: @Composable ColumnScope.() -> Unit = { + PrimaryButton(primary, room, winner, eligibility, connected, actions) + when (secondary) { + WatchPartySecondaryAction.ChangeTitle -> WatchPartyButton( + text = "Change title", + kind = WatchPartyButtonKind.Secondary, + enabled = eligibility.canStage, + onClick = actions::chooseTitle, + modifier = Modifier.fillMaxWidth(), + ) + WatchPartySecondaryAction.Suggest -> WatchPartyButton( + text = if (primary is WatchPartyPrimaryAction.Start) "Suggest" else "Suggest a title", + icon = Icons.Filled.Add, + kind = WatchPartyButtonKind.Secondary, + enabled = eligibility.canSuggest, + onClick = actions::suggest, + modifier = Modifier.fillMaxWidth(), + ) + null -> Unit + } + if (hint != null) WatchPartyHint(hint) + } + + BoxWithConstraints(modifier.fillMaxSize()) { + val landscape = maxWidth > maxHeight + WatchPartyBackdrop( + url = backdrop, + thumbhash = if (backdropIsPoster) staged?.posterThumbhash else staged?.backdropThumbhash, + isPoster = backdropIsPoster, + ) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), + ) { + if (!landscape) { + WatchPartyTopBar(onBack = onBack) + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = WatchPartyMetrics.pageInset) + .padding(top = 8.dp, bottom = 16.dp), + ) { + header() + middle(false) + } + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .background(WatchPartyBottomScrim) + .padding(horizontal = WatchPartyMetrics.pageInset) + .padding(top = 12.dp, bottom = 8.dp) + .navigationBarsPadding(), + content = buttons, + ) + } else { + // Over the landscape player: one bar across the top, the room on + // the left, the actions bottom-right, as on tvOS. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = WatchPartyMetrics.pageInset, vertical = 10.dp), + ) { + if (onBack != null) { + WatchPartyCircleButton(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back", onBack, size = 40.dp) + Spacer(Modifier.width(14.dp)) + } + Text("Watch Party", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + Spacer(Modifier.width(14.dp)) + ConnectionDot(room, connected, reconnecting = !connected && !firstConnect) + Spacer(Modifier.weight(1f)) + WatchPartyCodePill(code = room.code, onClick = actions::invite) + Spacer(Modifier.width(12.dp)) + WatchPartyOptionsMenu(room, features, eligibility, actions) + } + Row(Modifier.weight(1f).padding(horizontal = WatchPartyMetrics.pageInset)) { + Column( + verticalArrangement = Arrangement.spacedBy(18.dp), + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + ) { + middle(true) + } + Spacer(Modifier.width(28.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.Bottom), + modifier = Modifier + .width(300.dp) + .fillMaxHeight() + .padding(bottom = 16.dp) + .navigationBarsPadding(), + content = buttons, + ) + } + } + } + } +} + +@Composable +private fun ConnectionDot(room: RoomSnapshot, connected: Boolean, reconnecting: Boolean) { + val (tone, label) = watchPartyConnectionLabel(room, connected, reconnecting) + val color = when (tone) { + WatchPartyConnectionTone.Good -> WatchPartyColors.emerald + WatchPartyConnectionTone.Warning -> WatchPartyColors.amber + WatchPartyConnectionTone.Neutral -> SiloSecondaryText + WatchPartyConnectionTone.Bad -> WatchPartyColors.rose + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.semantics(mergeDescendants = true) {}, + ) { + Box(Modifier.size((WatchPartyMetrics.CAPTION * 0.55f).dp).clip(CircleShape).background(color)) + Text(label, fontSize = WatchPartyMetrics.CAPTION.sp, color = SiloSecondaryText, maxLines = 1) + } +} + +/** Eyebrow + title + facts, with the staged poster beside it. A voting lobby asks the question instead. */ +@Composable +private fun RoomHero(room: RoomSnapshot, staged: WatchPartyStagedTitle?, suggestions: List, compact: Boolean) { + val hero = watchPartyLobbyHero(room, suggestions, staged?.unavailable == true) + val showsPoster = !room.selectedContentId.isNullOrBlank() && !hero.isVoteQuestion + val titleSize = if (compact) 24f else WatchPartyMetrics.HERO_TITLE + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(top = if (compact) 4.dp else if (showsPoster) 120.dp else 40.dp), + ) { + if (showsPoster) { + WatchPartyHeroPoster(staged?.posterUrl, staged?.posterThumbhash, if (compact) 72.dp else 112.dp) + } + Column(verticalArrangement = Arrangement.spacedBy((titleSize * 0.28f).dp), modifier = Modifier.weight(1f)) { + WatchPartyEyebrow(hero.eyebrow) + val title = hero.title ?: staged?.title + WatchPartyHeroTitle( + text = title ?: "Loading title…", + color = if (title == null || staged?.unavailable == true) SiloSecondaryText else SiloOnSurface, + size = titleSize, + ) + val selected = !room.selectedContentId.isNullOrBlank() && !hero.isVoteQuestion + if (selected && staged != null) FactsLine(staged) + hero.supporting?.let { + Text( + it, + fontSize = if (selected) WatchPartyMetrics.CAPTION.sp else WatchPartyMetrics.BODY.sp, + color = if (selected) SiloSecondaryText.copy(alpha = 0.7f) else SiloSecondaryText, + ) + } + } + } +} + +@Composable +private fun FactsLine(staged: WatchPartyStagedTitle) { + val parts = listOfNotNull(staged.subtitle?.takeIf { it.isNotBlank() }) + staged.facts + if (parts.isEmpty() && staged.chips.isEmpty()) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (parts.isNotEmpty()) { + Text( + buildAnnotatedString { + parts.forEachIndexed { index, part -> + if (index > 0) withStyle(SpanStyle(color = SiloSecondaryText.copy(alpha = 0.5f))) { append(" · ") } + append(part) + } + }, + fontSize = (WatchPartyMetrics.BODY - 1).sp, + color = SiloSecondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + staged.chips.forEach { chip -> + Text( + chip, + fontSize = (WatchPartyMetrics.CAPTION * 0.85f).sp, + fontWeight = FontWeight.SemiBold, + color = SiloSecondaryText, + maxLines = 1, + modifier = Modifier + .border(1.dp, SiloSecondaryText.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + } + } +} + +@Composable +private fun PrimaryButton( + primary: WatchPartyPrimaryAction, + room: RoomSnapshot, + winner: Suggestion?, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: WatchPartyRoomActions, +) { + val full = Modifier.fillMaxWidth() + when (primary) { + WatchPartyPrimaryAction.ChooseTitle -> WatchPartyButton( + text = if (room.selectedContentId.isNullOrBlank()) "Choose a title" else "Change title", + enabled = eligibility.canStage, + onClick = actions::chooseTitle, + modifier = full, + ) + is WatchPartyPrimaryAction.Start -> { + // A voting room starts its leader; a host-pick room its staged title. + val vote = room.selectionMode == RoomSelectionMode.Vote + WatchPartyButton( + text = primary.title?.let { "Start $it" } ?: "Start for everyone", + icon = Icons.Filled.PlayArrow, + enabled = connected && if (vote) eligibility.canPromote && winner != null else eligibility.canStart, + onClick = { if (vote) winner?.let(actions::promote) else actions.start() }, + modifier = full, + ) + } + WatchPartyPrimaryAction.WaitingForVotes -> WatchPartyButton( + text = "Suggest a title", + icon = Icons.Filled.Add, + enabled = eligibility.canSuggest, + onClick = actions::suggest, + modifier = full, + ) + is WatchPartyPrimaryAction.Ready -> if (primary.isReady) { + WatchPartyButton( + text = "You're ready", + icon = Icons.Filled.Check, + kind = WatchPartyButtonKind.Outlined, + enabled = connected && eligibility.canLobbyReady, + onClick = { actions.setLobbyReady(false) }, + modifier = full, + trailing = { + Text( + " tap to undo", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = SiloOnSurface.copy(alpha = 0.6f), + maxLines = 1, + ) + }, + ) + } else { + WatchPartyButton( + text = "I'm ready", + icon = Icons.Outlined.CheckCircle, + enabled = connected && eligibility.canLobbyReady, + onClick = { actions.setLobbyReady(true) }, + modifier = full, + ) + } + WatchPartyPrimaryAction.ReturnToPlayback -> WatchPartyButton( + text = "Return to playback", + icon = Icons.Filled.PlayArrow, + onClick = actions::returnToPlayback, + modifier = full, + ) + WatchPartyPrimaryAction.None -> Unit + } +} + +private enum class OptionsPage { Main, Mode, Policy } + +/** + * The "···" menu. Settings open a second page that shows their choices, so + * the top level reads as one aligned list with each setting's current value. + */ +@Composable +private fun WatchPartyOptionsMenu( + room: RoomSnapshot, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + actions: WatchPartyRoomActions, +) { + var open by remember { mutableStateOf(false) } + var page by remember { mutableStateOf(OptionsPage.Main) } + val host = eligibility.isHost + fun close() { + open = false + page = OptionsPage.Main + } + Box { + WatchPartyCircleButton( + icon = Icons.Filled.MoreHoriz, + contentDescription = "Party options", + onClick = { open = true }, + ) + SiloDropdownMenu(expanded = open, onDismissRequest = ::close) { + val vote = room.selectionMode == RoomSelectionMode.Vote + val guestsControl = room.guestControlPolicy == GuestControlPolicy.GuestPlayPause + when (page) { + OptionsPage.Main -> { + if (host) { + if (room.phase == RoomPhase.Lobby && features?.selectionModeSwitch == true) { + OptionRow( + "Who chooses", + icon = Icons.Outlined.PanTool, + value = if (vote) "Everyone votes" else "Host", + enabled = eligibility.canSwitchMode, + ) { page = OptionsPage.Mode } + } + OptionRow( + "Play & pause", + icon = Icons.Outlined.SmartDisplay, + value = if (guestsControl) "Host and guests" else "Host only", + enabled = eligibility.canSetPolicy, + ) { page = OptionsPage.Policy } + if (room.phase == RoomPhase.Playing && features?.stopPlayback == true) { + OptionRow( + "Return everyone to lobby", + icon = Icons.AutoMirrored.Filled.Undo, + enabled = eligibility.canStop, + ) { + close() + actions.returnEveryoneToLobby() + } + } + HorizontalDivider(color = WatchPartyColors.chromeBorder) + } + OptionRow("Leave party", icon = Icons.AutoMirrored.Filled.Logout) { + close() + actions.leave() + } + if (host) { + OptionRow("End party", icon = Icons.Outlined.HighlightOff, destructive = true, enabled = eligibility.canEnd) { + close() + actions.end() + } + } + } + OptionsPage.Mode -> { + OptionRow("Who chooses", icon = Icons.AutoMirrored.Filled.ArrowBack, header = true) { page = OptionsPage.Main } + OptionRow("Host", checked = !vote, enabled = eligibility.canSwitchMode) { + close() + if (vote) actions.setMode(RoomSelectionMode.HostPick) + } + OptionRow("Everyone votes", checked = vote, enabled = eligibility.canSwitchMode) { + close() + if (!vote) actions.setMode(RoomSelectionMode.Vote) + } + } + OptionsPage.Policy -> { + OptionRow("Play & pause", icon = Icons.AutoMirrored.Filled.ArrowBack, header = true) { page = OptionsPage.Main } + OptionRow("Host only", checked = !guestsControl, enabled = eligibility.canSetPolicy) { + close() + if (guestsControl) actions.setPolicy(GuestControlPolicy.HostOnly) + } + OptionRow("Host and guests", checked = guestsControl, enabled = eligibility.canSetPolicy) { + close() + if (!guestsControl) actions.setPolicy(GuestControlPolicy.GuestPlayPause) + } + } + } + } + } +} + +@Composable +private fun OptionRow( + label: String, + icon: ImageVector? = null, + value: String? = null, + checked: Boolean? = null, + destructive: Boolean = false, + header: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val color = if (destructive) SiloDestructive else SiloOnSurface + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .widthIn(min = 240.dp) + .heightIn(min = 48.dp) + .alpha(if (enabled) 1f else 0.45f) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + when { + checked != null -> Box(Modifier.size(20.dp)) { + if (checked) Icon(Icons.Filled.Check, contentDescription = "Selected", tint = color, modifier = Modifier.size(20.dp)) + } + icon != null -> Icon(icon, contentDescription = null, tint = if (header) SiloSecondaryText else color, modifier = Modifier.size(20.dp)) + } + if (checked != null || icon != null) Spacer(Modifier.width(12.dp)) + Text( + label, + fontSize = 16.sp, + fontWeight = if (header) FontWeight.SemiBold else FontWeight.Normal, + color = if (header) SiloSecondaryText else color, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Spacer(Modifier.width(16.dp)) + Text(value, fontSize = 14.sp, color = SiloSecondaryText, maxLines = 1) + } + } +} + +// MARK: Ballot (vote mode) + +/** Candidates with vote counts and a vote toggle. The leader is highlighted; Suggest is the dashed row. */ +@Composable +private fun WatchPartyBallot( + room: RoomSnapshot, + suggestions: List, + personalVotesKnown: Boolean, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: WatchPartyRoomActions, +) { + val leaderId = roomVoteWinner(suggestions)?.id + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + suggestions.forEach { suggestion -> + CandidateRow( + suggestion = suggestion, + isLeader = suggestion.id == leaderId, + votesKnown = personalVotesKnown, + canVote = eligibility.canVote && connected, + canPromote = eligibility.canPromote, + canRemove = !eligibility.busy && canRemoveSuggestion(room, suggestion), + actions = actions, + ) + } + if (suggestions.isEmpty()) { + Text( + "Nothing suggested yet. Anyone can add a title.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = SiloSecondaryText, + ) + } + val dashColor = SiloSecondaryText.copy(alpha = 0.5f) + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .alpha(if (eligibility.canSuggest) 1f else 0.45f) + .clip(RoundedCornerShape(14.dp)) + .drawBehind { + drawRoundRect( + color = dashColor, + cornerRadius = CornerRadius(14.dp.toPx()), + style = Stroke(1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(5.dp.toPx(), 4.dp.toPx()))), + ) + } + .clickable(enabled = eligibility.canSuggest, role = Role.Button, onClick = actions::suggest), + ) { + Icon(Icons.Filled.Add, contentDescription = null, tint = SiloSecondaryText, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Suggest a title", fontSize = 15.sp, fontWeight = FontWeight.Medium, color = SiloSecondaryText) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CandidateRow( + suggestion: Suggestion, + isLeader: Boolean, + votesKnown: Boolean, + canVote: Boolean, + canPromote: Boolean, + canRemove: Boolean, + actions: WatchPartyRoomActions, +) { + var menu by remember { mutableStateOf(false) } + val voted = suggestion.votedByMe + val voteEnabled = votesKnown && canVote + val hasMenu = canPromote || canRemove + val shape = RoundedCornerShape(14.dp) + val votes = "${suggestion.voteCount} ${if (suggestion.voteCount == 1) "vote" else "votes"}" + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(if (isLeader) WatchPartyColors.chromeSelectedFill else WatchPartyColors.chromeFill) + .border(1.dp, if (isLeader) SiloOnSurface.copy(alpha = 0.35f) else WatchPartyColors.chromeBorder, shape) + .combinedClickable( + enabled = voteEnabled || hasMenu, + onClick = { if (voteEnabled) actions.vote(suggestion) }, + onLongClick = if (hasMenu) ({ menu = true }) else null, + onLongClickLabel = "More actions", + ) + .semantics { + contentDescription = "${suggestion.title}, $votes" + if (isLeader) ", leading" else "" + customActions = buildList { + if (canPromote) add(CustomAccessibilityAction("Start this one") { actions.promote(suggestion); true }) + if (canRemove) add(CustomAccessibilityAction("Remove suggestion") { actions.remove(suggestion); true }) + } + } + .padding(8.dp), + ) { + WatchPartyPoster(suggestion.posterUrl, null, WatchPartyMetrics.ballotPosterWidth, cornerRadius = 6.dp) + SuggestionText(suggestion, Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(min = 48.dp)) { + Text( + "${suggestion.voteCount}", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = SiloOnSurface, + ) + Text( + if (suggestion.voteCount == 1) "VOTE" else "VOTES", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + color = SiloSecondaryText, + ) + } + val toggleShape = RoundedCornerShape(12.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(44.dp) + .alpha(if (voteEnabled) 1f else 0.45f) + .clip(toggleShape) + .background(if (voted && votesKnown) SiloOnSurface else Color.Transparent) + .border(1.dp, if (voted && votesKnown) Color.Transparent else WatchPartyColors.outline, toggleShape) + .clickable(enabled = voteEnabled, role = Role.Button) { actions.vote(suggestion) } + .semantics { + contentDescription = when { + !votesKnown -> "Loading your vote" + voted -> "Remove vote for ${suggestion.title}" + else -> "Vote for ${suggestion.title}" + } + }, + ) { + Icon( + when { + !votesKnown -> Icons.Outlined.Schedule + voted -> Icons.Filled.Check + else -> Icons.Filled.Add + }, + contentDescription = null, + tint = if (voted && votesKnown) Color.Black else SiloSecondaryText, + modifier = Modifier.size(20.dp), + ) + } + } + SiloDropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (canPromote) { + OptionRow("Start this one", icon = Icons.Filled.PlayArrow) { + menu = false + actions.promote(suggestion) + } + } + if (canRemove) { + OptionRow("Remove suggestion", icon = Icons.Outlined.Delete, destructive = true) { + menu = false + actions.remove(suggestion) + } + } + } + } +} + +@Composable +private fun SuggestionText(suggestion: Suggestion, modifier: Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + suggestion.title, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val detail = listOfNotNull( + suggestion.subtitle.takeIf { it.isNotBlank() }, + suggestion.note.takeIf { it.isNotBlank() }?.let { "“$it”" }, + ).joinToString(" · ") + if (detail.isNotEmpty()) { + Text(detail, fontSize = 12.sp, color = SiloSecondaryText, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +// MARK: Suggestions (host-pick mode) + +/** + * Guests' suggestions in a host-pick lobby. The host queues one as the pick + * ("Up next") rather than starting it; anyone can pull their own. + */ +@Composable +private fun WatchPartyHostPickSuggestions( + room: RoomSnapshot, + suggestions: List, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: WatchPartyRoomActions, +) { + val manages = room.selfRole == MemberRole.Host && room.selfCanManageRoom + Column(verticalArrangement = Arrangement.spacedBy((WatchPartyMetrics.CAPTION * 0.6f).dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + WatchPartyEyebrow("Suggestions · ${suggestions.size}") + Spacer(Modifier.width(12.dp).weight(1f)) + Text( + if (manages) "Queue one to put it up next." else "The host decides what plays.", + fontSize = WatchPartyMetrics.CAPTION.sp, + color = SiloSecondaryText, + maxLines = 1, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + suggestions.forEach { suggestion -> + HostPickSuggestionRow( + suggestion = suggestion, + queued = suggestion.contentId == room.selectedContentId, + canQueue = eligibility.canQueue && connected, + canRemove = !eligibility.busy && canRemoveSuggestion(room, suggestion), + actions = actions, + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HostPickSuggestionRow( + suggestion: Suggestion, + queued: Boolean, + canQueue: Boolean, + canRemove: Boolean, + actions: WatchPartyRoomActions, +) { + var menu by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape) + .combinedClickable( + enabled = canRemove, + onClick = {}, + onLongClick = if (canRemove) ({ menu = true }) else null, + onLongClickLabel = "More actions", + ) + .semantics { + contentDescription = suggestion.title + if (canRemove) { + customActions = listOf(CustomAccessibilityAction("Remove suggestion") { actions.remove(suggestion); true }) + } + } + .padding(8.dp), + ) { + WatchPartyPoster(suggestion.posterUrl, null, WatchPartyMetrics.ballotPosterWidth, cornerRadius = 6.dp) + SuggestionText(suggestion, Modifier.weight(1f)) + when { + queued -> Text( + "Up next", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = SiloSecondaryText, + modifier = Modifier.padding(horizontal = 8.dp), + ) + canQueue -> WatchPartyButton( + text = "Queue", + kind = WatchPartyButtonKind.Secondary, + onClick = { actions.queue(suggestion) }, + modifier = Modifier.height(44.dp).semantics { contentDescription = "Queue ${suggestion.title}" }, + ) + } + } + SiloDropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + OptionRow("Remove suggestion", icon = Icons.Outlined.Delete, destructive = true) { + menu = false + actions.remove(suggestion) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt new file mode 100644 index 0000000000..89715eb923 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt @@ -0,0 +1,612 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.siloserver.silo.android.ui.theme.SiloBackground +import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.android.ui.theme.SiloSecondaryText +import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated +import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.watchtogether.WatchPartySeatState +import org.siloserver.silo.watchtogether.watchPartyAvatarIndex +import org.siloserver.silo.watchtogether.watchPartyPresenceSummary +import org.siloserver.silo.watchtogether.watchPartySeatOrder +import org.siloserver.silo.watchtogether.watchPartySeatState +import org.siloserver.silo.watchtogether.watchPartySeatStatus + +/* + * The Watch Party look, after silo-apple's WatchPartyComponents (iOS values): + * the film's artwork is the room, members sit over it as seats, and actions + * are white-on-black pills. + */ + +internal object WatchPartyMetrics { + val seat = 56.dp + val seatGap = 12.dp + val pageInset = 20.dp + const val HERO_TITLE = 28f + const val BODY = 15f + const val CAPTION = 13f + const val EYEBROW = 11f + const val CODE = 20f + val ballotPosterWidth = 44.dp +} + +internal object WatchPartyColors { + val chromeFill = Color.White.copy(alpha = 0.10f) + val chromeBorder = Color.White.copy(alpha = 0.14f) + val chromeSelectedFill = Color.White.copy(alpha = 0.18f) + val outline = Color.White.copy(alpha = 0.28f) + val amber = Color(0xFFF59E0B) + val amberText = Color(0xFFF5C563) + val emerald = Color(0xFF10B981) + val rose = Color(0xFFF43F5E) +} + +@Composable +internal fun WatchPartyEyebrow(text: String, modifier: Modifier = Modifier, color: Color = SiloSecondaryText) { + Text( + text = text.uppercase(), + fontSize = WatchPartyMetrics.EYEBROW.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (WatchPartyMetrics.EYEBROW * 0.12f).sp, + color = color, + modifier = modifier, + ) +} + +/** + * Full-bleed artwork under the room. A poster (when that is all there is) is + * blurred and dimmed so it tints the page rather than competing with it. + */ +@Composable +internal fun WatchPartyBackdrop( + url: String?, + thumbhash: String? = null, + isPoster: Boolean = false, + modifier: Modifier = Modifier, +) { + Box(modifier.fillMaxSize().background(SiloBackground)) { + Crossfade(targetState = url?.takeIf { it.isNotBlank() }, animationSpec = tween(400), label = "backdrop") { art -> + if (art != null) { + ThumbhashImage( + url = art, + thumbhash = thumbhash, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .then(if (isPoster) Modifier.blur(28.dp).alpha(0.7f) else Modifier), + ) + } + } + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.6f), + 0.22f to Color.Black.copy(alpha = 0.25f), + 0.45f to Color.Black.copy(alpha = 0.72f), + 0.62f to Color.Black.copy(alpha = 0.94f), + 0.78f to Color.Black, + ), + ), + ) + } +} + +/** Transparent bar: a round Back and the centred page title. */ +@Composable +internal fun WatchPartyTopBar(onBack: (() -> Unit)?, modifier: Modifier = Modifier, title: String = "Watch Party") { + Box(modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp)) { + if (onBack != null) { + WatchPartyCircleButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Navigate back", + onClick = onBack, + size = 44.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + Text( + title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +internal fun WatchPartyCircleButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 36.dp, + enabled: Boolean = true, + shape: Shape = CircleShape, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(size) + .clip(shape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, shape) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics { this.contentDescription = contentDescription }, + ) { + Icon(icon, contentDescription = null, tint = SiloOnSurface, modifier = Modifier.size(size * 0.5f)) + } +} + +/** The room code is the invitation: one monospaced pill that opens it. */ +@Composable +internal fun WatchPartyCodePill(code: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val size = WatchPartyMetrics.CODE + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((size * 0.5f).dp), + modifier = modifier + .clip(CircleShape) + .background(WatchPartyColors.chromeFill) + .border(1.dp, WatchPartyColors.chromeBorder, CircleShape) + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { contentDescription = "Party code ${code.toList().joinToString(" ")}. Invite friends" } + .padding(horizontal = (size * 0.75f).dp, vertical = (size * 0.4f).dp), + ) { + Text( + code, + fontSize = size.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = (size * 0.18f).sp, + color = SiloOnSurface, + maxLines = 1, + ) + Icon( + Icons.Filled.QrCode2, + contentDescription = null, + tint = SiloSecondaryText, + modifier = Modifier.size((size * 0.9f).dp), + ) + } +} + +private val avatarPalette = listOf( + Color(0xFF5A4A8A) to Color(0xFF2B2450), + Color(0xFF8A5A3A) to Color(0xFF4A2C1C), + Color(0xFF3A7A6A) to Color(0xFF1C3F36), + Color(0xFF7A3A5A) to Color(0xFF3F1C30), + Color(0xFF4A6A8A) to Color(0xFF213546), + Color(0xFF7A6A2A) to Color(0xFF3F3616), +) + +/** The member's avatar colours, chosen as Apple chooses them so a member keeps one colour everywhere. */ +internal fun watchPartyAvatarGradient(member: RoomMember): Brush { + val (start, end) = avatarPalette[watchPartyAvatarIndex(member, avatarPalette.size)] + return Brush.linearGradient(listOf(start, end)) +} + +/** + * One member as a seat: an avatar disc whose ring encodes state. Dashed grey + * is here but not ready, solid white with a tick is ready (or watching), + * dotted and dimmed is reconnecting. + */ +@Composable +internal fun WatchPartySeat(member: RoomMember, state: WatchPartySeatState, size: Dp = WatchPartyMetrics.seat) { + val ring = size * 1.14f + val ready = state == WatchPartySeatState.Ready || state == WatchPartySeatState.Watching + val away = state == WatchPartySeatState.Away + val name = watchPartyMemberName(member) + val status = watchPartySeatStatus(state) + val description = buildList { + add(name) + if (member.isSelf) add("you") + if (member.isHost) add("host") + add( + when (state) { + WatchPartySeatState.Ready -> "ready" + WatchPartySeatState.Watching -> "watching" + else -> status.orEmpty() + }, + ) + }.filter { it.isNotBlank() }.joinToString(", ") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.11f), + modifier = Modifier.width(size * 1.4f).semantics(mergeDescendants = true) { contentDescription = description }, + ) { + Box(Modifier.padding(top = size * 0.1f).size(ring), contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(size) + .alpha(if (away) 0.45f else 1f) + .clip(CircleShape) + .background(watchPartyAvatarGradient(member)), + ) { + Text( + name.trim().take(1).uppercase(), + fontSize = (size.value * 0.36f).sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + ) + } + SeatRing(state = state, modifier = Modifier.size(ring)) + if (member.isHost) { + Text( + "HOST", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = SiloOnSurface, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = -(size * 0.12f)) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black) + .border(1.dp, WatchPartyColors.chromeBorder, RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) + } + if (ready) { + // Sits on the ring at 4:30, cutting into it. + val offset = ring / 2 * 0.7071f + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset(x = offset, y = offset) + .size(size * 0.34f) + .clip(CircleShape) + .background(Color.Black) + .padding(size * 0.03f) + .clip(CircleShape) + .background(SiloOnSurface), + ) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(size * 0.2f), + ) + } + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + buildAnnotatedString { + append(name) + if (member.isSelf) { + withStyle(SpanStyle(color = SiloSecondaryText, fontWeight = FontWeight.Normal)) { append(" · you") } + } + }, + fontSize = (size.value * 0.19f).sp, + fontWeight = FontWeight.Medium, + color = SiloOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + if (status != null) { + Text( + status, + fontSize = (size.value * 0.16f).sp, + color = SiloSecondaryText, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun SeatRing(state: WatchPartySeatState, modifier: Modifier) { + Canvas(modifier) { + val width = this.size.minDimension * 0.035f / 1.14f + val radius = (this.size.minDimension - width) / 2 + when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> + drawCircle(SiloOnSurface, radius = radius, style = Stroke(width)) + WatchPartySeatState.Away -> drawCircle( + SiloOnSurface.copy(alpha = 0.25f), + radius = radius, + style = Stroke( + width, + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(0.1f, width * 3)), + ), + ) + else -> drawCircle( + SiloOnSurface.copy(alpha = 0.38f), + radius = radius, + style = Stroke(width, pathEffect = PathEffect.dashPathEffect(floatArrayOf(width * 3, width * 2.2f))), + ) + } + } +} + +/** The dashed "+" seat that opens the invitation. */ +@Composable +private fun WatchPartyInviteSeat(onClick: () -> Unit, size: Dp = WatchPartyMetrics.seat) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.11f), + modifier = Modifier + .width(size * 1.4f) + .clip(RoundedCornerShape(12.dp)) + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { contentDescription = "Invite friends" }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(top = size * 0.1f) + .size(size * 1.14f) + .border(size * 0.035f, WatchPartyColors.outline, CircleShape), + ) { + Icon(Icons.Filled.Add, contentDescription = null, tint = SiloSecondaryText, modifier = Modifier.size(size * 0.4f)) + } + Text("Invite", fontSize = (size.value * 0.19f).sp, fontWeight = FontWeight.Medium, color = SiloSecondaryText) + } +} + +/** "Here now" with its presence summary, then the seats and the Invite seat. */ +@Composable +internal fun WatchPartySeatsRow( + members: List, + phase: RoomPhase, + onInvite: () -> Unit, + modifier: Modifier = Modifier, + seatSize: Dp = WatchPartyMetrics.seat, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(seatSize * 0.25f)) { + Row(horizontalArrangement = Arrangement.spacedBy(seatSize * 0.2f), verticalAlignment = Alignment.CenterVertically) { + WatchPartyEyebrow("Here now") + Text( + watchPartyPresenceSummary(members, phase), + fontSize = WatchPartyMetrics.CAPTION.sp, + color = SiloSecondaryText.copy(alpha = 0.7f), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(WatchPartyMetrics.seatGap), + verticalAlignment = Alignment.Top, + modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 2.dp), + ) { + watchPartySeatOrder(members).forEach { member -> + WatchPartySeat(member, watchPartySeatState(member, phase), seatSize) + } + WatchPartyInviteSeat(onClick = onInvite, size = seatSize) + } + } +} + +internal enum class WatchPartyButtonKind { Primary, Secondary, Outlined } + +/** Apple's lobby button: 52dp, 14dp corners, white fill for the primary. */ +@Composable +internal fun WatchPartyButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: WatchPartyButtonKind = WatchPartyButtonKind.Primary, + enabled: Boolean = true, + icon: ImageVector? = null, + trailing: (@Composable RowScope.() -> Unit)? = null, +) { + val shape = RoundedCornerShape(14.dp) + val foreground = if (kind == WatchPartyButtonKind.Primary) Color.Black else SiloOnSurface + val background = when (kind) { + WatchPartyButtonKind.Primary -> SiloOnSurface + WatchPartyButtonKind.Secondary -> WatchPartyColors.chromeFill + WatchPartyButtonKind.Outlined -> Color.Transparent + } + val border = when (kind) { + WatchPartyButtonKind.Primary -> Color.Transparent + WatchPartyButtonKind.Secondary -> WatchPartyColors.chromeBorder + WatchPartyButtonKind.Outlined -> SiloOnSurface + } + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .heightIn(min = 52.dp) + .alpha(if (enabled) 1f else 0.45f) + .clip(shape) + .background(background) + .border(BorderStroke(1.dp, border), shape) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + if (icon != null) { + Icon(icon, contentDescription = null, tint = foreground, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + } + Text( + text, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + trailing?.invoke(this) + } +} + +internal enum class WatchPartyBannerTone { Warning, Neutral } + +/** One banner for anything the member should know. [action] adds trailing buttons. */ +@Composable +internal fun WatchPartyBanner( + text: String, + modifier: Modifier = Modifier, + tone: WatchPartyBannerTone = WatchPartyBannerTone.Neutral, + action: (@Composable () -> Unit)? = null, +) { + val warning = tone == WatchPartyBannerTone.Warning + val shape = RoundedCornerShape(12.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(if (warning) WatchPartyColors.amber.copy(alpha = 0.12f) else WatchPartyColors.chromeFill) + .border(1.dp, if (warning) WatchPartyColors.amber.copy(alpha = 0.3f) else WatchPartyColors.chromeBorder, shape) + .padding(start = 13.dp, end = if (action != null) 4.dp else 13.dp, top = 10.dp, bottom = 10.dp), + ) { + Box( + Modifier + .size((WatchPartyMetrics.CAPTION * 0.6f).dp) + .clip(CircleShape) + .background(if (warning) WatchPartyColors.amber else SiloSecondaryText), + ) + Spacer(Modifier.width(10.dp)) + Text( + text, + fontSize = (WatchPartyMetrics.CAPTION + 1).sp, + color = if (warning) WatchPartyColors.amberText else SiloOnSurface, + modifier = Modifier.weight(1f), + ) + action?.invoke() + } +} + +@Composable +internal fun WatchPartyPoster( + url: String?, + thumbhash: String?, + width: Dp, + modifier: Modifier = Modifier, + cornerRadius: Dp = 8.dp, +) { + val shape = RoundedCornerShape(cornerRadius) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.width(width).height(width * 1.5f).clip(shape).background(SiloSurfaceElevated), + ) { + if (url.isNullOrBlank()) { + Icon(Icons.Outlined.Movie, contentDescription = null, tint = SiloSecondaryText) + } else { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +/** A poster with Apple's lifted shadow, for the lobby hero and the confirmation page. */ +@Composable +internal fun WatchPartyHeroPoster(url: String?, thumbhash: String?, width: Dp) { + WatchPartyPoster( + url = url, + thumbhash = thumbhash, + width = width, + modifier = Modifier.shadow(16.dp, RoundedCornerShape(8.dp), ambientColor = Color.Black, spotColor = Color.Black), + ) +} + +/** Soft black under pinned bottom actions. */ +internal val WatchPartyBottomScrim = Brush.verticalGradient( + 0f to Color.Transparent, + 0.35f to Color.Black.copy(alpha = 0.9f), + 1f to Color.Black, +) + +/** The quiet trailing hint line under the lobby's buttons. */ +@Composable +internal fun WatchPartyHint(text: String, modifier: Modifier = Modifier) { + Text( + text, + fontSize = WatchPartyMetrics.CAPTION.sp, + color = SiloSecondaryText.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = modifier.fillMaxWidth(), + ) +} + +/** Title-sized heading helper. */ +@Composable +internal fun WatchPartyHeroTitle(text: String, color: Color = SiloOnSurface, size: Float = WatchPartyMetrics.HERO_TITLE, maxLines: Int = 3) { + Text( + text, + fontSize = size.sp, + lineHeight = (size * 1.12f).sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-size * 0.02f).sp, + color = color, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupport.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupport.kt new file mode 100644 index 0000000000..1c24e64755 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupport.kt @@ -0,0 +1,116 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import java.security.MessageDigest +import org.siloserver.silo.model.catalog.FileVersion +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.repository.WatchPartyEndReason +import org.siloserver.silo.watchtogether.WatchPartyInvite +import org.siloserver.silo.watchtogether.parseWatchPartyInvite + +/** + * A `silo://watch-party?server=…&token=…` app link, validated by the shared + * invitation parser. Anything else (including `silo://invite`, the account + * invitation) is not a party link. + */ +internal fun watchPartyAppLinkOrNull(rawUri: String?): WatchPartyInvite.Link? { + val text = rawUri?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (!text.startsWith("silo://", ignoreCase = true)) return null + return parseWatchPartyInvite(text) as? WatchPartyInvite.Link +} + +/** + * A stable, non-reversible id for an invitation link. It names the pending + * invitation in the hub route and in the Activity's consumed-route marker, so + * a task rebuilt after process death does not join again, while the token + * itself stays out of every route and saved state. + */ +internal fun watchPartyInviteHandoffId(link: WatchPartyInvite.Link): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest("${link.serverUrl}\n${link.token}".toByteArray(Charsets.UTF_8)) + return digest.take(INVITE_ID_BYTES).joinToString("") { "%02x".format(it) } +} + +private const val INVITE_ID_BYTES = 12 + +/** What the lobby and player say when this device's party ended. */ +internal fun watchPartyEndedMessage(reason: String): String = when (reason) { + WatchPartyEndReason.Replaced -> "This profile joined the Watch Party on another device." + WatchPartyEndReason.ConnectionLost -> "Lost connection to the party." + WatchPartyEndReason.Unauthorized -> "Sign in again to use Watch Party." + else -> "The Watch Party has ended." +} + +/** The party itself goes on after these, so the hub offers Rejoin. */ +internal fun watchPartyEndedOffersRejoin(reason: String): Boolean = + reason == WatchPartyEndReason.Replaced || reason == WatchPartyEndReason.ConnectionLost + +internal fun watchPartyMemberName(member: RoomMember): String = + member.displayName.ifBlank { "Someone" } + +/** "Alex", "Alex and Sam", "Alex, Sam, and Kim", "Alex, Sam, and 3 others". */ +internal fun formatWatchPartyNames(names: List): String = when (names.size) { + 0 -> "" + 1 -> names[0] + 2 -> "${names[0]} and ${names[1]}" + 3 -> "${names[0]}, ${names[1]}, and ${names[2]}" + else -> "${names[0]}, ${names[1]}, and ${names.size - 2} others" +} + +/** The other members the room is waiting for while it syncs playback. */ +internal fun watchPartyWaitingNames(room: RoomSnapshot?): List { + if (room == null || room.phase != RoomPhase.Playing || room.playbackState != RoomPlaybackState.Waiting) { + return emptyList() + } + return room.members.filter { it.isSyncing && !it.isSelf }.map(::watchPartyMemberName) +} + +/** + * Members the room went on without: they were syncing while the room waited + * and are still not ready once it plays again at the same selection. The + * viewer is left out; their own status is the catching-up notice. A member + * who left the room is not "left behind". + */ +internal fun watchPartyLeftBehindNames(previous: RoomSnapshot?, next: RoomSnapshot?): List { + if (previous == null || next == null) return emptyList() + if (previous.roomId != next.roomId || previous.selectionRevision != next.selectionRevision) return emptyList() + if (previous.phase != RoomPhase.Playing || next.phase != RoomPhase.Playing) return emptyList() + if (previous.playbackState != RoomPlaybackState.Waiting || next.playbackState != RoomPlaybackState.Playing) { + return emptyList() + } + return previous.members + .filter { it.isSyncing && !it.isSelf } + .mapNotNull { before -> + val after = next.members.firstOrNull { it.isSameMemberAs(before) } ?: return@mapNotNull null + watchPartyMemberName(after).takeIf { !after.isReady } + } +} + +private fun RoomMember.isSameMemberAs(other: RoomMember): Boolean = + userId.isNotBlank() && profileId.isNotBlank() && userId == other.userId && profileId == other.profileId + +/** Share text: the host's invitation link with the code, or the code alone for guests. */ +internal fun watchPartyShareText(code: String, inviteUrl: String?): String = + if (inviteUrl != null) { + "Join my Watch Party on Silo: $inviteUrl\nOr enter the code $code." + } else { + "Join my Watch Party on Silo with the code $code." + } + +/** Quality chips for the staged version, for example "4K" and "HDR". */ +internal fun watchPartyQualityChips(version: FileVersion?): List { + version ?: return emptyList() + val resolution = version.resolution?.lowercase().orEmpty() + return listOfNotNull( + "4K".takeIf { "2160" in resolution || "4k" in resolution }, + "HDR".takeIf { version.hdr }, + ) +} + +/** "2h 35m" or "48m", as Apple's `WatchPartyFacts.runtime`. */ +internal fun watchPartyRuntime(minutes: Int?): String? { + val value = minutes?.takeIf { it > 0 } ?: return null + return if (value >= 60) "${value / 60}h ${value % 60}m" else "${value}m" +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/SuggestToRoomViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/SuggestToRoomViewModel.kt deleted file mode 100644 index 3f6fcc96c4..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/SuggestToRoomViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.siloserver.silo.android.ui.screens.watchtogether - -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.siloserver.silo.model.watchtogether.AddSuggestionRequest -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.errorMessage -import org.siloserver.silo.repository.WatchTogetherRepository - -class SuggestToRoomViewModel( - private val repository: WatchTogetherRepository, -) : ViewModel() { - data class UiState( - val isBusy: Boolean = false, - val notice: String? = null, - val error: String? = null, - ) - - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow = _uiState.asStateFlow() - val room: StateFlow = repository.roomSnapshot - - fun suggest( - contentId: String, - contentType: String, - title: String, - subtitle: String?, - posterUrl: String?, - ) { - if (_uiState.value.isBusy) return - _uiState.update { it.copy(isBusy = true, error = null, notice = null) } - viewModelScope.launch { - val result = repository.addSuggestion( - AddSuggestionRequest( - contentId = contentId, - contentType = contentType, - title = title, - subtitle = subtitle, - posterUrl = posterUrl, - ), - ) - when (result) { - is ApiResult.Success -> - _uiState.update { it.copy(isBusy = false, notice = "Suggested to the room") } - is ApiResult.Error, is ApiResult.NetworkError -> - _uiState.update { - it.copy(isBusy = false, error = result.errorMessage("Could not suggest that")) - } - } - } - } - - fun consumeNotice() = _uiState.update { it.copy(notice = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt deleted file mode 100644 index ea5af2f0c7..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.siloserver.silo.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.text.KeyboardOptions -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.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -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.siloserver.silo.watchtogether.canDismissRoomEntry -import org.koin.compose.viewmodel.koinViewModel - -/** - * Watch Together entry sheet, opened from the item-detail overflow. - * Host = create a room with this title pre-selected. Join = enter an - * invite code. On success [onNavigate] fires with the resolved route - * (synced player or lobby). Styled after the other detail bottom sheets. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WatchTogetherEntrySheet( - contentId: String, - fileId: Int?, - onNavigate: (String) -> Unit, - onDismiss: () -> Unit, - viewModel: WatchTogetherEntryViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - var code by remember { mutableStateOf("") } - var showJoin by remember { mutableStateOf(false) } - val latestBusy by rememberUpdatedState(state.busy) - - LaunchedEffect(state.destination) { - val dest = state.destination ?: return@LaunchedEffect - viewModel.consumeDestination() - onDismiss() - onNavigate(dest) - } - - ModalBottomSheet( - onDismissRequest = { - if (canDismissRoomEntry(state.busy)) onDismiss() - }, - sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { target -> - target != SheetValue.Hidden || canDismissRoomEntry(latestBusy) - }, - ), - containerColor = MaterialTheme.colorScheme.surface, - ) { - Text( - text = "Watch Together", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - state.error?.let { - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) - } - - if (!showJoin) { - Button( - onClick = { viewModel.host(contentId, fileId) }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (state.busy) "Creating…" else "Host a room") } - - OutlinedButton( - onClick = { viewModel.hostEmptyVoteRoom() }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Host a vote 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().take(8) }, - label = { Text("Invite code") }, - singleLine = true, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), - modifier = Modifier.fillMaxWidth(), - ) - Button( - onClick = { viewModel.joinByCode(code) }, - enabled = !state.busy && code.length >= 4, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.busy) CircularProgressIndicator(modifier = Modifier.height(18.dp)) - else Text("Join") - } - OutlinedButton( - onClick = { viewModel.clearError(); showJoin = false }, - enabled = !state.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Back") } - } - } - Spacer(Modifier.height(24.dp)) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt deleted file mode 100644 index 890205c73f..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.siloserver.silo.android.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.siloserver.silo.android.ui.navigation.Route -import org.siloserver.silo.model.watchtogether.CreateRoomRequest -import org.siloserver.silo.model.watchtogether.JoinRoomRequest -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.SetSelectionRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.errorMessage -import org.siloserver.silo.watchtogether.WatchTogetherEntryGateway -import org.siloserver.silo.watchtogether.WatchTogetherEntryTarget -import org.siloserver.silo.watchtogether.resumableWatchTogetherRoom -import org.siloserver.silo.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 - -/** - * Pure navigation decision: after create/join, where do we send the user? - * If the room already has a selected title -> straight to the synced player - * (carrying roomId). Otherwise -> the lobby to wait / vote / pick. - * - * Kept top-level + pure so it is unit-testable without Compose or the repo. - */ -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 - } - -/** - * Backs the [WatchTogetherEntrySheet]. Hosts a room (create + set this title as - * the room selection) or joins an existing room by invite code, then resolves a - * navigation [UiState.destination] the sheet observes. - * - * 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 gateway: WatchTogetherEntryGateway, -) : ViewModel() { - - data class UiState( - val busy: Boolean = false, - val error: String? = null, - /** Set once a create/join resolves — the sheet observes this and navigates. */ - val destination: String? = null, - ) - - 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( - contentId: String, - 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 = gateway.createRoom( - CreateRoomRequest(selectionMode = selectionMode.wire), - ) - ) { - is ApiResult.Success -> { - // Set this title as the room selection so everyone lands on it. - when ( - val sel = gateway.setSelection( - SetSelectionRequest(contentId = contentId, fileId = fileId), - ) - ) { - is ApiResult.Success -> finish(sel.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(sel.errorMessage("Failed to set selection")) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> - fail(created.errorMessage("Failed to create room")) - } - } - } - - /** 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 = 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")) - } - } - } - - private fun finish(room: RoomSnapshot) { - _uiState.update { it.copy(busy = false, error = null, destination = watchTogetherDestination(room)) } - } - - private fun fail(message: String) { - _uiState.update { it.copy(busy = false, error = message) } - } - - fun consumeDestination() = _uiState.update { it.copy(destination = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt deleted file mode 100644 index 0f491479ed..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt +++ /dev/null @@ -1,226 +0,0 @@ -package org.siloserver.silo.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 -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -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 -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.Suggestion -import org.siloserver.silo.watchtogether.isVoteRoom -import org.siloserver.silo.watchtogether.roomVoteWinner -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf - -/** - * Watch Together lobby. Shows member count / role / selection mode, the - * suggestion list (vote / unvote, host promote + remove), an invite-code share - * affordance, and auto-navigates everyone into the synced player once the host - * picks/promotes a title (room transitions to [org.siloserver.silo.model.watchtogether.RoomPhase.Playing] - * with a selection). - * - * The "add a suggestion" affordance is deferred — the host pre-selection comes - * from item-detail; this screen focuses on vote/unvote/promote/pick + invite. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WatchTogetherLobbyScreen( - roomId: String, - onNavigateToPlayer: (String) -> Unit, - onBack: () -> Unit, - viewModel: WatchTogetherLobbyViewModel = koinViewModel(parameters = { parametersOf(roomId) }), -) { - val room by viewModel.room.collectAsState() - val suggestions by viewModel.suggestions.collectAsState() - val closedReason by viewModel.roomClosedReason.collectAsState() - val context = LocalContext.current - - // Auto-navigate into the synced player when the room starts playing. - LaunchedEffect(room?.phase, room?.selectedContentId, room?.memberCount, room?.selfRole) { - val snapshot = room ?: return@LaunchedEffect - lobbyPlayerDestinationOrNull(snapshot)?.let { onNavigateToPlayer(it) } - } - - // Server closed the room (host left / explicit close) — back out. - LaunchedEffect(closedReason) { - 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. - val isHostLabel = room?.selfRole == MemberRole.Host - val canManage = room?.selfCanManageRoom == true - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Watch Together") }, - 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") } - } - }, - ) - }, - ) { padding -> - Column( - Modifier.padding(padding).fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - val snapshot = room - if (snapshot == null) { - CircularProgressIndicator() - } else { - Text( - "${snapshot.memberCount} in room · ${if (isHostLabel) "Host" else "Guest"}", - style = MaterialTheme.typography.titleMedium, - ) - Text( - "Mode: ${selectionModeLabel(snapshot.selectionMode)}", - style = MaterialTheme.typography.bodyMedium, - ) - - // Invite code share row - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Invite code: ${snapshot.code}", style = MaterialTheme.typography.bodyLarge) - Spacer(Modifier.width(12.dp)) - IconButton(onClick = { - val send = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "Join my Watch Together room with code ${snapshot.code}", - ) - } - context.startActivity(Intent.createChooser(send, "Share invite code")) - }) { Icon(Icons.Filled.Share, contentDescription = "Share invite code") } - } - - HorizontalDivider() - val isVoteRoom = room.isVoteRoom() - val winner = roomVoteWinner(suggestions) - if (isVoteRoom && canManage) { - Button( - onClick = { winner?.let { viewModel.promote(it.id) } }, - enabled = winner != null, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - winner?.let { "Start winner: ${it.title}" } - ?: "Start winner — no votes yet", - ) - } - } - Text("Suggestions", style = MaterialTheme.typography.titleSmall) - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(suggestions, key = { it.id }) { s -> - SuggestionRow( - suggestion = s, - canManage = canManage, - isWinning = isVoteRoom && winner?.id == s.id, - onVote = { if (s.votedByMe) viewModel.unvote(s.id) else viewModel.vote(s.id) }, - onPromote = { viewModel.promote(s.id) }, - onRemove = { viewModel.removeSuggestion(s.id) }, - ) - } - } - } - } - } -} - -private fun selectionModeLabel(mode: RoomSelectionMode): String = when (mode) { - RoomSelectionMode.HostPick -> "Host pick" - RoomSelectionMode.Vote -> "Vote" - RoomSelectionMode.Unknown -> "—" -} - -@Composable -private fun SuggestionRow( - suggestion: Suggestion, - canManage: Boolean, - isWinning: Boolean, - onVote: () -> Unit, - onPromote: () -> Unit, - onRemove: () -> Unit, -) { - Card(Modifier.fillMaxWidth()) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(suggestion.title, style = MaterialTheme.typography.bodyLarge) - if (isWinning) { - Text("WINNING", style = MaterialTheme.typography.labelSmall) - } - if (suggestion.subtitle.isNotBlank()) { - Text(suggestion.subtitle, style = MaterialTheme.typography.bodySmall) - } - } - TextButton(onClick = onVote) { - Icon(Icons.Filled.HowToVote, contentDescription = "Vote") - Spacer(Modifier.width(4.dp)) - Text("${suggestion.voteCount}${if (suggestion.votedByMe) " ✓" else ""}") - } - if (canManage) { - TextButton(onClick = onPromote) { Text("Pick") } - TextButton(onClick = onRemove) { Text("Remove") } - } - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt deleted file mode 100644 index b06485d8d0..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt +++ /dev/null @@ -1,109 +0,0 @@ -package org.siloserver.silo.android.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.siloserver.silo.android.ui.navigation.Route -import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.Suggestion -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.errorMessage -import org.siloserver.silo.repository.WatchTogetherRepository -import org.siloserver.silo.watchtogether.RoomSession -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -/** - * Pure: should the lobby jump to the synced player? Only once the room is - * actually playing AND a title is selected. Returns the player route or null. - * - * Compares against the real [RoomPhase] enum (not a wire string) — the landed - * shared model uses lenient enums, see WatchTogetherModels.kt. - */ -fun lobbyPlayerDestinationOrNull(room: RoomSnapshot): String? = - if (room.phase == RoomPhase.Playing && - !room.selectedContentId.isNullOrBlank() && - !(room.selfRole == MemberRole.Host && room.memberCount <= 1) - ) { - Route.Player( - contentId = room.selectedContentId!!, - fileId = room.selectedFileId, - roomId = room.roomId, - ).route - } else { - null - } - -/** - * Backs [WatchTogetherLobbyScreen]. Binds the per-room websocket on enter so - * snapshots/suggestions flow into the state flows, and exposes vote / unvote / - * host-promote / host-pick / close-room ops. - * - * The repository owns the room JWT and reads the active roomId from its own - * snapshot, so the room-scoped ops take only request/id params (not a roomId). - * The application-scoped [RoomSession] owns reconnect and survives navigation - * from this lobby into the synced player. - */ -class WatchTogetherLobbyViewModel( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, -) : ViewModel() { - - init { - roomSession.adopt(roomId) - } - - val room: StateFlow = repository.roomSnapshot - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomSnapshot.value) - val suggestions: StateFlow> = repository.suggestions - .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 removeSuggestion(suggestionId: String) = - 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) = - launchOperation("Could not start suggestion") { - repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) - } - - 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() { - roomSession.depart() - } - - override fun onCleared() { - super.onCleared() - // Do NOT reset here — the player binding reuses the same repo connection - // when we auto-navigate into the synced player. reset() is called - // explicitly via leave() when the user backs out without playing. - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt deleted file mode 100644 index f836e41dcd..0000000000 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt +++ /dev/null @@ -1,127 +0,0 @@ -package org.siloserver.silo.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.siloserver.silo.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/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index 316ed5176f..10b218f651 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -73,6 +73,7 @@ import org.siloserver.silo.common.player.PlaybackSessionManager import org.siloserver.silo.common.player.SleepTimerController import org.siloserver.silo.common.player.StartParams import org.siloserver.silo.common.player.VideoSessionStartV3 +import org.siloserver.silo.common.player.video.PlaybackDiagnosticsCode import org.siloserver.silo.common.player.video.VideoPlaybackSessionCoordinator import org.siloserver.silo.common.player.video.VideoPlaybackStartRequest import org.siloserver.silo.common.player.video.VideoPlaybackStartResult @@ -116,6 +117,7 @@ import org.siloserver.silo.repository.port.NoOpUserItemStatePort import org.siloserver.silo.repository.port.UserItemStatePort import org.siloserver.silo.playback.audioTrackFingerprint import org.siloserver.silo.playback.encodeCatalogSubtitlePreference +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -511,6 +513,177 @@ class PlayerViewModelLoadOwnershipIntegrationTest { } } + @Test + fun roomStartStreamsTheRoomFilePausedAndEveryRestartKeepsTheRoom() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + val room = WatchPartyPlaybackContext( + roomId = "room", + selectionRevision = 3, + contentId = "movie", + fileId = 41, + libraryId = 7, + positionSeconds = 120.0, + paused = false, + ) + viewModel.startRoomPlayback(room) + starter.awaitRequestCount(1) + val request = starter.request(0) + assertEquals(room, request.room) + assertEquals("room", request.roomId) + assertEquals("movie", request.contentId) + assertEquals(41, request.preferredFileId) + assertEquals(7, request.libraryId) + + starter.complete(0, ready(request, "session-3")) + viewModel.awaitState { it.sessionId == "session-3" && !it.isLoading } + advanceUntilIdle() + // Only the room's command may start playback. + assertTrue(viewModel.uiState.value.isPaused) + assertFalse(viewModel.uiState.value.isPlaying) + assertEquals("session-3", viewModel.roomObservation.value.sessionId) + assertEquals(3L, viewModel.roomObservation.value.selectionRevision) + + // The screen collecting the same epoch again (recreation) restarts nothing. + viewModel.startRoomPlayback(room) + advanceUntilIdle() + assertEquals(1, starter.startedRequestCount) + + // A new epoch never exposes the outgoing session under its revision. + val next = room.copy(selectionRevision = 4, fileId = 42) + viewModel.startRoomPlayback(next) + starter.awaitRequestCount(2) + advanceUntilIdle() + assertNull(viewModel.roomObservation.value.sessionId) + assertEquals(4L, viewModel.roomObservation.value.selectionRevision) + assertTrue(viewModel.roomObservation.value.seekPending) + + // Retry and Try Anyway replay the room start, never a solo one. + starter.complete(1, VideoPlaybackStartResult.ServerUnreachable(contentId = "movie")) + viewModel.awaitState { it.serverUnreachable } + viewModel.playIgnoringServerReachability() + starter.awaitRequestCount(3) + val retry = starter.request(2) + assertEquals(next, retry.room) + assertEquals(42, retry.preferredFileId) + assertTrue(retry.force) + } finally { + store.clear() + } + } + + @Test + fun roomStartRefusalIsHandedToTheRoomWithoutChoosingAnotherFile() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + val room = WatchPartyPlaybackContext( + roomId = "room", + selectionRevision = 5, + contentId = "movie", + fileId = 41, + libraryId = null, + positionSeconds = 0.0, + paused = true, + ) + viewModel.startRoomPlayback(room) + starter.awaitRequestCount(1) + starter.complete( + 0, + VideoPlaybackStartResult.Error( + contentId = "movie", + message = "This title needs HDR tone mapping.", + diagnosticsCode = PlaybackDiagnosticsCode.serverTerminal("hdr_transcode_unsupported"), + terminalReason = "hdr_transcode_unsupported", + ), + ) + viewModel.awaitState { it.error != null } + advanceUntilIdle() + assertEquals( + PlayerViewModel.RoomRefusal(room, fileId = 41, reason = "hdr_transcode_unsupported"), + viewModel.roomRefusal.value, + ) + assertEquals(1, starter.startedRequestCount) + viewModel.roomRefusal.value?.let(viewModel::consumeRoomRefusal) + assertNull(viewModel.roomRefusal.value) + } finally { + store.clear() + } + } + + @Test + fun outsideRoomSeekStaysUnreportableUntilTheRestoreLands() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + viewModel.startRoomPlayback( + WatchPartyPlaybackContext("room", 3, "movie", 41, null, 20.0, false), + ) + starter.awaitRequestCount(1) + starter.complete(0, ready(starter.request(0), "session-3")) + viewModel.awaitState { it.sessionId == "session-3" && !it.isLoading } + advanceUntilIdle() + viewModel.onMediaMountApplied(viewModel.uiState.value.mediaMountGeneration) + + fun sample(positionMs: Long) { + viewModel.onRoomEngineSample( + positionMs, positionMs, playWhenReady = true, isPlaying = true, + playbackState = androidx.media3.common.Player.STATE_READY, + ) + viewModel.onPositionChanged(positionMs, 300_000L) + } + sample(20_000L) + assertFalse(viewModel.roomObservation.value.seekPending) + + // A MediaSession controller has already jumped to 80 seconds. + // PlayerScreen arms the undo before publishing that sample. + viewModel.applyRoomSeek(20.0) + sample(80_000L) + assertEquals(80.0, viewModel.roomObservation.value.sourcePositionSeconds) + assertTrue(viewModel.roomObservation.value.seekPending) + assertEquals(20.0, viewModel.immediateSeeks.first()) + + sample(20_000L) + assertEquals(20.0, viewModel.roomObservation.value.sourcePositionSeconds) + assertFalse(viewModel.roomObservation.value.seekPending) + } finally { + store.clear() + } + } + + @Test + fun roomCommandsHoldALocalSuspensionUntilTheViewerResumes() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + viewModel.setInWatchTogetherRoom(true) + viewModel.applyRoomPlaying(true) + assertFalse(viewModel.uiState.value.isPaused) + + viewModel.beginRoomSuspension(PlayerViewModel.RoomSuspensionReason.SleepTimer) + assertTrue(viewModel.uiState.value.isPaused) + assertTrue(viewModel.roomSuspended.value) + // The room plays on; this device stays held. + viewModel.applyRoomPlaying(true) + assertTrue(viewModel.uiState.value.isPaused) + + viewModel.endRoomSuspensions(resumeLocally = true) + assertFalse(viewModel.uiState.value.isPaused) + assertFalse(viewModel.roomSuspended.value) + } finally { + store.clear() + } + } + @Suppress("UNCHECKED_CAST") private fun PlayerViewModel.mutableUiState(): MutableStateFlow = PlayerViewModel::class.java.getDeclaredField("_uiState").let { diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/RoomDeliveryKeySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/RoomDeliveryKeySourceTest.kt deleted file mode 100644 index d6d4784a02..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/RoomDeliveryKeySourceTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.siloserver.silo.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/siloserver/silo/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/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGateTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGateTest.kt deleted file mode 100644 index bf557e0f2b..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/RoomSyncStateReportGateTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.siloserver.silo.android.ui.screens.player - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class RoomSyncStateReportGateTest { - - private val cadenceMs = 1_500L - private val suppressWindowMs = 250L - - @Test fun reports_when_cadence_elapsed_and_no_pending_command() { - assertTrue( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = null, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun does_not_report_before_cadence_elapsed() { - assertFalse( - shouldEmitStateReport( - nowMs = 1_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = null, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun suppresses_within_window_of_pending_execute() { - // cadence elapsed, but a command executes at 2100 and now=2000 → within 250ms - assertFalse( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = 2_100L, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun reports_when_pending_execute_is_far_away() { - assertTrue( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = 5_000L, suppressWindowMs = suppressWindowMs, - ), - ) - } - - @Test fun suppresses_just_after_execute_too() { - assertFalse( - shouldEmitStateReport( - nowMs = 2_000L, lastReportMs = 0L, cadenceMs = cadenceMs, - pendingExecuteAtMs = 1_900L, suppressWindowMs = suppressWindowMs, - ), - ) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyExternalSeekTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyExternalSeekTest.kt new file mode 100644 index 0000000000..6eaf04c070 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyExternalSeekTest.kt @@ -0,0 +1,131 @@ +package org.siloserver.silo.android.ui.screens.player + +import org.siloserver.silo.common.player.watchparty.IssuedSeekTracker +import android.app.Application +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.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSystemClock +import org.siloserver.silo.common.player.watchparty.WatchPartyPlayback +import org.siloserver.silo.model.watchtogether.JoinRoomRequest +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.api.DefaultWatchTogetherApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.RoomPlayerObservation +import org.siloserver.silo.watchtogether.RoomPlayerPort +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.RoomTransportResult +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class WatchPartyExternalSeekTest { + @Test + fun everyRapidDeniedSeekReturnsToThePreviousPosition() = runTest { + withGuestPlayback { playback -> + var position = 20.0 + val restored = mutableListOf() + for (target in listOf(80.0, 100.0, 120.0, 140.0, 160.0)) { + ShadowSystemClock.advanceBy(Duration.ofMillis(300)) + val previous = position + position = target + val result = playback.onExternalSeek(previous, target) { + restored += it + position = it + } + assertEquals(RoomTransportResult.Denied, result) + assertEquals(20.0, position, "The guest's seek to $target was left applied") + } + assertEquals(List(5) { 20.0 }, restored) + } + } + + @Test + fun duplicateControllerAndSessionCallbacksDoNotUndoRoomSeeksOrRestores() = runTest { + withGuestPlayback { playback -> + val issued = IssuedSeekTracker { 0L } + var outsideSeeks = 0 + val restores = mutableListOf() + fun discontinuity(fromMs: Long, toMs: Long) { + if (!issued.isExternal(toMs)) return + check(++outsideSeeks < 10) { "Restoration callbacks started a seek loop" } + playback.onExternalSeek(fromMs / 1_000.0, toMs / 1_000.0) { restore -> + restores += restore + val restoreMs = (restore * 1_000).toLong() + // PlayerScreen records before seekTo; MediaController + // emits immediately, then the session reports it again. + issued.note(restoreMs) + repeat(2) { discontinuity(toMs, restoreMs) } + } + } + + issued.note(60_000L) + repeat(2) { discontinuity(20_000L, 60_000L) } + assertEquals(0, outsideSeeks) + + discontinuity(60_000L, 120_000L) + assertEquals(1, outsideSeeks) + assertEquals(listOf(60.0), restores) + } + } + + private suspend fun TestScope.withGuestPlayback(block: (WatchPartyPlayback) -> Unit) { + val client = HttpClient(MockEngine { + respond( + """{"room":{"room_id":"room","phase":"playing","self_role":"guest"},"room_access_token":"proof"}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }) { install(ContentNegotiation) { json() } } + try { + val authority = AuthScopeSnapshot( + serverId = "server", profileId = "profile", serverUrl = "https://silo.example", + profileToken = null, identityGeneration = 1, + ) + val repository = WatchTogetherRepository( + api = DefaultWatchTogetherApi(client, ApiV2Gate.Unrestricted), + authScopeProvider = { authority }, + ) + assertIs>(repository.joinRoom(JoinRoomRequest(code = "K7PQ2M4X"))) + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + val availability = WatchPartyAvailabilityRepository( + roomCapabilities = { error("This test does not probe capabilities") }, + playbackCapabilities = { error("This test does not probe capabilities") }, + authScopeProvider = { authority }, + ) + val player = object : RoomPlayerPort { + override val observations = MutableStateFlow(RoomPlayerObservation()) + override fun seekTo(sourceSeconds: Double) = Unit + override fun setPlaying(playing: Boolean) = Unit + override fun setCorrectionRate(rate: Double?) = Unit + override fun isBuffered(sourceSeconds: Double) = true + } + val playback = WatchPartyPlayback("room", repository, session, availability, player, backgroundScope) + try { + block(playback) + } finally { + playback.dispose() + } + } finally { + client.close() + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupportTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupportTest.kt new file mode 100644 index 0000000000..1ef0d9be91 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/WatchPartyPlayerSupportTest.kt @@ -0,0 +1,53 @@ +package org.siloserver.silo.android.ui.screens.player + +import org.siloserver.silo.model.playback.PlaybackAvailableQualityV3 +import org.siloserver.silo.model.playback.PlaybackTimeline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyPlayerSupportTest { + // A transcode whose player timeline starts at source 600 s. + private val offsetTimeline = PlaybackTimeline(timelineOffsetSeconds = 600.0) + + @Test + fun bufferedTargetMapsSourceTimeOntoThePlayerTimeline() { + // Player at 10 s (source 610), buffered to player 40 s (source 640). + assertTrue(roomTargetBufferedLocally(offsetTimeline, 630.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertTrue(roomTargetBufferedLocally(offsetTimeline, 609.5, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + // Past the buffer, or more than a second behind the playhead, needs new media. + assertFalse(roomTargetBufferedLocally(offsetTimeline, 641.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertFalse(roomTargetBufferedLocally(offsetTimeline, 608.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + // Before this stream's origin: only a re-anchor reaches it. + assertFalse(roomTargetBufferedLocally(offsetTimeline, 30.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + } + + @Test + fun bufferedTargetIsFalseWhenAReanchorWouldBeNeededOrStateIsUnknown() { + val bounded = PlaybackTimeline(seekWindowStartSeconds = 0.0, seekWindowEndSeconds = 50.0) + assertFalse(roomTargetBufferedLocally(bounded, 60.0, playerPositionSeconds = 40.0, bufferedPlayerSeconds = 90.0)) + val unknownRestoration = PlaybackTimeline(seekRestoration = "") + assertFalse(roomTargetBufferedLocally(unknownRestoration, 20.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertFalse(roomTargetBufferedLocally(null, 20.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + assertFalse(roomTargetBufferedLocally(PlaybackTimeline(), Double.NaN, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 40.0)) + // A buffered position behind the playhead is not evidence of anything. + assertFalse(roomTargetBufferedLocally(PlaybackTimeline(), 10.0, playerPositionSeconds = 10.0, bufferedPlayerSeconds = 9.0)) + } + + @Test + fun lowerQualityIsOneRungBelowTheCurrentOneOnTheSameLadder() { + val ladder = listOf( + PlaybackAvailableQualityV3(label = "original", height = 2160, preservesSource = true), + PlaybackAvailableQualityV3(label = "1080p", height = 1080), + PlaybackAvailableQualityV3(label = "720p", height = 720), + PlaybackAvailableQualityV3(label = "480p", height = 480), + ) + assertEquals("1080p", lowerQualityRung(ladder, selectedLabel = null)?.label) + assertEquals("1080p", lowerQualityRung(ladder, selectedLabel = "auto")?.label) + assertEquals("720p", lowerQualityRung(ladder, selectedLabel = "1080p")?.label) + assertNull(lowerQualityRung(ladder, selectedLabel = "480p")) + assertNull(lowerQualityRung(emptyList(), selectedLabel = null)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteRouteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteRouteTest.kt new file mode 100644 index 0000000000..a141e97a14 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyInviteRouteTest.kt @@ -0,0 +1,34 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.siloserver.silo.android.ui.navigation.Route + +// Route strings call android.net.Uri.encode, which needs Robolectric's runtime. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class WatchPartyInviteRouteTest { + + @Test + fun `an app link becomes a hub route that carries only an opaque id`() { + val link = watchPartyAppLinkOrNull( + "silo://watch-party?server=https%3A%2F%2Fmedia.example.com&token=secret-join-token", + )!! + val route = watchPartyInviteRoute(link) + + assertEquals("watch_party?invite=${watchPartyInviteHandoffId(link)}", route) + assertFalse(route.contains("secret")) + assertFalse(route.contains("media.example.com")) + } + + @Test + fun `hub and lobby routes`() { + assertEquals("watch_party", Route.WatchPartyHub().route) + assertEquals("watch_party?host=abc", Route.WatchPartyHub(host = "abc").route) + assertEquals("watch_party/lobby/room-1", Route.WatchPartyLobby("room-1").route) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupportTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupportTest.kt new file mode 100644 index 0000000000..cc46741014 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartySupportTest.kt @@ -0,0 +1,156 @@ +package org.siloserver.silo.android.ui.screens.watchparty + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.watchtogether.WatchPartyInvite + +class WatchPartySupportTest { + + // ---- Invitation links --------------------------------------------------- + + @Test + fun `a silo watch-party link parses into its server and token`() { + val link = watchPartyAppLinkOrNull( + "silo://watch-party?server=https%3A%2F%2Fmedia.example.com%2Fsilo&token=secret-token", + ) + assertEquals(WatchPartyInvite.Link("https://media.example.com/silo", "secret-token"), link) + } + + @Test + fun `account invitations, web links, codes, and malformed links are not party app links`() { + assertNull(watchPartyAppLinkOrNull("silo://invite?server=https%3A%2F%2Fmedia.example.com&token=t")) + assertNull(watchPartyAppLinkOrNull("https://media.example.com/rooms/join?token=t")) + assertNull(watchPartyAppLinkOrNull("ABCD2345")) + assertNull(watchPartyAppLinkOrNull("silo://watch-party?server=https%3A%2F%2Fmedia.example.com")) + assertNull(watchPartyAppLinkOrNull("silo://watch-party?server=https%3A%2F%2Fmedia.example.com&token=a&token=b")) + assertNull(watchPartyAppLinkOrNull(null)) + assertNull(watchPartyAppLinkOrNull("")) + } + + @Test + fun `the handoff id is stable per link and never contains the token`() { + val link = WatchPartyInvite.Link("https://media.example.com", "secret-token") + val id = watchPartyInviteHandoffId(link) + assertEquals(id, watchPartyInviteHandoffId(link.copy())) + assertFalse(id.contains("secret")) + assertNotEquals(id, watchPartyInviteHandoffId(link.copy(token = "other-token"))) + assertNotEquals(id, watchPartyInviteHandoffId(link.copy(serverUrl = "https://other.example.com"))) + } + + @Test + fun `an invitation is taken once, and a newer one replaces an older one`() { + val handoff = WatchPartyHandoff() + val first = WatchPartyInvite.Link("https://media.example.com", "first") + val second = WatchPartyInvite.Link("https://media.example.com", "second") + val firstId = handoff.offerInvite(first) + val secondId = handoff.offerInvite(second) + + assertNull(handoff.takeInvite(firstId)) + assertEquals(second, handoff.takeInvite(secondId)) + assertNull(handoff.takeInvite(secondId)) + assertNull(handoff.takeInvite(null)) + } + + @Test + fun `a host item is taken once and leaves a title preview behind`() { + val handoff = WatchPartyHandoff() + val item = WatchPartyItem(contentId = "movie:1", contentType = "movie", title = "Heat") + val id = handoff.offerHost(item) + + assertEquals(item, handoff.takeHost(id)) + assertNull(handoff.takeHost(id)) + assertEquals(item, handoff.preview("movie:1")) + } + + // ---- In-playback status ------------------------------------------------- + + @Test + fun `members the room went on without are named once it plays again`() { + val previous = room( + RoomPlaybackState.Waiting, + member("sam", "Sam", syncing = true), + member("kim", "Kim", syncing = true), + member("me", "Me", syncing = true, self = true), + ) + val next = room( + RoomPlaybackState.Playing, + member("sam", "Sam", ready = false), + member("kim", "Kim", ready = true), + member("me", "Me", ready = false, self = true), + ) + assertEquals(listOf("Sam"), watchPartyLeftBehindNames(previous, next)) + } + + @Test + fun `nobody is left behind across a new selection, a pause, or a departure`() { + val waiting = room(RoomPlaybackState.Waiting, member("sam", "Sam", syncing = true)) + val playingNewRevision = room(RoomPlaybackState.Playing, member("sam", "Sam"), revision = 2) + val paused = room(RoomPlaybackState.Paused, member("sam", "Sam")) + val samLeft = room(RoomPlaybackState.Playing) + val stillWaiting = room(RoomPlaybackState.Waiting, member("sam", "Sam", syncing = true)) + + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, playingNewRevision)) + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, paused)) + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, samLeft)) + assertEquals(emptyList(), watchPartyLeftBehindNames(waiting, stillWaiting)) + assertEquals(emptyList(), watchPartyLeftBehindNames(null, waiting)) + } + + @Test + fun `the waiting status names the other members still syncing`() { + val waiting = room( + RoomPlaybackState.Waiting, + member("sam", "Sam", syncing = true), + member("kim", "", syncing = true), + member("lee", "Lee", ready = true), + member("me", "Me", syncing = true, self = true), + ) + assertEquals(listOf("Sam", "Someone"), watchPartyWaitingNames(waiting)) + assertEquals(emptyList(), watchPartyWaitingNames(room(RoomPlaybackState.Playing, member("sam", "Sam", syncing = true)))) + } + + @Test + fun `names read naturally`() { + assertEquals("", formatWatchPartyNames(emptyList())) + assertEquals("Sam", formatWatchPartyNames(listOf("Sam"))) + assertEquals("Sam and Kim", formatWatchPartyNames(listOf("Sam", "Kim"))) + assertEquals("Sam, Kim, and Lee", formatWatchPartyNames(listOf("Sam", "Kim", "Lee"))) + assertEquals("Sam, Kim, and 3 others", formatWatchPartyNames(listOf("Sam", "Kim", "Lee", "Ann", "Bo"))) + } + + private fun room( + state: RoomPlaybackState, + vararg members: RoomMember, + revision: Long = 1L, + ) = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Playing, + playbackState = state, + selectionRevision = revision, + members = members.toList(), + ) + + private fun member( + id: String, + name: String, + syncing: Boolean = false, + ready: Boolean = false, + self: Boolean = false, + ) = RoomMember( + userId = "user-$id", + profileId = "profile-$id", + displayName = name, + isSelf = self, + connected = true, + isReady = ready, + isSyncing = syncing, + ) +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt deleted file mode 100644 index af8ee19542..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/LobbyAutoNavigateTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.siloserver.silo.android.ui.screens.watchtogether - -import org.siloserver.silo.android.ui.navigation.Route -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomPlaybackState -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.MemberRole -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -// Asserts on real Route.route strings, which call android.net.Uri.encode — -// Robolectric provides the real Android impl under plain JVM unit tests. -// Pinned to SDK 34 (matches WatchTogetherEntryDestinationTest). -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = android.app.Application::class) -class LobbyAutoNavigateTest { - - private fun room(selectedContentId: String?, phase: RoomPhase) = RoomSnapshot( - roomId = "r1", - phase = phase, - playbackState = RoomPlaybackState.Idle, - selectionMode = RoomSelectionMode.HostPick, - selectionRevision = 0L, - selectedContentId = selectedContentId, - selectedFileId = 3, - ) - - @Test fun playing_with_selection_navigates_to_player() { - val dest = lobbyPlayerDestinationOrNull(room("c9", phase = RoomPhase.Playing)) - assertEquals(Route.Player(contentId = "c9", fileId = 3, roomId = "r1").route, dest) - } - - @Test fun lobby_phase_does_not_navigate() { - assertNull(lobbyPlayerDestinationOrNull(room("c9", phase = RoomPhase.Lobby))) - } - - @Test fun playing_without_selection_does_not_navigate() { - assertNull(lobbyPlayerDestinationOrNull(room(null, phase = RoomPhase.Playing))) - } - - @Test fun host_alone_stays_in_lobby_until_a_guest_arrives() { - val host = room("c9", RoomPhase.Playing).copy( - selfRole = MemberRole.Host, - memberCount = 1, - ) - assertNull(lobbyPlayerDestinationOrNull(host)) - assertEquals( - Route.Player(contentId = "c9", fileId = 3, roomId = "r1").route, - lobbyPlayerDestinationOrNull(host.copy(memberCount = 2)), - ) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt deleted file mode 100644 index b38ae4f94f..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.siloserver.silo.android.ui.screens.watchtogether - -import org.siloserver.silo.android.ui.navigation.Route -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomPlaybackState -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.MemberRole -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -// Asserts on real Route.route strings, which call android.net.Uri.encode — -// Robolectric provides the real Android impl under plain JVM unit tests. -// Pinned to SDK 34 (the project targetSdk 36 is newer than this Robolectric -// release ships an emulated runtime for). -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = android.app.Application::class) -class WatchTogetherEntryDestinationTest { - - private fun snapshot( - roomId: String = "room-1", - selectedContentId: String? = null, - selectedFileId: Int? = null, - ) = RoomSnapshot( - roomId = roomId, - phase = RoomPhase.Lobby, - playbackState = RoomPlaybackState.Idle, - selectionMode = RoomSelectionMode.HostPick, - selectionRevision = 0L, - selectedContentId = selectedContentId, - selectedFileId = selectedFileId, - ) - - @Test - fun host_with_selection_goes_to_player_with_roomId() { - val dest = watchTogetherDestination(snapshot(selectedContentId = "c1", selectedFileId = 7)) - assertEquals(Route.Player(contentId = "c1", fileId = 7, roomId = "room-1").route, dest) - } - - @Test - fun no_selection_goes_to_lobby() { - val dest = watchTogetherDestination(snapshot(selectedContentId = null)) - assertEquals(Route.WatchTogetherLobby(roomId = "room-1").route, dest) - } - - @Test - fun selection_set_but_no_fileId_still_routes_to_player() { - val dest = watchTogetherDestination(snapshot(selectedContentId = "c2", selectedFileId = null)) - assertEquals(Route.Player(contentId = "c2", fileId = null, roomId = "room-1").route, dest) - } - - @Test - fun host_alone_with_selection_stays_in_lobby_to_share_invite() { - val dest = watchTogetherDestination( - snapshot(selectedContentId = "c1").copy( - selfRole = MemberRole.Host, - memberCount = 1, - ), - ) - assertEquals(Route.WatchTogetherLobby(roomId = "room-1").route, dest) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt deleted file mode 100644 index b41dc79b77..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package org.siloserver.silo.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.siloserver.silo.model.watchtogether.CreateRoomRequest -import org.siloserver.silo.model.watchtogether.JoinRoomRequest -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomResponse -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.SetSelectionRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.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/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt deleted file mode 100644 index b8a73d71c4..0000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -package org.siloserver.silo.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.siloserver.silo.model.watchtogether.AddSuggestionRequest -import org.siloserver.silo.model.watchtogether.CreateRoomRequest -import org.siloserver.silo.model.watchtogether.JoinRoomRequest -import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest -import org.siloserver.silo.model.watchtogether.RoomResponse -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.SetSelectionRequest -import org.siloserver.silo.model.watchtogether.SuggestionsResponse -import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.AuthScopeSnapshot -import org.siloserver.silo.network.DefaultIdentityTransitionBarrier -import org.siloserver.silo.network.api.WatchTogetherApi -import org.siloserver.silo.repository.WatchTogetherRepository -import org.siloserver.silo.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/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt index de800b985f..cd39d37d48 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt @@ -8,6 +8,7 @@ import coil3.PlatformContext import coil3.SingletonImageLoader import org.siloserver.silo.common.di.playerInfraModule import org.siloserver.silo.common.ui.marquee.marqueeModule +import org.siloserver.silo.common.di.watchPartyModule import org.siloserver.silo.common.di.playerModule import org.siloserver.silo.common.diagnostics.DiagnosticsCoordinator import org.siloserver.silo.common.diagnostics.DiagnosticsStartup @@ -36,7 +37,7 @@ class SiloTvApplication : Application(), Configuration.Provider, SingletonImageL DiagnosticsStartup.installCrashCapture(this) val koinApp = startKoin { androidContext(this@SiloTvApplication) - modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + androidTvModule + diagnosticsModule) + modules(sharedModules() + playerModule + playerInfraModule + marqueeModule + watchPartyModule + androidTvModule + diagnosticsModule) } DiagnosticsStartup.startCoordinator { koinApp.koin.get() } koinApp.koin.get().start( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt index c03207ec00..294a3356b8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastPlayerAdapter.kt @@ -18,6 +18,12 @@ class TvSiloCastPlayerAdapter( private val setVolume: (Double) -> Unit, private val setMuted: (Boolean) -> Unit, private val playNext: () -> Unit, + /** + * Non-null when a phone's launch must not replace this player (a Watch + * Party is playing): the reason, sent back to the phone as an error. + * Called off the main thread. + */ + val launchRefusal: () -> String? = { null }, ) { fun handle(command: SiloCastControlCommand) { // Field mapping follows Apple's SiloControlCommand exactly: seek uses diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index 21812467c7..c196ba7fbe 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -72,6 +72,8 @@ class TvSiloCastReceiver( private val deviceIdProvider: () -> String, /** Suspends until the player's queued session teardown has finished. */ private val awaitPlaybackTeardown: suspend () -> Unit, + /** True while this TV is in a Watch Party, whose membership an identity swap would end. */ + private val inWatchParty: () -> Boolean = { false }, ) { data class StandbyState( val controllerName: String?, @@ -418,6 +420,20 @@ class TvSiloCastReceiver( } is SiloCastMessage.HandoffOffer -> { val offer = message.handoffOffer + // A handoff swaps in the phone's profile, and any identity + // change leaves the Watch Party this TV is in. + if (inWatchParty()) { + session.send( + SiloCastMessage.HandoffCancel( + SiloCastHandoffCancel( + requestId = offer.requestId, + reason = "watch_party_active", + message = "Leave the Watch Party on the TV first.", + ), + ), + ) + return true + } val controllerId = session.controllerDeviceId if (session.negotiatedVersion != SiloCastProtocol.version || controllerId == null) { session.send( @@ -520,6 +536,25 @@ class TvSiloCastReceiver( } is SiloCastMessage.Launch -> { if (!requireAuthorized(session)) return true + // A Watch Party player is never silently replaced by a cast. + activePlayer?.adapter?.launchRefusal?.invoke()?.let { refusal -> + session.send( + SiloCastMessage.Error( + SiloCastError(code = "watch_party_active", message = refusal), + ), + ) + return true + } + // In a party lobby no party player is registered yet, but a + // solo launch would still take the shared player from the room. + if (inWatchParty()) { + session.send( + SiloCastMessage.Error( + SiloCastError(code = "watch_party_active", message = "Leave the Watch Party on the TV first."), + ), + ) + return true + } if (!session.remoteLaunchReady || identityManager.activeIdentity == null) { session.send( SiloCastMessage.Error( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 045d95012b..f7d0aa5b27 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -157,6 +157,20 @@ val androidTvModule = module { // androidModule for why every identity reporter resolves this instead of // deriving its own answer. single { SiloClientBuildIdentity(BuildConfig.BUILD_NUMBER, BuildConfig.RELEASE_CHANNEL) } + // Settings → Experimental → Watch Party: on by default only in debug builds. + single { + org.siloserver.silo.common.watchparty.WatchPartyExperiment( + prefs = androidContext().getSharedPreferences( + org.siloserver.silo.common.watchparty.WatchPartyExperiment.PREFS_NAME, + android.content.Context.MODE_PRIVATE, + ), + defaultEnabled = BuildConfig.DEBUG, + onDisabled = { get().depart() }, + ) + } + single { + get() + } single { AndroidDeviceMetadataProvider( androidContext(), @@ -366,6 +380,9 @@ val androidTvModule = module { org.siloserver.silo.common.pairing.PairingDeviceId.stable(androidContext()) }, awaitPlaybackTeardown = playbackLifecycle::awaitPendingStop, + inWatchParty = { + get().roomSnapshot.value != null + }, ) } @@ -485,21 +502,43 @@ val androidTvModule = module { capabilityDetector = get(), ) } - // Watch Together entry (create/join orchestration) — backs the entry + - // join-code dialogs on the detail screen. - viewModel { - org.siloserver.silo.tv.ui.screens.watchtogether.TvWatchTogetherViewModel(get()) - } + // Watch Party: the shared hub, lobby (keyed per room; roomId is the + // positional parameter), and picker ViewModels. viewModel { - org.siloserver.silo.tv.ui.screens.watchtogether.TvSuggestToRoomViewModel(get()) + val serverRegistry = get() + org.siloserver.silo.viewmodel.WatchPartyHubViewModel( + repository = get(), + roomSession = get(), + availability = get(), + recents = get(), + isCurrentServer = { url -> + org.siloserver.silo.common.watchparty.watchPartyServerMatches( + linkServerUrl = url, + activeServerUrl = serverRegistry.activeEntry.value?.url, + ) + }, + ) } - // Watch Together lobby — keyed per roomId (koinViewModel key="wt-lobby-$roomId"); - // roomId is read from the positional parameter. viewModel { params -> - org.siloserver.silo.tv.ui.screens.watchtogether.TvWatchTogetherLobbyViewModel( + org.siloserver.silo.viewmodel.WatchPartyLobbyViewModel( roomId = params.get(), repository = get(), roomSession = get(), + availability = get(), + ) + } + viewModel { + val catalog = get() + org.siloserver.silo.viewmodel.WatchPartyPickerViewModel( + repository = get(), + availability = get(), + search = { query -> + when (val result = catalog.browse(query = query, limit = 30)) { + is ApiResult.Success -> ApiResult.Success(result.data.items) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + }, ) } viewModel { params -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index c12cb00c94..5f8ca0c577 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -54,11 +55,12 @@ import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsPromp import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsReportScreen import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsSurfacePresence import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel -import org.siloserver.silo.tv.ui.screens.watchtogether.TvWatchTogetherLobbyScreen -import org.siloserver.silo.tv.ui.screens.watchtogether.tvWatchTogetherDestination -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.watchtogether.WatchTogetherEntryTarget -import org.siloserver.silo.watchtogether.watchTogetherEntryTarget +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyHubScreen +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyLobbyScreen +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyPlayGuardDialog +import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvWatchPartyPlayGuard +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.WatchPartyDestination import org.siloserver.silo.common.cards.ProvideCardPresentation import org.siloserver.silo.common.settings.ProvideTitleArt import org.siloserver.silo.common.settings.TitleArtStore @@ -254,30 +256,77 @@ private fun NavHostController.navigateToTvPlayback( ) } +/** Whether [entry] is the party screen for [destination]. */ +private fun tvIsWatchPartyEntry(entry: NavBackStackEntry, destination: WatchPartyDestination): Boolean = + when (destination) { + is WatchPartyDestination.Lobby -> + entry.destination.route == TvRoute.WatchPartyLobby.ROUTE && + entry.arguments?.getString(TvRoute.WatchPartyLobby.ARG_ROOM_ID) == destination.roomId + is WatchPartyDestination.Player -> + entry.destination.route == TvRoute.Player.ROUTE && + entry.arguments?.getString(TvRoute.Player.ARG_ROOM_ID) == destination.roomId + } + /** - * Watch Together enters either a lobby or a player. The player case is an - * ordinary playback navigation and must go through [navigateToTvPlayback] — - * it used `launchSingleTop`, which mutates the existing player entry's - * arguments while PRESERVING its id, so a recorded solo request could still - * look current afterwards and suppress the user's next real request. + * Shows a Watch Party screen: the hub (null), the room's lobby, or its + * player. A party screen already on the back stack is returned to instead of + * stacked again, so Back never walks through stale party screens. The player + * goes through [navigateToTvPlayback] like every other playback request. */ -private fun NavHostController.navigateToTvWatchTogether( - room: RoomSnapshot, +private fun NavHostController.navigateToWatchParty( + destination: WatchPartyDestination?, + repository: WatchTogetherRepository, lastPlaybackNavigation: MutableState, ) { - val destination = tvWatchTogetherDestination(room) - val contentId = room.selectedContentId - if (watchTogetherEntryTarget(room) == WatchTogetherEntryTarget.Player && contentId != null) { - navigateToTvPlayback( - destination = destination, - contentId = contentId, - lastPlaybackNavigation = lastPlaybackNavigation, - ) - } else { - navigate(destination) { launchSingleTop = true } + // The topmost entry of the party screen's destination, if it is this + // room's. (One party at a time, and D5 keeps solo players off a party's + // stack, so the topmost entry is the only candidate.) + val existing = destination?.let { target -> + val pattern = when (target) { + is WatchPartyDestination.Lobby -> TvRoute.WatchPartyLobby.ROUTE + is WatchPartyDestination.Player -> TvRoute.Player.ROUTE + } + runCatching { getBackStackEntry(pattern) }.getOrNull()?.takeIf { tvIsWatchPartyEntry(it, target) } + } + if (existing != null) { + popBackStack(existing.destination.id, inclusive = false) + return + } + val fromHub = currentBackStackEntry?.destination?.route == TvRoute.WatchPartyHub.ROUTE + when (destination) { + null -> navigate(TvRoute.WatchPartyHub().route) { launchSingleTop = true } + is WatchPartyDestination.Lobby -> navigate(TvRoute.WatchPartyLobby(destination.roomId).route) { + // Back from the lobby returns to where the viewer came from, not the hub. + if (fromHub) popUpTo(TvRoute.WatchPartyHub.ROUTE) { inclusive = true } + } + is WatchPartyDestination.Player -> { + val room = repository.roomSnapshot.value?.takeIf { it.roomId == destination.roomId } + val contentId = room?.selectedContentId?.takeIf { it.isNotBlank() } ?: destination.roomId + // The player replaces the hub, as the lobby does, so leaving the + // party returns to where the viewer came from. + if (fromHub) popBackStack() + navigateToTvPlayback( + destination = TvRoute.Player( + contentId = contentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + roomId = destination.roomId, + ).route, + contentId = contentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) + } } } +/** + * Whether a party route for [roomId] is backed by a live membership. The + * room token lives only in memory, so after process death a restored lobby or + * player route has nothing behind it and must not silently re-adopt. + */ +private fun tvHasLiveWatchParty(repository: WatchTogetherRepository, roomId: String?): Boolean = + roomId != null && repository.roomSnapshot.value?.roomId == roomId + /** Pushes item detail, collapsing only an exact repeat of the current page. */ private fun NavHostController.navigateToTvItemDetail( contentId: String, @@ -367,6 +416,7 @@ fun TvAppNavigation( val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore = koinInject() val watchNextSeeder: WatchNextSeeder = koinInject() val siloCastReceiver: TvSiloCastReceiver = koinInject() + val watchPartyRepository: WatchTogetherRepository = koinInject() val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() val pendingDeepLink: MutableStateFlow = @@ -464,6 +514,13 @@ fun TvAppNavigation( pendingDeepLink.value = null return@collect } + // D5: a launcher play link while this TV is in a Watch Party opens + // the title's page instead, where Play asks to leave the party + // first. Playing it outright would hide the departure. + if (uri.host == "play" && watchPartyRepository.roomSnapshot.value != null) { + pendingDeepLink.value = uri.buildUpon().authority("item").clearQuery().build() + return@collect + } // Arrived: the current destination is this link's target, so the // link is spent. This is the only place a successful content link // is cleared — see the bookkeeping comment above. @@ -799,6 +856,8 @@ fun TvAppNavigation( } composable(TvRoute.Main.route) { mainEntry -> + val watchPartyPlayGuard = rememberTvWatchPartyPlayGuard() + TvWatchPartyPlayGuardDialog(watchPartyPlayGuard) val returnToManageServers = mainEntry.savedStateHandle.get(RETURN_TO_MANAGE_SERVERS_KEY) == true TvMainShell( @@ -828,8 +887,8 @@ fun TvAppNavigation( episodeContentId = episodeContentId, ) }, - onOpenWatchTogether = { room -> - navController.navigateToTvWatchTogether(room, lastPlaybackNavigation) + onOpenWatchParty = { + navController.navigateToWatchParty(null, watchPartyRepository, lastPlaybackNavigation) }, onOpenLibraryCollectionDetail = { libraryId, collectionId, title, libraryType -> navController.navigate( @@ -914,16 +973,18 @@ fun TvAppNavigation( onPlayItem = { playContentId, itemType, resumePositionSeconds -> // A fast double Select otherwise stacks a second player, // starting two sessions and leaving Back on a duplicate. - navController.navigateToTvPlayback( - destination = tvPlayDestinationFor( - itemType = itemType, + watchPartyPlayGuard.requestPlay { + navController.navigateToTvPlayback( + destination = tvPlayDestinationFor( + itemType = itemType, + contentId = playContentId, + fileId = null, + resumePositionSeconds = resumePositionSeconds, + ), contentId = playContentId, - fileId = null, - resumePositionSeconds = resumePositionSeconds, - ), - contentId = playContentId, - lastPlaybackNavigation = lastPlaybackNavigation, - ) + lastPlaybackNavigation = lastPlaybackNavigation, + ) + } }, onOpenPersonDetail = { personId -> navController.navigate(TvRoute.PersonDetail(personId).route) { @@ -1065,8 +1126,8 @@ fun TvAppNavigation( onSeasonClick = { seriesId, selectedSeason -> navController.navigateToTvItemDetail(seriesId, selectedSeason, libraryId = libraryId) }, - onWatchTogether = { snapshot -> - navController.navigateToTvWatchTogether(snapshot, lastPlaybackNavigation) + onWatchParty = { destination -> + navController.navigateToWatchParty(destination, watchPartyRepository, lastPlaybackNavigation) }, onOpenPerson = { personId -> navController.navigate(TvRoute.PersonDetail(personId).route) { @@ -1147,8 +1208,7 @@ fun TvAppNavigation( defaultValue = null }, navArgument(TvRoute.Player.ARG_ROOM_ID) { - // Watch Together room binding; null for solo play. Consumed - // by TvPlayerScreen in T3. + // Watch Party room binding; null for solo play. type = NavType.StringType nullable = true defaultValue = null @@ -1234,6 +1294,19 @@ fun TvAppNavigation( targetContentId = contentId, ) } + // A party player restored with no live membership (process death) + // opens the hub, which offers Rejoin, instead of re-adopting. + val livePartyRoute = remember(backStack.id) { + roomId == null || tvHasLiveWatchParty(watchPartyRepository, roomId) + } + if (!livePartyRoute) { + LaunchedEffect(backStack.id) { + navController.navigate(TvRoute.WatchPartyHub().route) { + popUpTo(TvRoute.Player.ROUTE) { inclusive = true } + } + } + return@composable + } // TvPlayerViewModel starts loading in its initializer, which runs // while TvPlayerScreen's default parameters are evaluated. Bind // the playback display first so the very first capability probe @@ -1263,6 +1336,20 @@ fun TvAppNavigation( autoAdvanceCount = autoAdvanceCount, episodeSelectionHandoff = episodeSelectionHandoff, onExit = { navController.popBackStack() }, + // Host Stop: back to the room's lobby in place of the player. + // The membership is kept, so the lobby follows the next Start. + onReturnToWatchPartyLobby = { id -> + navController.navigate(TvRoute.WatchPartyLobby(id, hostStopped = true).route) { + popUpTo(TvRoute.Player.ROUTE) { inclusive = true } + } + }, + // The party ended under the player: the hub says why and + // offers Rejoin when the room may still be going. + onWatchPartyEnded = { + navController.navigate(TvRoute.WatchPartyHub(showEnded = true).route) { + popUpTo(TvRoute.Player.ROUTE) { inclusive = true } + } + }, ) } @@ -1297,25 +1384,85 @@ fun TvAppNavigation( } composable( - route = TvRoute.WatchTogetherLobby.ROUTE, + route = TvRoute.WatchPartyHub.ROUTE, arguments = listOf( - navArgument(TvRoute.WatchTogetherLobby.ARG_ROOM_ID) { type = NavType.StringType }, + navArgument(TvRoute.WatchPartyHub.ARG_SHOW_ENDED) { + type = NavType.BoolType + defaultValue = false + }, + ), + ) { backStack -> + TvWatchPartyHubScreen( + showEnded = backStack.arguments?.getBoolean(TvRoute.WatchPartyHub.ARG_SHOW_ENDED) == true, + onDestination = { destination -> + if (navController.currentBackStackEntry?.id == backStack.id) { + navController.navigateToWatchParty(destination, watchPartyRepository, lastPlaybackNavigation) + } + }, + onBack = { navController.popBackStack() }, + ) + } + + composable( + route = TvRoute.WatchPartyLobby.ROUTE, + arguments = listOf( + navArgument(TvRoute.WatchPartyLobby.ARG_ROOM_ID) { type = NavType.StringType }, + navArgument(TvRoute.WatchPartyLobby.ARG_HOST_STOPPED) { + type = NavType.BoolType + defaultValue = false + }, ), ) { backStack -> val roomId = backStack.arguments - ?.getString(TvRoute.WatchTogetherLobby.ARG_ROOM_ID) + ?.getString(TvRoute.WatchPartyLobby.ARG_ROOM_ID) ?: return@composable - TvWatchTogetherLobbyScreen( + // A lobby restored with no live membership (process death) opens + // the hub, which offers Rejoin, instead of re-adopting silently. + val live = remember(backStack.id) { tvHasLiveWatchParty(watchPartyRepository, roomId) } + if (!live) { + LaunchedEffect(backStack.id) { + navController.navigate(TvRoute.WatchPartyHub().route) { + popUpTo(TvRoute.WatchPartyLobby.ROUTE) { inclusive = true } + } + } + return@composable + } + // Only the entry on top may navigate: an exiting lobby still + // composed during its fade must not act on a late snapshot. + val isTop = { navController.currentBackStackEntry?.id == backStack.id } + TvWatchPartyLobbyScreen( roomId = roomId, - // The lobby computes the synced-player route from its snapshot - // (T2). We pop the lobby so Back from the player exits the room - // rather than returning to a stale lobby. - onNavigateToPlayer = { route -> - navController.navigate(route) { - popUpTo(TvRoute.WatchTogetherLobby.ROUTE) { inclusive = true } + hostStopped = backStack.arguments?.getBoolean(TvRoute.WatchPartyLobby.ARG_HOST_STOPPED) == true, + // The lobby is replaced by the player, so Back from the + // player never returns to a stale lobby. + onOpenPlayer = { destination -> + if (isTop()) { + val room = watchPartyRepository.roomSnapshot.value?.takeIf { it.roomId == destination.roomId } + val contentId = room?.selectedContentId?.takeIf { it.isNotBlank() } ?: destination.roomId + navController.navigate( + TvRoute.Player( + contentId = contentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + roomId = destination.roomId, + ).route, + ) { + popUpTo(TvRoute.WatchPartyLobby.ROUTE) { inclusive = true } + } } }, - onBack = { navController.popBackStack() }, + onOpenDetail = { contentId -> + if (isTop()) navController.navigateToTvItemDetail(contentId) + }, + onEnded = { + if (isTop()) { + navController.navigate(TvRoute.WatchPartyHub(showEnded = true).route) { + popUpTo(TvRoute.WatchPartyLobby.ROUTE) { inclusive = true } + } + } + }, + onLeft = { if (isTop()) navController.popBackStack() }, + onBack = { if (isTop()) navController.popBackStack() }, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt index 96df17c10c..27ca4d96bf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt @@ -96,7 +96,7 @@ sealed class TvRoute(val route: String) { * Playback route. An optional `fileId` query param lets the detail screen * pre-select a specific file version (e.g. 4K vs 1080p). When absent, the * player uses `auto` selection. An optional `roomId` query param binds the - * player to a Watch Together room (synced playback); absent for solo play. + * player to a Watch Party room (synced playback); absent for solo play. */ data class Player( val contentId: String, @@ -207,15 +207,32 @@ sealed class TvRoute(val route: String) { } /** - * Watch Together lobby — the waiting/vote/pick surface for a room that has - * no selection yet. Reached from the entry dialog's Host/Join when the room - * snapshot carries no `selectedContentId`. + * Watch Party hub: availability, Return to Party, Rejoin, Host, and Join + * with a code. [showEnded] explains why the last party ended (the lobby + * and player open it this way when a party ends under them). */ - data class WatchTogetherLobby(val roomId: String) : - TvRoute("watch_together/lobby/${roomId.routeEncode()}") { + data class WatchPartyHub(val showEnded: Boolean = false) : + TvRoute(if (showEnded) "watch_party/hub?$ARG_SHOW_ENDED=true" else "watch_party/hub") { companion object { - const val ROUTE = "watch_together/lobby/{roomId}" + const val ROUTE = "watch_party/hub?showEnded={showEnded}" + const val ARG_SHOW_ENDED = "showEnded" + } + } + + /** + * Watch Party lobby for one room. Only the room's phase moves it to the + * player. [hostStopped] marks a return from playback the host stopped. + * The room token is memory-only, so a restored lobby with no live + * membership redirects to the hub. + */ + data class WatchPartyLobby(val roomId: String, val hostStopped: Boolean = false) : + TvRoute( + "watch_party/lobby/${roomId.routeEncode()}" + if (hostStopped) "?$ARG_HOST_STOPPED=true" else "", + ) { + companion object { + const val ROUTE = "watch_party/lobby/{roomId}?hostStopped={hostStopped}" const val ARG_ROOM_ID = "roomId" + const val ARG_HOST_STOPPED = "hostStopped" } } @@ -352,6 +369,9 @@ sealed class TvMainRoute(val route: String) { internal val TvRemovedRoutes: List = listOf( // Diagnostics became a Settings category rather than a top-level screen. "diagnostics", + // The Watch Together lobby became [TvRoute.WatchPartyLobby]. A restored + // room route has no live membership (the room token is memory-only). + "watch_together/lobby/{roomId}", ) /** Nested [TvRoute.Main] equivalents of [TvRemovedRoutes]. */ diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index 92b1782a76..ee49c5044c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -1,6 +1,5 @@ package org.siloserver.silo.tv.ui.screens.detail -import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FastOutSlowInEasing @@ -112,11 +111,9 @@ import org.siloserver.silo.model.catalog.selectedMediaRuntimeMinutes import org.siloserver.silo.model.catalog.titleRatings import org.siloserver.silo.model.catalog.trailerRailEntries import org.siloserver.silo.model.ebook.MediaRelatedItem -import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.metadata.MetadataAiOnView import org.siloserver.silo.model.section.SectionItem -import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.tv.ui.navigation.TvSubtitleLaunchSelection import org.siloserver.silo.tv.ui.navigation.explicitTvSubtitleLaunchSelection import org.siloserver.silo.tv.ui.components.TvDialogOption @@ -134,10 +131,13 @@ import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved import org.siloserver.silo.tv.ui.screens.audiobook.formatAudiobookTime -import org.siloserver.silo.tv.ui.screens.watchtogether.TvJoinCodeDialog -import org.siloserver.silo.tv.ui.screens.watchtogether.TvSuggestToRoomViewModel -import org.siloserver.silo.tv.ui.screens.watchtogether.TvWatchTogetherEntryDialog -import org.siloserver.silo.tv.ui.screens.watchtogether.TvWatchTogetherViewModel +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyDetailEffects +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyPlayGuardDialog +import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvWatchPartyDetailEntry +import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvWatchPartyDetailOption +import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvWatchPartyPlayGuard +import org.siloserver.silo.tv.ui.screens.watchparty.tvWatchPartyItem +import org.siloserver.silo.watchtogether.WatchPartyDestination import org.siloserver.silo.tv.ui.theme.Spacing import org.siloserver.silo.tv.ui.theme.TvSmoothBringIntoViewSpec @@ -190,7 +190,8 @@ fun TvItemDetailScreen( ) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, - onWatchTogether: (RoomSnapshot) -> Unit, + /** Open a Watch Party screen: the lobby or player, or the hub for null. */ + onWatchParty: (WatchPartyDestination?) -> Unit, onOpenPerson: (personId: Long) -> Unit, onBack: () -> Unit, viewModel: TvItemDetailViewModel = koinViewModel( @@ -216,6 +217,17 @@ fun TvItemDetailScreen( BackHandler(enabled = true) { onBack() } + // D5: while this TV is in a Watch Party, Play (and extras) first asks to + // leave the party; leaving is never hidden behind a solo start. + val watchPartyPlayGuard = rememberTvWatchPartyPlayGuard() + val guardedOnPlay: (String, Int?, Int?, Boolean, TvSubtitleLaunchSelection?, String?, Double?) -> Unit = + { playContentId, fileId, audioTrackIndex, audioPicked, subtitleSelection, itemType, resumePositionSeconds -> + watchPartyPlayGuard.requestPlay { + onPlay(playContentId, fileId, audioTrackIndex, audioPicked, subtitleSelection, itemType, resumePositionSeconds) + } + } + TvWatchPartyPlayGuardDialog(watchPartyPlayGuard) + LaunchedEffect(seriesRedirect, seriesRedirectFailed) { val redirect = seriesRedirect ?: return@LaunchedEffect if (seriesRedirectFailed) return@LaunchedEffect @@ -307,12 +319,13 @@ fun TvItemDetailScreen( initialSeasonNumber = seasonNumber, initialEpisodeContentId = initialEpisodeContentId, viewModel = viewModel, - onPlay = onPlay, + libraryId = libraryId, + onPlay = guardedOnPlay, onItemDetail = onItemDetail, onItemDetailReplace = onItemDetailReplace, onSeriesClick = onSeriesClick, onSeasonClick = onSeasonClick, - onWatchTogether = onWatchTogether, + onWatchParty = onWatchParty, onOpenPerson = onOpenPerson, ) } @@ -326,12 +339,13 @@ private fun TvDetailContent( initialSeasonNumber: Int?, initialEpisodeContentId: String?, viewModel: TvItemDetailViewModel, + libraryId: Int?, onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onItemDetail: (contentId: String) -> Unit, onItemDetailReplace: (contentId: String) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, - onWatchTogether: (RoomSnapshot) -> Unit, + onWatchParty: (WatchPartyDestination?) -> Unit, onOpenPerson: (personId: Long) -> Unit, ) { val playFocus = remember { FocusRequester() } @@ -843,7 +857,8 @@ private fun TvDetailContent( onPlay = onPlay, onSeriesClick = onSeriesClick, onSeasonClick = onSeasonClick, - onWatchTogether = onWatchTogether, + libraryId = libraryId, + onWatchParty = onWatchParty, // Season mode marks the selected season; // Show mode offers only the whole series. watchedSeason = state.seasons @@ -1494,24 +1509,12 @@ private fun HeroActionRow( onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, - onWatchTogether: (RoomSnapshot) -> Unit, + libraryId: Int?, + onWatchParty: (WatchPartyDestination?) -> Unit, watchedSeason: org.siloserver.silo.model.catalog.Season? = null, ) { - val suggestViewModel: TvSuggestToRoomViewModel = koinViewModel() - val activeRoom by suggestViewModel.room.collectAsState() - val suggestState by suggestViewModel.uiState.collectAsState() - val suggestContext = LocalContext.current - LaunchedEffect(suggestState.notice, suggestState.error) { - val message = suggestState.notice ?: suggestState.error - if (message != null) { - Toast.makeText(suggestContext, message, Toast.LENGTH_SHORT).show() - suggestViewModel.consumeNotice() - suggestViewModel.clearError() - } - } + val watchPartyEntry = rememberTvWatchPartyDetailEntry() var moreOpen by remember(detail.contentId) { mutableStateOf(false) } - var watchTogetherOpen by remember(detail.contentId) { mutableStateOf(false) } - var joinCodeOpen by remember(detail.contentId) { mutableStateOf(false) } var playLaunchPending by remember(detail.contentId) { mutableStateOf(false) } LaunchedEffect(playLaunchPending) { if (playLaunchPending) { @@ -1547,11 +1550,6 @@ private fun HeroActionRow( // higher-frequency Watchlist toggle is visible in the stable action row. val hasSeriesNavigation = detail.type in setOf("episode", "season") && detail.seriesId != null val hasOverflowNavigation = hasSeriesNavigation - val hasWatchTogether = - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && !isAudiobookItemType(detail.type) - val hasSuggestionTarget = detail.type in setOf("movie", "episode") || nextUp != null - val canSuggestToRoom = - CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && activeRoom != null && hasSuggestionTarget // Version set + selection state driving the selector row / Play file id. // Series/season use the next-up episode's versions + the next-up selection; @@ -1591,6 +1589,37 @@ private fun HeroActionRow( ) } val selectedFileId = selectedVersion?.fileId + // Watch Party offers movies, episodes, and series (as the next-up + // episode), staging the edition this page displays. + val watchPartyItem = when { + detail.type == "movie" || detail.type == "episode" -> tvWatchPartyItem( + contentId = detail.contentId, + contentType = detail.type, + title = detail.title, + subtitle = if (detail.type == "episode") { + listOfNotNull( + detail.seriesTitle, + detail.seasonNumber?.let { season -> detail.episodeNumber?.let { "S${season}E$it" } }, + ).joinToString(" · ") + } else { + detail.year.takeIf { it > 0 }?.toString() + }, + posterUrl = detail.posterUrl, + fileId = selectedFileId, + libraryId = libraryId, + ) + detail.type == "series" && nextUp != null && playReady -> tvWatchPartyItem( + contentId = nextUp.contentId, + contentType = "episode", + title = nextUp.title?.takeIf { it.isNotBlank() } ?: "Episode ${nextUp.episodeNumber}", + subtitle = "${detail.title} · S${nextUp.seasonNumber}E${nextUp.episodeNumber}", + posterUrl = nextUp.stillUrl ?: detail.posterUrl, + fileId = selectedFileId, + libraryId = libraryId, + ) + else -> null + } + val watchPartyOption = rememberTvWatchPartyDetailOption(watchPartyEntry, watchPartyItem) val automaticAudioTrackOrdinal = resolveTvAutomaticAudioTrackOrdinal( version = selectedVersion, preferredAudioLanguage = state.preferredAudioLanguage, @@ -1798,34 +1827,15 @@ private fun HeroActionRow( ), ) } - if (canSuggestToRoom) { + watchPartyOption?.let { option -> add( TvDialogOption( - key = "suggest-to-room", - title = "Suggest to Watch Together", - subtitle = "Add to the room you are in", + key = "watch-party", + title = option.title, + subtitle = option.subtitle, onClick = { moreOpen = false - suggestViewModel.suggest( - contentId = playContentId, - contentType = playType, - title = nextUp?.title ?: detail.title, - subtitle = if (nextUp != null) detail.title else detail.seriesTitle, - posterUrl = nextUp?.stillUrl ?: detail.posterUrl, - ) - }, - ), - ) - } - if (hasWatchTogether) { - add( - TvDialogOption( - key = "watch-together", - title = "Watch Together", - subtitle = "Host a room or join by code", - onClick = { - moreOpen = false - watchTogetherOpen = true + option.onSelect() }, ), ) @@ -1870,45 +1880,7 @@ private fun HeroActionRow( ) } - if (watchTogetherOpen && hasWatchTogether) { - val watchTogetherViewModel: TvWatchTogetherViewModel = koinViewModel() - val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() - - LaunchedEffect(watchTogetherState.result) { - val room = watchTogetherState.result ?: return@LaunchedEffect - watchTogetherViewModel.consumeResult() - watchTogetherOpen = false - joinCodeOpen = false - onWatchTogether(room) - } - - if (joinCodeOpen) { - TvJoinCodeDialog( - isBusy = watchTogetherState.isBusy, - error = watchTogetherState.error, - onJoin = watchTogetherViewModel::joinRoom, - onDismiss = { - watchTogetherViewModel.clearError() - joinCodeOpen = false - }, - ) - } else { - TvWatchTogetherEntryDialog( - isBusy = watchTogetherState.isBusy, - error = watchTogetherState.error, - onHost = { watchTogetherViewModel.createRoom(playContentId, playFileId) }, - onHostVote = watchTogetherViewModel::createEmptyVoteRoom, - onJoin = { - watchTogetherViewModel.clearError() - joinCodeOpen = true - }, - onDismiss = { - watchTogetherViewModel.clearError() - watchTogetherOpen = false - }, - ) - } - } + TvWatchPartyDetailEffects(entry = watchPartyEntry, onNavigate = onWatchParty) } internal data class SeriesEpisodePlaybackLaunch( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt index 622d36fae7..adae1bd9a8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt @@ -29,6 +29,13 @@ class TvPlaybackRealtimeController( private val client: PlaybackRealtimeClient, private val viewModel: TvPlayerViewModel, private val scope: CoroutineScope, + /** + * Set while the player belongs to a Watch Party. Transport commands then + * go through the room's permission decision instead of the player, and + * Stop/terminate end only this device's engagement. Returns whether the + * command was accepted. + */ + private val roomTransport: ((PlaybackAction) -> Boolean)? = null, ) { private companion object { const val BACKOFF_START_MS = 2_000L @@ -64,9 +71,22 @@ class TvPlaybackRealtimeController( private suspend fun handleCommand(cmd: PlaybackRealtimeEvent.Command) { client.sendAck(sessionId, cmd.commandId) val action = decidePlaybackAction(cmd) - // Watch Together is authoritative for transport — reject (don't apply) - // transport commands while in a room so an admin can't desync members. - val gated = action.isTransport && viewModel.remoteTransportSuppressed + val room = roomTransport + if (room != null && action.isTransport) { + // The room is authoritative for transport: an admin command is one + // more input, decided by this membership's permissions. + if (action is PlaybackAction.Stop) { + // Result first: leaving tears the screen down. + client.sendResult(sessionId, cmd.commandId, STATUS_COMPLETED) + room(action) + } else { + val accepted = room(action) + client.sendResult(sessionId, cmd.commandId, if (accepted) STATUS_COMPLETED else STATUS_REJECTED) + } + return + } + // A party player with no room route yet must not let transport through. + val gated = action.isTransport && viewModel.inWatchParty val status = if (action is PlaybackAction.Reject || gated) STATUS_REJECTED else STATUS_COMPLETED // Result BEFORE applying — a Stop tears the screen down (cancelling this // coroutine), so applying first could drop the result. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt index 44379eb604..9ba5160319 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt @@ -224,6 +224,12 @@ internal fun TvPlayerHud( onSelectChapter: (Int) -> Unit, onDismiss: () -> Unit, initialTab: HudTab = HudTab.Info, + /** + * False in a Watch Party: the room plays one exact file at 1x, so the + * version picker and the speed row are hidden. Quality stays (it is the + * same file's ladder). + */ + versionAndSpeedControlsVisible: Boolean = true, modifier: Modifier = Modifier, ) { val tabs = visibleHudTabs( @@ -503,7 +509,7 @@ internal fun TvPlayerHud( HudTab.Video -> HudVideoPane( videoQualities = videoQualities, onSelectVideoQuality = onSelectVideoQuality, - fileVersions = fileVersions, + fileVersions = if (versionAndSpeedControlsVisible) fileVersions else emptyList(), selectedFileId = selectedFileId, onSelectFileVersion = onSelectFileVersion, hdrEnabled = hdrEnabled, @@ -515,6 +521,7 @@ internal fun TvPlayerHud( onFillModeChanged = onVideoFillModeChanged, playbackSpeed = playbackSpeed, onPlaybackSpeedChanged = onPlaybackSpeedChanged, + speedRowVisible = versionAndSpeedControlsVisible, sleepTimerState = sleepTimerState, onStartSleepTimer = onStartSleepTimer, onCancelSleepTimer = onCancelSleepTimer, @@ -1041,6 +1048,7 @@ private fun HudVideoPane( onFillModeChanged: (VideoFillMode) -> Unit, playbackSpeed: Double, onPlaybackSpeedChanged: (Double) -> Unit, + speedRowVisible: Boolean, sleepTimerState: SleepTimerState, onStartSleepTimer: (Int) -> Unit, onCancelSleepTimer: () -> Unit, @@ -1061,7 +1069,8 @@ private fun HudVideoPane( val entryRow = when { hasVersionRow -> "version" hasQualityRow -> "quality" - else -> "speed" + speedRowVisible -> "speed" + else -> "aspect" } Row( modifier = modifier.fillMaxWidth(), @@ -1144,32 +1153,35 @@ private fun HudVideoPane( }, ) - HudFocusedSettingRow( - label = "Speed", - value = formatTvPlaybackSpeed(playbackSpeed), - enabled = enabled, - entryFocusRequester = entryFocusRequester.takeIf { entryRow == "speed" }, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Playback Speed", - options = PLAYBACK_SPEED_OPTIONS.map { - HudPickerOption(speedOptionId(it), formatTvPlaybackSpeed(it)) - }, - selectedId = speedOptionId(playbackSpeed), - onSelect = { id -> - PLAYBACK_SPEED_OPTIONS.firstOrNull { speedOptionId(it) == id } - ?.let(onPlaybackSpeedChanged) - }, - ), - ) - }, - ) + if (speedRowVisible) { + HudFocusedSettingRow( + label = "Speed", + value = formatTvPlaybackSpeed(playbackSpeed), + enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "speed" }, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Playback Speed", + options = PLAYBACK_SPEED_OPTIONS.map { + HudPickerOption(speedOptionId(it), formatTvPlaybackSpeed(it)) + }, + selectedId = speedOptionId(playbackSpeed), + onSelect = { id -> + PLAYBACK_SPEED_OPTIONS.firstOrNull { speedOptionId(it) == id } + ?.let(onPlaybackSpeedChanged) + }, + ), + ) + }, + ) + } HudFocusedSettingRow( label = "Aspect", value = fillModeLabel(fillMode), enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "aspect" }, onActivate = { onPresentPicker( HudPickerPresentation( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index 6c62975ca2..6551f40cfe 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -63,8 +63,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -123,6 +121,7 @@ import org.siloserver.silo.common.player.SubtitleManager import org.siloserver.silo.common.player.VideoPlayerMediaSpec import org.siloserver.silo.common.player.subtitlesForVideoMediaMount import org.siloserver.silo.common.player.videoMountToken +import org.siloserver.silo.common.player.backend.VideoPlaybackBackend import org.siloserver.silo.common.player.backend.VideoPlaybackBackendFactory import org.siloserver.silo.common.player.validatedColorRangeFallback import org.siloserver.silo.common.player.video.PlaybackRuntimeCorrectionMetrics @@ -137,8 +136,9 @@ import org.siloserver.silo.model.playback.executableMedia3ClientTransformations import org.siloserver.silo.model.playback.activeOriginalHttpClaims import org.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.model.settings.SubtitlePositionPreset -import org.siloserver.silo.model.watchtogether.RoomPlaybackState import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.watchtogether.RoomTransportIntent +import org.siloserver.silo.watchtogether.roomTransportAuthorized import org.siloserver.silo.player.formatSubtitleTrackDisplayLabel import org.siloserver.silo.tv.R import org.siloserver.silo.tv.cast.SiloCastVolumeState @@ -146,7 +146,6 @@ import org.siloserver.silo.tv.cast.TvSiloCastPlayerAdapter import org.siloserver.silo.tv.cast.TvSiloCastReceiver import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvLoadingScreen -import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvFocusLog import org.siloserver.silo.tv.ui.focus.claimFocusOrReport @@ -251,10 +250,17 @@ fun TvPlayerScreen( contentId: String, libraryId: Int? = null, onExit: () -> Unit, + // Watch Party host Stop: the room is back in its lobby and this device is + // still a member. Receives the room id. + onReturnToWatchPartyLobby: (String) -> Unit, + // The Watch Party ended under this player (or its membership is gone); + // the hub explains why and offers Rejoin. + onWatchPartyEnded: () -> Unit = onExit, preferredFileId: Int? = null, preferredQuality: String? = null, - // Watch Together room binding. When non-null, a [TvRoomSyncController] - // binds this player to the synced room for the lifetime of the screen. + // Watch Party room. When non-null, [WatchPartyPlayback] binds this player + // to the room for the lifetime of the screen and the room's playback + // context, not the route's content, decides what plays. roomId: String? = null, resumePositionOverride: Double? = null, // Pre-playback track selections chosen on the detail screen (null = auto; @@ -352,10 +358,6 @@ fun TvPlayerScreen( val aiTranslate by viewModel.aiTranslate.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current val latestOnExit by rememberUpdatedState(onExit) - val latestSiloCastPlaybackSpeed by rememberUpdatedState(playbackSpeed) - val latestSiloCastSubtitleDelayMs by rememberUpdatedState(subtitleDelayMs) - val latestSiloCastHdrEnabled by rememberUpdatedState(hdrEnabled) - val latestSiloCastSubtitleAppearance by rememberUpdatedState(subtitleAppearance) val context = LocalContext.current val hdrDisplayController = remember { HdrDisplayController() } // Bind the playback display before anything plans: the ViewModel's @@ -428,39 +430,17 @@ fun TvPlayerScreen( var playerRootBounds by remember { mutableStateOf(null) } var pictureInPictureSourceRect by remember { mutableStateOf(null) } - // Watch Together binding. Built once per roomId; null for solo playback. - // The process RoomSession owns the WS; this controller owns only the - // screen's RoomSyncEngine and requests durable teardown on explicit leave. - val watchTogetherRepository: org.siloserver.silo.repository.WatchTogetherRepository = koinInject() - val roomSession: org.siloserver.silo.watchtogether.RoomSession = koinInject() - val roomScope = rememberCoroutineScope() - val roomController = remember(roomId) { - roomId?.takeIf { it.isNotBlank() }?.let { id -> - TvRoomSyncController( - roomId = id, - repository = watchTogetherRepository, - roomSession = roomSession, - viewModel = viewModel, - scope = roomScope, - ) - } - } - DisposableEffect(roomController) { - roomController?.start() - // Repo teardown happens on explicit leave (Leave affordance) or - // room_closed; only this replaceable controller's child jobs are - // canceled on disposal. - onDispose { roomController?.dispose() } - } - val roomSnapshot by (roomController?.room ?: kotlinx.coroutines.flow.MutableStateFlow(null)) - .collectAsState() - val roomClosedReason by (roomController?.closedReason ?: kotlinx.coroutines.flow.MutableStateFlow(null)) - .collectAsState() - var showLeaveDialog by remember { mutableStateOf(false) } + // Watch Party binding. Built once per roomId; null for solo playback. + // The process RoomSession owns the room socket; this screen owns only its + // playback binding, and disposing it never leaves the room. + val watchParty = rememberTvWatchParty(roomId, viewModel) + val roomSnapshot: RoomSnapshot? = watchParty?.playback?.room?.collectAsState()?.value + // Back in a party opens the party panel over the retained player. + var showPartyPanel by remember { mutableStateOf(false) } // Per-session playback control socket (admin remote control). Bound for the // lifetime of a sessionId; reconnects on its own and never interrupts - // playback. Separate from the Watch Together socket above. + // playback. Separate from the Watch Party room socket above. val playbackRealtimeClient: org.siloserver.silo.network.PlaybackRealtimeClient = koinInject() LaunchedEffect(state.sessionId) { val id = state.sessionId ?: return@LaunchedEffect @@ -469,6 +449,11 @@ fun TvPlayerScreen( client = playbackRealtimeClient, viewModel = viewModel, scope = this, // cancelled when sessionId changes / screen leaves + // Admin transport follows the room; Stop ends only this device's + // engagement (the exit follows through remoteStopRequests). + roomTransport = watchParty?.let { party -> + { action -> party.onRemoteTransport(action, exit = viewModel::remoteStop) } + }, ).start() } @@ -514,93 +499,32 @@ fun TvPlayerScreen( ) } } - val latestSiloCastMediaController by rememberUpdatedState(mediaController) - val latestSiloCastSessionPlayer by rememberUpdatedState(sessionPlayer) - DisposableEffect(siloCastReceiver, viewModel, contentId) { - val adapter = TvSiloCastPlayerAdapter( - play = { - // Watch Together is authoritative for transport: suppress - // SiloCast transport while in a room so a caster can't desync - // members, mirroring the realtime remote path's - // remoteTransportSuppressed gate. Non-room casting is unchanged. - if (!viewModel.remoteTransportSuppressed) { - viewModel.setPaused(false) - latestSiloCastMediaController?.play() - } - }, - pause = { - if (!viewModel.remoteTransportSuppressed) { - viewModel.setPaused(true) - latestSiloCastMediaController?.pause() - } - }, - playPause = { if (!viewModel.remoteTransportSuppressed) viewModel.onPlayPause() }, - seek = { seconds -> - if (!viewModel.remoteTransportSuppressed) { - viewModel.seekImmediate(seconds) - } - }, - stop = { if (!viewModel.remoteTransportSuppressed) viewModel.remoteStop() }, - selectAudio = { index -> viewModel.remoteSelectAudio(index.toInt()) }, - selectSubtitle = { index -> viewModel.remoteSelectSubtitle(index?.toInt() ?: -1) }, - setPlaybackSpeed = { speed -> - viewModel.onSetPlaybackSpeed(speed) - latestSiloCastMediaController?.playbackParameters = PlaybackParameters(speed.toFloat()) - }, - setQuality = { qualityId -> - val player = latestSiloCastMediaController ?: latestSiloCastSessionPlayer - if (player != null && selectVideoQuality(player, qualityId)) { - val resolution = viewModel.uiState.value.videoQualities - .firstOrNull { it.id == qualityId } - ?.resolution - viewModel.onVideoQualitySelectionApplied(resolution) - } - }, - setVideoGravity = { value -> - viewModel.onVideoFillModeChanged(value.toSiloCastVideoFillMode()) - }, - setSubtitleSyncMs = viewModel::onSubtitleDelayChanged, - setSubtitlePosition = { value -> - viewModel.onSetSubtitleAppearance( - latestSiloCastSubtitleAppearance.copy(position = value.toSiloCastSubtitlePosition()), - ) - }, - setVolume = { volume -> - latestSiloCastMediaController?.let { controller -> - val next = volume.toFloat().coerceIn(0f, 1f) - siloCastReceiver.recordPlayerVolume(next.toDouble()) - controller.volume = next - } - }, - setMuted = { muted -> - latestSiloCastMediaController?.let { controller -> - siloCastReceiver.recordPlayerMuted(muted, controller.volume.toDouble()) - controller.volume = if (muted) 0f else siloCastReceiver.retainedPlayerVolume().toFloat() - } - }, - playNext = viewModel::playNextEpisodeNow, - ) - val registration = siloCastReceiver.registerPlayer(adapter) { - val volumeState = siloCastReceiver.resolvePlayerVolume( - currentVolume = latestSiloCastMediaController?.volume?.toDouble(), - ) - viewModel.uiState.value.toSiloCastPlaybackState( - playbackSpeed = latestSiloCastPlaybackSpeed, - hdrEnabled = latestSiloCastHdrEnabled, - subtitleDelayMs = latestSiloCastSubtitleDelayMs, - subtitleAppearance = latestSiloCastSubtitleAppearance, - volumeState = volumeState, - ) - } - onDispose { registration.close() } - } - val stopPlaybackAndExit = { + TvSiloCastPlayerRegistration( + siloCastReceiver = siloCastReceiver, + viewModel = viewModel, + contentId = contentId, + watchParty = watchParty, + mediaController = mediaController, + sessionPlayer = sessionPlayer, + playbackSpeed = playbackSpeed, + subtitleDelayMs = subtitleDelayMs, + hdrEnabled = hdrEnabled, + subtitleAppearance = subtitleAppearance, + ) + /** + * The one terminal exit. It leaves the Watch Party when [leaveRoom] + * (explicit host-close paths depart first; leaving is idempotent). Host + * Stop and an ended party exit without leaving: the first keeps the + * membership for the lobby, the second has nothing left to leave. + * + * Lambdas rather than local functions: every caller captures one value + * instead of the exit's dependencies, which keeps this composable's + * method within ART's JIT limit. + */ + val exitPlayer: (Boolean, (() -> Unit)?) -> Unit = { leaveRoom, destination -> if (!exitRequested) { exitRequested = true - // Every terminal player exit must release the process-owned room - // session. Explicit host-close paths enqueue close first, then this - // idempotent local departure follows behind it. - roomController?.leave(closeRoom = false) + if (leaveRoom) watchParty?.leave() mediaController?.let { controller -> viewModel.stopSessionForExitAsync( positionMs = controller.currentPosition, @@ -610,16 +534,34 @@ fun TvPlayerScreen( controller.stop() controller.clearMediaItems() } ?: viewModel.stopSessionForExitAsync() - latestOnExit() + (destination ?: latestOnExit)() } } + val stopPlaybackAndExit = { exitPlayer(true, null) } // A remote "stop"/"terminate" command tears the screen down like a Back press. LaunchedEffect(Unit) { viewModel.remoteStopRequests.collect { stopPlaybackAndExit() } } - val latestIntroSkipState by rememberUpdatedState(introSkipState) + val latestOnReturnToWatchPartyLobby by rememberUpdatedState(onReturnToWatchPartyLobby) + val latestOnWatchPartyEnded by rememberUpdatedState(onWatchPartyEnded) + if (watchParty != null && roomId != null) { + TvWatchPartyEffects( + watchParty = watchParty, + roomId = roomId, + viewModel = viewModel, + player = mediaController, + onReturnToLobby = { exitPlayer(false) { latestOnReturnToWatchPartyLobby(roomId) } }, + onPartyEnded = { exitPlayer(false) { latestOnWatchPartyEnded() } }, + ) + } + // Members who may not seek get no intro pill: its only action is a seek. + val canSeekInRoom = watchParty == null || roomTransportAuthorized(roomSnapshot, RoomTransportIntent.Seek) + val canPlayPauseInRoom = watchParty == null || + roomTransportAuthorized(roomSnapshot, RoomTransportIntent.PlayPause) + val visibleIntroSkipState = if (canSeekInRoom) introSkipState else IntroAutoSkipState.Hidden + val latestIntroSkipState by rememberUpdatedState(visibleIntroSkipState) val latestRoomSnapshot by rememberUpdatedState(roomSnapshot) - val latestShowLeaveDialog by rememberUpdatedState(showLeaveDialog) + val latestShowPartyPanel by rememberUpdatedState(showPartyPanel) val latestShowQuickSubtitlePicker by rememberUpdatedState(showQuickSubtitlePicker) val selectTvSubtitle: (SubtitleIdentity) -> Unit = { identity -> subtitleFocusedStableId = tvSubtitleOptionStableId(identity) @@ -655,14 +597,13 @@ fun TvPlayerScreen( if (!latestIntroSkipState.isVisible) return false // The controller decides where Select goes — the intro's end for the // `ask` offer, its start for `always`'s undo — and resolves the intro. - // In a room the gate is checked BEFORE asking, so a guest's refused + // In a room the pill only shows to members who may seek, and its Skip + // is a room seek; permission is checked BEFORE resolving, so a refused // press leaves the pill (and the intro) exactly as it was. - if (roomController != null) { - if (tvRoomTransportGate(latestRoomSnapshot, TvTransportIntent.Seek) != TransportGate.Send) { - return true - } + if (watchParty != null) { + if (!watchParty.canSeek()) return true val target = viewModel.onSelectIntroPrompt() ?: return false - roomController.onUserSeek(target) + watchParty.seek(target) } else { val soloTarget = viewModel.onSelectIntroPrompt() ?: return false viewModel.seekImmediate(soloTarget) @@ -702,23 +643,18 @@ fun TvPlayerScreen( ): Boolean { val controller = mediaController ?: return true val playerState = viewModel.uiState.value - if (roomController != null && - tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send - ) { - return true - } val duration = playerState.duration.takeIf { it > 0.0 } ?: if (playerState.playbackPlan == null) controller.duration / 1000.0 else 0.0 - val targetSec = if (roomController == null) { + val targetSec = if (watchParty == null) { viewModel.onSkipBy(deltaMs / 1000.0) } else { (playerState.position + deltaMs / 1000.0) .coerceAtLeast(0.0) .let { if (duration > 0.0) it.coerceAtMost(duration) else it } } - if (roomController != null) { - roomController.onUserSeek(targetSec) - } + // In a room the skip is a request; a refused one changes nothing here + // and the room's notice explains why. + if (watchParty != null && !watchParty.seek(targetSec)) return true if (revealControls) { if (!playerState.showControls) { requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Scrubber) @@ -730,7 +666,7 @@ fun TvPlayerScreen( // press reads as the bar twitching. Room seeks commit per press with no // accumulator, so there the per-press delta IS the total. val burstDeltaSec = viewModel.quickSkipBurstOriginSec - ?.takeIf { roomController == null } + ?.takeIf { watchParty == null } ?.let { targetSec - it } ?: (deltaMs / 1000.0) skipSeekFeedback = SkipSeekFeedback( @@ -739,7 +675,7 @@ fun TvPlayerScreen( durationSec = duration.coerceAtLeast(0.0), nonce = (skipSeekFeedback?.nonce ?: 0) + 1, ) - if (captureQuickSkipBurst && roomController == null) { + if (captureQuickSkipBurst && watchParty == null) { armQuickSkipCapture() } return true @@ -767,9 +703,13 @@ fun TvPlayerScreen( fun beginCleanPlaybackSeek(direction: Int, snapshot: RoomSnapshot?) { pendingCleanSeekBecameHold = true if (cleanSeekRate != 0 || mediaController == null) return - if (roomController != null && - tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send - ) { + if (watchParty != null && !watchParty.canSeek()) { + // Guests never seek: say so instead of scanning a preview nobody can commit. + viewModel.showPlayerMessage( + org.siloserver.silo.common.player.watchparty.watchPartyNoticeText( + org.siloserver.silo.watchtogether.RoomPlaybackNotice.Denied(RoomTransportIntent.Seek), + ), + ) return } @@ -879,10 +819,8 @@ fun TvPlayerScreen( fun commitCleanPlaybackSeek(snapshot: RoomSnapshot?) { val targetSec = cleanSeekPreviewSec stopCleanPlaybackSeek() - if (roomController != null) { - if (tvRoomTransportGate(snapshot, TvTransportIntent.Seek) == TransportGate.Send) { - roomController.onUserSeek(targetSec) - } + if (watchParty != null) { + watchParty.seek(targetSec) } else { viewModel.seekImmediate(targetSec) } @@ -924,6 +862,17 @@ fun TvPlayerScreen( } } + // After repeated stalls in a room: a one-line offer to step down the same + // file's quality ladder, taken with OK while the transport is hidden. + val qualityOffer = remember { TvWatchPartyQualityOfferState() } + val lowerQuality = tvWatchPartyLowerQuality( + watchParty = watchParty, + offerState = qualityOffer, + videoQualities = state.videoQualities, + selectedFileResolution = state.selectedFileResolution, + ) + val latestLowerQuality by rememberUpdatedState(lowerQuality) + // More-specific overlays register their own BackHandlers later in the // composition and therefore run first. This screen callback owns the // remaining player-state ladder on Android 16, where KEYCODE_BACK is no @@ -944,32 +893,28 @@ fun TvPlayerScreen( // dispatchKeyEvent, so a countdown Back would otherwise fall // through to hiding the controls or exiting the player. latestIntroSkipState.isVisible -> viewModel.onDismissIntroPrompt() + lowerQuality != null && !state.showControls -> qualityOffer.dismissed = true showQuickSubtitlePicker -> showQuickSubtitlePicker = false // On the Up-Next overlay, Back exits the player (matches tvOS, where // the Up-Next "Back" button dismisses the whole player). state.showNextUp -> stopPlaybackAndExit() state.hudOpen -> viewModel.closeHUD() - showLeaveDialog -> showLeaveDialog = false + showPartyPanel -> showPartyPanel = false // While PLAYING, Back steps controls -> hidden before exiting. // While PAUSED, hiding controls would just strand a frozen frame, // so Back falls through to the exit (or room-leave) flow instead — // Apple parity (silo-apple f12a928). state.showControls && !state.isPaused -> viewModel.setControlsVisible(false) - // In a room: Back surfaces the Leave affordance. Host gets a - // close-confirm dialog (closing tears down the room for everyone); - // a guest leaves immediately. - roomController != null && roomSnapshot?.isHost == true -> showLeaveDialog = true - roomController != null -> { - roomController.leave(closeRoom = false) - stopPlaybackAndExit() - } + // In a party: Back opens the party panel over the retained + // player (Leave, and for the host Stop and End). Never an exit. + watchParty != null -> showPartyPanel = true else -> { stopPlaybackAndExit() } } } - DisposableEffect(viewModel, roomController) { + DisposableEffect(viewModel, watchParty) { val handler: (KeyEvent) -> Boolean = handler@{ event -> val playerState = viewModel.uiState.value if (event.keyCode == KeyEvent.KEYCODE_BACK) { @@ -1097,7 +1042,7 @@ fun TvPlayerScreen( viewModel.setControlsVisible(true) } if (latestShowQuickSubtitlePicker || - playerState.hudOpen || latestShowLeaveDialog || + playerState.hudOpen || latestShowPartyPanel || // The Up-Next overlay is a focus-trapping Compose surface that // owns its own remote input (Play Now / Keep Watching / Back) — // don't let the transport bridge toggle play/pause underneath it. @@ -1164,6 +1109,28 @@ fun TvPlayerScreen( return@handler handleIntroPromptSelect() } + // The lower-quality offer owns OK and Back while the transport is + // hidden, like the intro pill. + val offeredQuality = latestLowerQuality + if (offeredQuality != null && !playerState.showControls && event.repeatCount == 0) { + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + -> { + if (event.action == KeyEvent.ACTION_DOWN) { + qualityOffer.dismissed = true + switchTvPlaybackQuality(viewModel, watchParty, offeredQuality.id) + } + return@handler true + } + KeyEvent.KEYCODE_BACK -> { + if (event.action == KeyEvent.ACTION_DOWN) qualityOffer.dismissed = true + return@handler true + } + } + } + // Back while PLAYING with the transport overlay up: hide the // overlay HERE, at the key-dispatch bridge, before Compose's // focus system can eat the press as a button focus-deselection @@ -1176,7 +1143,7 @@ fun TvPlayerScreen( !playerState.hudOpen && !playerState.showNextUp && !latestShowQuickSubtitlePicker && - !latestShowLeaveDialog + !latestShowPartyPanel ) { if (event.action == KeyEvent.ACTION_UP) { // While scrubbing, Back cancels the in-flight scrub (drop the @@ -1192,17 +1159,12 @@ fun TvPlayerScreen( when (action) { TvPlayerRemoteKeyAction.PlayPause -> { - val canPlayPauseInRoom = roomController == null || - tvRoomTransportGate( - latestRoomSnapshot, - TvTransportIntent.PlayPause, - ) == TransportGate.Send - if (canPlayPauseInRoom) { - if (roomController != null) { - roomController.onUserPlayPause() - } else { - viewModel.onPlayPause() - } + // In a room every press goes to the room, which applies, + // refuses with a notice, or resumes a local hold. + if (watchParty != null) { + watchParty.togglePlayPause() + } else { + viewModel.onPlayPause() } viewModel.setControlsVisible(true) requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Transport) @@ -1250,15 +1212,6 @@ fun TvPlayerScreen( onDispose { TvPlayerRemoteKeyBridge.clear(handler) } } - // room_closed (TERMINAL only — host left / explicit close) → stop + exit - // back to detail. Transient server `error` frames never reach here (they - // flow on the repo's errors stream and do NOT eject the user). - LaunchedEffect(roomClosedReason) { - if (roomClosedReason != null && roomController != null) { - stopPlaybackAndExit() - } - } - // A subtitle or audio change that failed has to say so. Stage, validation, // commit, rollback and mount failures all populated subtitleFailureMessage // and nothing ever read it: "Applying…" simply vanished and the tick @@ -1271,19 +1224,6 @@ fun TvPlayerScreen( viewModel.onSubtitleFailureShown(state.subtitleFailureId) } - // Surface transient Watch Together server rejections (e.g. a guest seek the - // server refuses) as a brief Toast. These flow on the repo errors stream and - // do NOT eject the user. Only collected while bound to a room. - LaunchedEffect(roomController) { - if (roomController != null) { - watchTogetherRepository.errors.collect { message -> - if (message.isNotBlank()) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - } - } - // Apply capability-aware track selection presets. Re-runs on HDMI // hot-plug / AVR power cycle so Atmos and DV stay preferred as the sink // reports them. Also re-runs when the user flips the HDR toggle in the @@ -1370,9 +1310,10 @@ fun TvPlayerScreen( onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } - val latestLifecycleRoomSnapshot by rememberUpdatedState(roomSnapshot) - // Lifecycle pausing — send pause to the service when we're backgrounded. + // In a Watch Party leaving the foreground is a local hold instead: the + // player pauses, nothing is reported or asked of the room, and the + // membership survives. Coming back resyncs through the room binding. DisposableEffect( lifecycleOwner, mediaController, @@ -1383,6 +1324,7 @@ fun TvPlayerScreen( when (event) { Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> if (!isInPictureInPictureMode) { + if (watchParty != null) viewModel.holdRoomPlayback(TvRoomHold.Background) mediaController?.let { controller -> if (controller.playWhenReady) { if ( @@ -1400,26 +1342,15 @@ fun TvPlayerScreen( // destination too: the session cannot be resumed after // teardown, and keeping this route mounted would show a // stopped/black player when the activity resumes. - if (event == Lifecycle.Event.ON_STOP && !exitRequested) { + // + // Not in a Watch Party: the room needs this session, and + // backgrounding must never leave the room. + if (event == Lifecycle.Event.ON_STOP && !exitRequested && watchParty == null) { stopPlaybackAndExit() } } - Lifecycle.Event.ON_RESUME -> if (roomController != null) { - val desired = latestLifecycleRoomSnapshot?.isPaused?.not() - mediaController?.let { controller -> - if ( - desired != null && - (controller.playWhenReady != desired || - playWhenReadyReconciliationGate.hasPendingChanges) - ) { - if ( - playWhenReadyReconciliationGate - .requestProgrammaticChange(desired) - ) { - controller.playWhenReady = desired - } - } - } + Lifecycle.Event.ON_RESUME -> if (watchParty != null) { + viewModel.releaseRoomPlayback(TvRoomHold.Background) } else -> Unit } @@ -1512,22 +1443,59 @@ fun TvPlayerScreen( val provenance = playWhenReadyReconciliationGate .onPlayWhenReadyChanged(playWhenReady, reason) provenance.followUpProgrammaticValue?.let { controller.playWhenReady = it } - if (!provenance.shouldReconcile) return - roomController - ?.onExternalPlayWhenReadyChanged(playWhenReady) - ?.let { authoritative -> - if (controller.playWhenReady != authoritative) { - if ( - playWhenReadyReconciliationGate - .requestProgrammaticChange(authoritative) - ) { - controller.playWhenReady = authoritative - } - } - } + val party = watchParty ?: return + // Audio focus loss and a noisy route are local holds: + // nothing is sent to the room. + if (!playWhenReady && + (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS || + reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY) + ) { + party.sample(controller) + viewModel.holdRoomPlayback(TvRoomHold.AudioFocus) + return + } + if (!provenance.shouldReconcile) { + party.sample(controller) + return + } + // A deliberate MediaSession play/pause: the room decides, + // and the player goes back to its room-applied state now. + val restore = party.onExternalPlayWhenReady(playWhenReady) + if (controller.playWhenReady != restore && + playWhenReadyReconciliationGate.requestProgrammaticChange(restore) + ) { + controller.playWhenReady = restore + } + // Sample only after the restore (the controller masks it + // at once), so a report never carries the outside change. + party.sample(controller) + } + + override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) { + watchParty?.sample(controller) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + val party = watchParty ?: return + // A seek this screen did not issue (notification, headset, + // MediaSession seek back/forward, any other controller). + // Mounts replace the item and never arrive as a seek. + if (reason == Player.DISCONTINUITY_REASON_SEEK && + oldPosition.mediaItemIndex == newPosition.mediaItemIndex + ) { + party.onSeekDiscontinuity(oldPosition.positionMs, newPosition.positionMs) + } + // Restoring an outside seek marks it pending before the + // room can observe the unaccepted target. + party.sample(controller) } override fun onIsPlayingChanged(isPlaying: Boolean) { + watchParty?.sample(controller) viewModel.onPlayingChanged(isPlaying) val live = viewModel.uiState.value val key = live.sessionId?.let { sessionId -> @@ -1547,6 +1515,9 @@ fun TvPlayerScreen( } } override fun onPlaybackStateChanged(playbackState: Int) { + // Readiness is evaluated on every signal that can mean + // playable media, not only on the sampling tick. + watchParty?.sample(controller) // Buffering during normal playback flips the centered // spinner. This complements the lifecycle's Reconnecting // state which the player can't observe (server-outage @@ -1611,38 +1582,6 @@ fun TvPlayerScreen( } } - // Position polling — lifecycle-bounded so it doesn't outlive the screen. - LaunchedEffect(mediaController, state.sessionId, lifecycleOwner) { - val controller = mediaController ?: return@LaunchedEffect - val timelineWindow = Timeline.Window() - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - while (isActive && state.sessionId != null) { - viewModel.onPositionChanged( - controller.currentPosition, - controller.duration.coerceAtLeast(0L), - ) - // Mounted-transport extent for the VM's native-first seek - // decision (see TvPlayerViewModel.mountedSeekableSourceRange). - // A seekable window with a known length can serve any target - // it spans without a server reanchor. - if (!controller.currentTimeline.isEmpty) { - controller.currentTimeline.getWindow( - controller.currentMediaItemIndex, - timelineWindow, - ) - viewModel.onPlayerWindowChanged( - isSeekable = timelineWindow.isSeekable, - windowEndPlayerMs = if (timelineWindow.durationUs != C.TIME_UNSET) { - timelineWindow.durationUs / 1000 - } else { - -1L - }, - ) - } - delay(500) - } - } - } LaunchedEffect( mediaController, state.sessionId, @@ -1699,10 +1638,11 @@ fun TvPlayerScreen( renderedOutputBufferCount = decoderCounters?.renderedOutputBufferCount, )) { PostResumeVideoStallDetector.Signal.SeekBack -> { - controller.seekTo( - (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) - .coerceAtLeast(0L), - ) + val target = (controller.currentPosition - PostResumeVideoStallDetector.SEEK_BACK_MS) + .coerceAtLeast(0L) + // A local recovery action, never a room seek. + watchParty?.recordIssuedSeek(target) + controller.seekTo(target) viewModel.onRuntimeCorrection( "runtime_correction_applied", "client_post_resume_video_recovery_v1", @@ -1714,6 +1654,7 @@ fun TvPlayerScreen( val resume = controller.playWhenReady controller.stop() controller.prepare() + watchParty?.recordIssuedSeek(position) controller.seekTo(position) if (resume) controller.play() viewModel.onRuntimeCorrection( @@ -1747,26 +1688,6 @@ fun TvPlayerScreen( } // Prepare the player when a stream URL becomes available. - // Applies a local audio switch: the track is already in the mounted stream, - // so it only needs selecting on the player. The ViewModel does not commit - // on the strength of this call -- AudioTrackManager returns Unit and does - // nothing silently if the group is gone -- it waits for onTracksChanged to - // show the target selected. - LaunchedEffect(videoBackend) { - val backend = videoBackend ?: return@LaunchedEffect - viewModel.pendingLocalAudioSelection.collect { request -> - request ?: return@collect - backend.selectAudioTrack( - VideoPlayerTrackEntry( - index = request.targetOrdinal, - label = "", - language = null, - isSelected = true, - ), - ) - } - } - LaunchedEffect( videoBackend, state.sessionId, @@ -1894,63 +1815,17 @@ fun TvPlayerScreen( viewModel.onSubtitleCuesMounted(state.subtitleCueRevisions) } - // The single path from the subtitle transaction adapter to the player. - // Every request carries the owner that armed it, so the acknowledgement can - // never be dropped for want of one. Mirrors the seekRequests idiom. - LaunchedEffect(videoBackend) { - val backend = videoBackend ?: return@LaunchedEffect - viewModel.subtitleMountRequests.collect { request -> - if (!viewModel.canApplySubtitleMount(request)) return@collect - if (request.trackIndex == -1) { - if (backend.selectSubtitle(null)) { - viewModel.onSubtitleSelectionApplied(request) - } else { - viewModel.onSubtitleSelectionFailed(request) - } - return@collect - } - val selectedTrack = viewModel.uiState.value.subtitleTracks - .firstOrNull { it.index == request.trackIndex } - ?.toVideoTrackEntry() - if (selectedTrack != null && backend.selectSubtitle(selectedTrack)) { - viewModel.onSubtitleSelectionApplied(request) - } else { - viewModel.onSubtitleSelectionFailed(request) - } - } - } - - // Remote set_audio_track / set_subtitle_track are latched and resolved in - // the ViewModel after stable track identities exist. Only the transaction - // adapter may emit a backend subtitle mount request. - - // Mirror user-intent pause state into the player. Kept separate from the - // onPlayingChanged listener so a transient buffering stall can't flip the - // pause icon or cancel the auto-hide timer. - LaunchedEffect(mediaController, state.isPaused, playWhenReadyReconciliationGate) { - val controller = mediaController ?: return@LaunchedEffect - val desired = !state.isPaused - if (controller.playWhenReady != desired) { - if (playWhenReadyReconciliationGate.requestProgrammaticChange(desired)) { - controller.playWhenReady = desired - } - } - } - - LaunchedEffect(mediaController) { - val controller = mediaController ?: return@LaunchedEffect - viewModel.seekRequests.collect { targetSec -> - controller.seekTo((targetSec * 1000).toLong()) - } - } - - // Apply per-profile playback speed to the MediaController. Uses - // PlaybackParameters because MediaController doesn't expose a direct - // setPlaybackSpeed setter that respects pitch correction defaults. - LaunchedEffect(mediaController, playbackSpeed) { - val controller = mediaController ?: return@LaunchedEffect - controller.playbackParameters = PlaybackParameters(playbackSpeed.toFloat()) - } + TvPlayerCommandEffects( + videoBackend = videoBackend, + mediaController = mediaController, + sessionId = state.sessionId, + lifecycleOwner = lifecycleOwner, + viewModel = viewModel, + watchParty = watchParty, + isPaused = state.isPaused, + playWhenReadyReconciliationGate = playWhenReadyReconciliationGate, + playbackSpeed = playbackSpeed, + ) // Apply user subtitle styling whenever the PlayerView mounts or the // appearance flow emits a new value. Mirrors the phone PlayerScreen. @@ -2149,10 +2024,7 @@ fun TvPlayerScreen( // In a room, transport authority gates what the local // member may drive: a guest who can't seek gets a disabled // scrubber + skip; play/pause only under guest_play_pause. - val canSeekInRoom = roomController == null || - tvRoomTransportGate(roomSnapshot, TvTransportIntent.Seek) == TransportGate.Send - val canPlayPauseInRoom = roomController == null || - tvRoomTransportGate(roomSnapshot, TvTransportIntent.PlayPause) == TransportGate.Send + // (canSeekInRoom / canPlayPauseInRoom are computed above.) val bufferedAheadSec = ( (mediaController?.bufferedPosition ?: 0L) - (mediaController?.currentPosition ?: 0L) @@ -2179,10 +2051,12 @@ fun TvPlayerScreen( // command → engine applies the seek locally). Solo // playback seeks the MediaController directly. transportEnabled = canSeekInRoom, - playPauseEnabled = canPlayPauseInRoom, + // A local hold (audio focus, sleep timer) is the + // viewer's own to clear, whatever the room policy. + playPauseEnabled = canPlayPauseInRoom || viewModel.isRoomHeld, skipBackSeconds = seekIntervals.backSeconds, skipForwardSeconds = seekIntervals.forwardSeconds, - canToggleAfterCommit = roomController == null, + canToggleAfterCommit = watchParty == null, onSkipBack = { if (canSeekInRoom) { performRelativeSeek( @@ -2206,8 +2080,8 @@ fun TvPlayerScreen( onCommitScrub = { val targetSec = viewModel.commitScrub() if (!canSeekInRoom) return@TvPlayerIdleOverlay - if (roomController != null) { - roomController.onUserSeek(targetSec) + if (watchParty != null) { + watchParty.seek(targetSec) } else { // seekImmediate pre-writes position so a scrub // committed into the credits region isn't mistaken @@ -2219,9 +2093,11 @@ fun TvPlayerScreen( onCancelScrub = { viewModel.cancelScrub() }, focusRequest = idleOverlayFocusRequest, onPlayPause = { - if (!canPlayPauseInRoom) return@TvPlayerIdleOverlay - if (roomController != null) { - roomController.onUserPlayPause() + // In a room every press goes to the room, which + // applies it or refuses with a notice, like the + // remote's play/pause key. + if (watchParty != null) { + watchParty.togglePlayPause() } else { viewModel.onPlayPause() } @@ -2246,15 +2122,7 @@ fun TvPlayerScreen( null }, onClose = { - when { - roomController != null && roomSnapshot?.isHost == true -> - showLeaveDialog = true - roomController != null -> { - roomController.leave(closeRoom = false) - stopPlaybackAndExit() - } - else -> stopPlaybackAndExit() - } + if (watchParty != null) showPartyPanel = true else stopPlaybackAndExit() }, ) } @@ -2281,6 +2149,8 @@ fun TvPlayerScreen( fileVersions = state.fileVersions, selectedFileId = state.selectedFileId ?: state.mediaFileId, onSelectFileVersion = viewModel::onSelectFileVersion, + // A Watch Party plays one exact file at 1x. + versionAndSpeedControlsVisible = watchParty == null, subtitleTracks = state.subtitleTracks, subtitleUrls = state.subtitleUrls, subtitlePresentation = subtitlePresentation, @@ -2293,10 +2163,10 @@ fun TvPlayerScreen( onSelectVideoQuality = { id -> // Server-transcode quality ladder (tvOS parity): // re-request the session at the chosen rung. - viewModel.switchQuality(id) + switchTvPlaybackQuality(viewModel, watchParty, id) }, onVideoFillModeChanged = viewModel::onVideoFillModeChanged, - playbackSpeed = playbackSpeed, + playbackSpeed = if (watchParty != null) 1.0 else playbackSpeed, onPlaybackSpeedChanged = viewModel::onSetPlaybackSpeed, sleepTimerState = sleepTimerState, onStartSleepTimer = viewModel::onStartSleepTimer, @@ -2346,14 +2216,11 @@ fun TvPlayerScreen( chapters = state.chapters, onSelectChapter = { idx -> viewModel.onSeekToChapter(idx)?.let { sec -> - if (roomController != null) { - // In a room, route through the same gated - // path as scrub-commit / performRelativeSeek: - // transport authority decides (a guest is a - // no-op) and a permitted seek broadcasts. - if (tvRoomTransportGate(roomSnapshot, TvTransportIntent.Seek) == TransportGate.Send) { - roomController.onUserSeek(sec) - } + if (watchParty != null) { + // A chapter jump is a room seek: the + // host's is sent; anyone else's is + // refused with a notice. + watchParty.seek(sec) } else { // Solo: seekImmediate pre-writes position so a // chapter jump into credits isn't mistaken for @@ -2486,17 +2353,16 @@ fun TvPlayerScreen( subtitleSyncNotice = viewModel.subtitleSyncNotice, remoteMessage = remoteMessage, roomSnapshot = roomSnapshot, - roomActive = roomController != null, + roomActive = watchParty != null, showControls = state.showControls, hudOpen = state.hudOpen, - showLeaveDialog = showLeaveDialog, showNextUp = state.showNextUp, nextEpisode = state.nextEpisode, nextUpVideoEnded = state.nextUpVideoEnded, nextUpCountdownSeconds = state.nextUpCountdownSeconds, nextUpCountdownTotalSeconds = state.nextUpCountdownTotalSeconds, autoPlayNextEnabled = autoPlayNextEnabled, - introSkipState = introSkipState, + introSkipState = visibleIntroSkipState, introSkipCountdownRun = introSkipCountdownRun, introSkipTimerRunning = introSkipTimerRunning, introSkipTotalSeconds = viewModel.introSkipTotalSeconds, @@ -2511,19 +2377,275 @@ fun TvPlayerScreen( showNextUp = state.showNextUp, isInPictureInPictureMode = isInPictureInPictureMode, ), - onCloseRoom = { - showLeaveDialog = false - roomController?.leave(closeRoom = true) - stopPlaybackAndExit() - }, - onCancelLeaveDialog = { showLeaveDialog = false }, onPlayNextNow = viewModel::playNextEpisodeNow, onKeepWatching = viewModel::dismissNextUp, onToggleAutoPlayNext = { viewModel.onSetAutoPlayNext(!autoPlayNextEnabled) }, onExitPlayback = { stopPlaybackAndExit() }, onNextUpVideoBoundsChanged = { nextUpVideoBounds = it }, onIntroPromptSelect = { handleIntroPromptSelect() }, + qualityOfferLabel = lowerQuality?.label?.takeIf { + !state.showControls && !state.hudOpen && !state.showNextUp + }, ) + if (watchParty != null) { + TvWatchPartyPlayerOverlays( + watchParty = watchParty, + panelOpen = showPartyPanel, + isInPictureInPictureMode = isInPictureInPictureMode, + playerFocus = rootFocus, + playerFocused = playerRootHasFocus, + onPanelOpenChange = { showPartyPanel = it }, + exit = stopPlaybackAndExit, + ) + } + } +} + +/** + * The position poll and the ViewModel's requests to the player: local audio + * switches, subtitle mounts, the pause mirror, seeks, and the playback speed. + * + * Split out of [TvPlayerScreen] to keep that composable's generated method + * within ART's JIT limit (see [TvPlayerOverlays]). + */ +@Composable +private fun TvPlayerCommandEffects( + videoBackend: VideoPlaybackBackend?, + mediaController: MediaController?, + sessionId: String?, + lifecycleOwner: androidx.lifecycle.LifecycleOwner, + viewModel: TvPlayerViewModel, + watchParty: TvWatchPartyScreenController?, + isPaused: Boolean, + playWhenReadyReconciliationGate: PlayWhenReadyReconciliationGate, + playbackSpeed: Double, +) { + // Position polling — lifecycle-bounded so it doesn't outlive the screen. + LaunchedEffect(mediaController, sessionId, lifecycleOwner) { + val controller = mediaController ?: return@LaunchedEffect + val timelineWindow = Timeline.Window() + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive && sessionId != null) { + viewModel.onPositionChanged( + controller.currentPosition, + controller.duration.coerceAtLeast(0L), + ) + // Mounted-transport extent for the VM's native-first seek + // decision (see TvPlayerViewModel.mountedSeekableSourceRange). + // A seekable window with a known length can serve any target + // it spans without a server reanchor. + if (!controller.currentTimeline.isEmpty) { + controller.currentTimeline.getWindow( + controller.currentMediaItemIndex, + timelineWindow, + ) + viewModel.onPlayerWindowChanged( + isSeekable = timelineWindow.isSeekable, + windowEndPlayerMs = if (timelineWindow.durationUs != C.TIME_UNSET) { + timelineWindow.durationUs / 1000 + } else { + -1L + }, + ) + } + delay(500) + } + } + } + + // Applies a local audio switch: the track is already in the mounted stream, + // so it only needs selecting on the player. The ViewModel does not commit + // on the strength of this call -- AudioTrackManager returns Unit and does + // nothing silently if the group is gone -- it waits for onTracksChanged to + // show the target selected. + LaunchedEffect(videoBackend) { + val backend = videoBackend ?: return@LaunchedEffect + viewModel.pendingLocalAudioSelection.collect { request -> + request ?: return@collect + backend.selectAudioTrack( + VideoPlayerTrackEntry( + index = request.targetOrdinal, + label = "", + language = null, + isSelected = true, + ), + ) + } + } + + // The single path from the subtitle transaction adapter to the player. + // Every request carries the owner that armed it, so the acknowledgement can + // never be dropped for want of one. Mirrors the seekRequests idiom. + LaunchedEffect(videoBackend) { + val backend = videoBackend ?: return@LaunchedEffect + viewModel.subtitleMountRequests.collect { request -> + if (!viewModel.canApplySubtitleMount(request)) return@collect + if (request.trackIndex == -1) { + if (backend.selectSubtitle(null)) { + viewModel.onSubtitleSelectionApplied(request) + } else { + viewModel.onSubtitleSelectionFailed(request) + } + return@collect + } + val selectedTrack = viewModel.uiState.value.subtitleTracks + .firstOrNull { it.index == request.trackIndex } + ?.toVideoTrackEntry() + if (selectedTrack != null && backend.selectSubtitle(selectedTrack)) { + viewModel.onSubtitleSelectionApplied(request) + } else { + viewModel.onSubtitleSelectionFailed(request) + } + } + } + + // Remote set_audio_track / set_subtitle_track are latched and resolved in + // the ViewModel after stable track identities exist. Only the transaction + // adapter may emit a backend subtitle mount request. + + // Mirror user-intent pause state into the player. Kept separate from the + // onPlayingChanged listener so a transient buffering stall can't flip the + // pause icon or cancel the auto-hide timer. + LaunchedEffect(mediaController, isPaused, playWhenReadyReconciliationGate) { + val controller = mediaController ?: return@LaunchedEffect + val desired = !isPaused + if (controller.playWhenReady != desired) { + if (playWhenReadyReconciliationGate.requestProgrammaticChange(desired)) { + controller.playWhenReady = desired + } + } + } + + LaunchedEffect(mediaController) { + val controller = mediaController ?: return@LaunchedEffect + viewModel.seekRequests.collect { targetSec -> + val targetMs = (targetSec * 1000).toLong() + watchParty?.recordIssuedSeek(targetMs) + controller.seekTo(targetMs) + } + } + + // Apply per-profile playback speed to the MediaController (a Watch Party + // runs at 1x or the room's correction rate instead). + TvPlaybackSpeedEffect(mediaController, watchParty != null, viewModel, playbackSpeed) +} + +/** + * Registers this player with the SiloCast receiver for the screen's lifetime. + * + * Split out of [TvPlayerScreen] to keep that composable's generated method + * within ART's JIT limit (see [TvPlayerOverlays]). + */ +@Composable +private fun TvSiloCastPlayerRegistration( + siloCastReceiver: TvSiloCastReceiver, + viewModel: TvPlayerViewModel, + contentId: String, + watchParty: TvWatchPartyScreenController?, + mediaController: MediaController?, + sessionPlayer: Player?, + playbackSpeed: Double, + subtitleDelayMs: Int, + hdrEnabled: Boolean, + subtitleAppearance: SubtitleAppearance, +) { + val latestSiloCastPlaybackSpeed by rememberUpdatedState(playbackSpeed) + val latestSiloCastSubtitleDelayMs by rememberUpdatedState(subtitleDelayMs) + val latestSiloCastHdrEnabled by rememberUpdatedState(hdrEnabled) + val latestSiloCastSubtitleAppearance by rememberUpdatedState(subtitleAppearance) + val latestSiloCastMediaController by rememberUpdatedState(mediaController) + val latestSiloCastSessionPlayer by rememberUpdatedState(sessionPlayer) + DisposableEffect(siloCastReceiver, viewModel, contentId) { + // In a Watch Party a phone's transport is one more input: the TV + // membership's permissions decide, so a host's phone controls the + // room and a guest's is refused with a notice. Stop ends only this + // device's engagement. Solo casting is unchanged. + val adapter = TvSiloCastPlayerAdapter( + play = { + if (watchParty != null) { + watchParty.setPlaying(play = true) + } else { + viewModel.setPaused(false) + latestSiloCastMediaController?.play() + } + }, + pause = { + if (watchParty != null) { + watchParty.setPlaying(play = false) + } else { + viewModel.setPaused(true) + latestSiloCastMediaController?.pause() + } + }, + playPause = { + if (watchParty != null) watchParty.togglePlayPause() else viewModel.onPlayPause() + }, + seek = { seconds -> + if (watchParty != null) watchParty.seek(seconds) else viewModel.seekImmediate(seconds) + }, + stop = { + watchParty?.leave() + viewModel.remoteStop() + }, + selectAudio = { index -> viewModel.remoteSelectAudio(index.toInt()) }, + selectSubtitle = { index -> viewModel.remoteSelectSubtitle(index?.toInt() ?: -1) }, + setPlaybackSpeed = { speed -> + // Refused in a Watch Party, and never written to the preference. + tvSiloCastPlaybackSpeed(inWatchParty = watchParty != null, requested = speed)?.let { applied -> + viewModel.onSetPlaybackSpeed(applied) + latestSiloCastMediaController?.playbackParameters = PlaybackParameters(applied.toFloat()) + } + }, + setQuality = { qualityId -> + val player = latestSiloCastMediaController ?: latestSiloCastSessionPlayer + if (player != null && selectVideoQuality(player, qualityId)) { + val resolution = viewModel.uiState.value.videoQualities + .firstOrNull { it.id == qualityId } + ?.resolution + viewModel.onVideoQualitySelectionApplied(resolution) + watchParty?.onQualityChanged() + } + }, + setVideoGravity = { value -> + viewModel.onVideoFillModeChanged(value.toSiloCastVideoFillMode()) + }, + setSubtitleSyncMs = viewModel::onSubtitleDelayChanged, + setSubtitlePosition = { value -> + viewModel.onSetSubtitleAppearance( + latestSiloCastSubtitleAppearance.copy(position = value.toSiloCastSubtitlePosition()), + ) + }, + setVolume = { volume -> + latestSiloCastMediaController?.let { controller -> + val next = volume.toFloat().coerceIn(0f, 1f) + siloCastReceiver.recordPlayerVolume(next.toDouble()) + controller.volume = next + } + }, + setMuted = { muted -> + latestSiloCastMediaController?.let { controller -> + siloCastReceiver.recordPlayerMuted(muted, controller.volume.toDouble()) + controller.volume = if (muted) 0f else siloCastReceiver.retainedPlayerVolume().toFloat() + } + }, + playNext = viewModel::playNextEpisodeNow, + // A phone's launch never silently replaces the party player. + launchRefusal = { watchParty?.refuseLaunch() }, + ) + val registration = siloCastReceiver.registerPlayer(adapter) { + val volumeState = siloCastReceiver.resolvePlayerVolume( + currentVolume = latestSiloCastMediaController?.volume?.toDouble(), + ) + viewModel.uiState.value.toSiloCastPlaybackState( + // A Watch Party plays at 1x; its correction rate is never shown. + playbackSpeed = if (watchParty != null) 1.0 else latestSiloCastPlaybackSpeed, + hdrEnabled = latestSiloCastHdrEnabled, + subtitleDelayMs = latestSiloCastSubtitleDelayMs, + subtitleAppearance = latestSiloCastSubtitleAppearance, + volumeState = volumeState, + ) + } + onDispose { registration.close() } } } @@ -2853,14 +2975,12 @@ private fun TvQuickSubtitlePicker( } /** - * Top-end Watch Together status pill. Shows the live member count, a "Waiting - * for members…" line while the room sits on the wait barrier, and the join - * code for a member who can manage the room (host). + * Top-end Watch Party pill: the live member count, and the party code for a + * member who can manage the room (host). */ @Composable private fun TvRoomIndicator( memberCount: Int, - waiting: Boolean, joinCode: String?, ) { Column( @@ -2872,20 +2992,13 @@ private fun TvRoomIndicator( verticalArrangement = Arrangement.spacedBy(2.dp), ) { androidx.tv.material3.Text( - text = "Watch Together · $memberCount in room", + text = "Watch Party · $memberCount watching", color = Color.White, style = androidx.tv.material3.MaterialTheme.typography.labelLarge, ) if (joinCode != null) { androidx.tv.material3.Text( - text = "Code $joinCode", - color = Color.White.copy(alpha = 0.80f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - ) - } - if (waiting) { - androidx.tv.material3.Text( - text = "Waiting for members…", + text = "Party code $joinCode", color = Color.White.copy(alpha = 0.80f), style = androidx.tv.material3.MaterialTheme.typography.labelMedium, ) @@ -3140,65 +3253,6 @@ private fun TvCountdownRing(seconds: Int, totalSeconds: Int) { } } -@Composable -private fun TvRoomCloseConfirmDialog( - onClose: () -> Unit, - onCancel: () -> Unit, -) { - val closeActionFocus = remember { FocusRequester() } - - Popup( - alignment = Alignment.Center, - onDismissRequest = onCancel, - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = false, - clippingEnabled = false, - ), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .clip(RoundedCornerShape(24.dp)) - .background(Color.Black.copy(alpha = 0.92f)) - .padding(40.dp) - .then(rememberTvDialogInitialFocus(closeActionFocus)), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - androidx.tv.material3.Text( - text = "Close this room?", - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.titleLarge, - ) - androidx.tv.material3.Text( - text = "Closing ends Watch Together for everyone in the room.", - color = Color.White.copy(alpha = 0.80f), - style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - ) - TvDialogActionRow( - title = "Close room for everyone", - onClick = onClose, - modifier = Modifier - .width(360.dp) - .focusRequester(closeActionFocus), - ) - TvDialogActionRow( - title = "Keep watching", - onClick = onCancel, - modifier = Modifier.width(360.dp), - ) - } - } - } -} - private fun PlayerTrackEntry.toVideoTrackEntry(): VideoPlayerTrackEntry = VideoPlayerTrackEntry( index = index, @@ -3566,7 +3620,7 @@ internal fun selectVideoQuality(player: Player, id: String): Boolean { /** * The overlay layer stacked above the player surface: lifecycle notice, remote - * message toast, Watch Together indicator and close confirmation, the Up Next + * message toast, Watch Party indicator, the Up Next * surface, the intro auto-skip banner, and the reconnect spinner. * * Split out of [TvPlayerScreen] to keep that composable's generated method @@ -3585,7 +3639,6 @@ private fun TvPlayerOverlays( roomActive: Boolean, showControls: Boolean, hudOpen: Boolean, - showLeaveDialog: Boolean, showNextUp: Boolean, nextEpisode: NextEpisodeState?, nextUpVideoEnded: Boolean, @@ -3605,14 +3658,14 @@ private fun TvPlayerOverlays( isBuffering: Boolean, sleepTimerState: SleepTimerState, showSpinner: Boolean, - onCloseRoom: () -> Unit, - onCancelLeaveDialog: () -> Unit, onPlayNextNow: () -> Unit, onKeepWatching: () -> Unit, onToggleAutoPlayNext: () -> Unit, onExitPlayback: () -> Unit, onNextUpVideoBoundsChanged: (Rect) -> Unit, onIntroPromptSelect: () -> Unit, + /** Watch Party: the lower rung offered after repeated stalls, or null. */ + qualityOfferLabel: String? = null, ) { // Lifecycle-driven notice toast (top-start). Slides in for outage // recovery, fades out when the lifecycle clears the notice. @@ -3670,10 +3723,10 @@ private fun TvPlayerOverlays( } } - // Watch Together room indicator (top-end so it doesn't collide with - // the top-start lifecycle notice). Member count, a "Waiting for - // members…" pill while the room is on the wait barrier, and the join - // code for the host. Only shown while the idle overlay is up. + // Watch Party indicator (top-end so it doesn't collide with the + // top-start lifecycle notice): member count and, for the host, the + // party code. Only shown while the idle overlay is up. Waiting and + // catching-up status is the party status overlay's job. val snapshot = roomSnapshot if (!isInPictureInPictureMode && roomActive && snapshot != null && showControls && !hudOpen) { Box( @@ -3684,7 +3737,6 @@ private fun TvPlayerOverlays( ) { TvRoomIndicator( memberCount = snapshot.memberCount, - waiting = snapshot.playbackState == RoomPlaybackState.Waiting, joinCode = snapshot.code.takeIf { snapshot.selfCanManageRoom && it.isNotBlank() }, ) } @@ -3782,15 +3834,6 @@ private fun TvPlayerOverlays( } } - // Host close-confirm dialog. Closing tears the room down for everyone - // (server emits room_closed → every member exits). Cancel resumes. - if (!isInPictureInPictureMode && showLeaveDialog && roomActive) { - TvRoomCloseConfirmDialog( - onClose = onCloseRoom, - onCancel = onCancelLeaveDialog, - ) - } - // F2 / Up-Next end-of-playback surface. Replaces the old "Still // watching?" dialog: a 16:9 mini-player (the still-playing video, // visible behind a bordered frame) beside a next-episode panel with @@ -3847,6 +3890,30 @@ private fun TvPlayerOverlays( } } + // Watch Party quality offer (bottom-start, clear of the intro pill). + // OK takes it and Back dismisses it, both through the remote bridge. + if (!isInPictureInPictureMode && qualityOfferLabel != null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 56.dp, start = 32.dp), + contentAlignment = Alignment.BottomStart, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color.Black.copy(alpha = 0.72f)) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + text = "Keeps buffering? Press OK to switch to $qualityOfferLabel.", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + // Outage spinner. Native ExoPlayer buffering surfaces as the top-right // Buffering capsule (mirroring tvOS), so this centered spinner is // reserved for the lifecycle Reconnecting state: the server-outage diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 5e7bc619d6..6256a1042a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -133,6 +133,8 @@ import org.siloserver.silo.repository.SubtitlesRepository import org.siloserver.silo.repository.port.PlaybackWriteScope import org.siloserver.silo.repository.port.TrackSelectionFingerprintUpdate import org.siloserver.silo.tv.ui.screens.detail.TvDetailTrackSelectionSession +import org.siloserver.silo.watchtogether.RoomPlayerObservation +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext import org.siloserver.silo.watchtogether.shouldNavigateToLocalNext import kotlin.math.ceil import kotlinx.coroutines.CancellationException @@ -789,6 +791,11 @@ class TvPlayerViewModel( // Auto-play countdown shown on the Up-Next overlay before the next // episode starts (mirrors tvOS CountdownRing default). const val NEXT_UP_COUNTDOWN_SECONDS = 10 + // A room player sample older than this cannot vouch for buffered media. + private const val ROOM_SAMPLE_MAX_AGE_MS = 1_000L + // How long a coordinated source fallback may take before the original + // refusal is shown after all. + private const val ROOM_FALLBACK_WAIT_MS = 15_000L } // Up-Next auto-play countdown ticker. Cancelled on dismiss / Play Now / @@ -839,7 +846,8 @@ class TvPlayerViewModel( // Wire values match // [PlaybackQuality]: "auto"/"original"/"2160p"/"1080p"/"720p"/"480p". private var qualityOverride: String? = null - private val roomId: String? = launchArgs.roomId + // Blank means solo, exactly as the screen treats it. + private val roomId: String? = launchArgs.roomId?.takeIf { it.isNotBlank() } private val resumePositionOverride: Double? = launchArgs.resumePositionOverride // The handoff belongs to one cross-screen transition. A recoverable start // leases it until Ready publication; replacement/exit invalidates it. @@ -963,6 +971,47 @@ class TvPlayerViewModel( private var playerWindowIsSeekable = false private var playerWindowEndPlayerMs = -1L + // ---- Watch Party ---------------------------------------------------------- + // Declared ahead of init: the room observation collector and the sleep + // timer callback installed there touch these. + + /** The room playback epoch this player is prepared for; every room restart reuses it. */ + private var roomContext: WatchPartyPlaybackContext? = null + + /** The selection revision of the published session, or -1 before one. */ + private var roomPreparedRevision = -1L + + /** The room's last applied play state (commands only; never user intent). */ + private var roomPlaying = false + + /** Local reasons playback is held without asking the room. Main thread only. */ + private val roomHolds = mutableSetOf() + private var roomLoadJob: Job? = null + + private data class TvRoomPlayerSample( + val positionMs: Long, + val bufferedPositionMs: Long, + val playWhenReady: Boolean, + val isPlaying: Boolean, + val playbackState: Int, + val suppressed: Boolean, + val sampledAtMs: Long, + ) + + private var roomPlayerSample: TvRoomPlayerSample? = null + private val _roomObservation = MutableStateFlow(RoomPlayerObservation()) + + /** What the player is actually doing, for the room binding. */ + val roomObservation: StateFlow = _roomObservation.asStateFlow() + private val _roomCorrectionRate = MutableStateFlow(null) + + /** The room's temporary drift-correction rate; null is exactly 1x. Never saved or shown. */ + val roomCorrectionRate: StateFlow = _roomCorrectionRate.asStateFlow() + private val _roomRefusal = MutableStateFlow(null) + + /** A server refusal the room must decide on before this player shows an error. */ + val roomRefusal: StateFlow = _roomRefusal.asStateFlow() + data class UiState( val isLoading: Boolean = true, val error: String? = null, @@ -1612,9 +1661,14 @@ class TvPlayerViewModel( } // When the sleep timer fires, flip user intent to paused. The screen - // mirrors `isPaused` to `mediaController.playWhenReady`. + // mirrors `isPaused` to `mediaController.playWhenReady`. In a Watch + // Party it is a local hold: the room keeps playing. sleepTimer.configure { - _uiState.update { it.copy(isPaused = true) } + if (roomId != null) { + holdRoomPlayback(TvRoomHold.SleepTimer) + } else { + _uiState.update { it.copy(isPaused = true) } + } } // Reduce the analytics listener's event stream into the HUD's Stats @@ -1626,7 +1680,16 @@ class TvPlayerViewModel( } } - if (contentId.isNotBlank()) loadContent(startPositionOverride = resumePositionOverride) + if (roomId != null) { + // Loading, session, error and duration changes all move the room + // observation; player samples arrive through onRoomPlayerSample. + viewModelScope.launch { _uiState.collect { publishRoomObservation() } } + } else if (contentId.isNotBlank()) { + // A Watch Party waits for the room's playback context + // (loadRoomPlayback): the route's own content and file must never + // start solo playback first. + loadContent(startPositionOverride = resumePositionOverride) + } } fun onBackendCapabilities(capabilities: VideoBackendCapabilities) { @@ -1761,6 +1824,8 @@ class TvPlayerViewModel( ?: before.selectedFileId ?: before.mediaFileId ?: return TvSubtitleAdoptionResult.Superseded + // A Watch Party plays exactly the room's file; never adopt another. + if (roomContext?.fileId?.let { it != fileId } == true) return TvSubtitleAdoptionResult.Superseded val version = before.fileVersions.firstOrNull { it.fileId == fileId } val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( @@ -1918,6 +1983,7 @@ class TvPlayerViewModel( // source/player time may no longer be identical. executeSeekTarget(targetSeconds) } + publishRoomObservation() } } @@ -1962,6 +2028,17 @@ class TvPlayerViewModel( // visible until the replacement has won ownership and is ready. preserveCurrentPlaybackOnFailure: Boolean = false, ) { + // A Watch Party only ever starts the room's exact file and position. + // Retry, the reachability retries and the 404 renewal all come through + // here and reuse the room context; before the room has supplied one + // there is nothing to start. + val requestRoom = tvRoomStartContext( + context = roomContext, + startPositionOverride = startPositionOverride, + currentSourcePositionSeconds = _uiState.value.position, + preparedRevision = roomPreparedRevision, + ) + if (roomId != null && requestRoom == null) return // Capture this pipeline's generation; a later loadContent bump makes // this one inert before it can touch _uiState. val generation = ++contentLoadGeneration @@ -2025,11 +2102,11 @@ class TvPlayerViewModel( ) val episodeSelectionHandoff = episodeSelectionHandoffLease?.handoff val request = VideoPlaybackStartRequest( - libraryId = launchArgs.libraryId, + libraryId = requestRoom?.libraryId ?: launchArgs.libraryId, contentId = contentId, - preferredFileId = preferredFileIdOverride ?: preferredFileId, - roomId = roomId, - resumePositionOverride = startPositionOverride, + preferredFileId = requestRoom?.fileId ?: preferredFileIdOverride ?: preferredFileId, + roomId = requestRoom?.roomId ?: roomId, + resumePositionOverride = requestRoom?.positionSeconds ?: startPositionOverride, audioTrackIndex = if (recoveryStartParams != null) { recoveryStartParams.audioTrackIndex } else if (audioTrackIndexOverrideSpecified) { @@ -2049,6 +2126,7 @@ class TvPlayerViewModel( force = force, episodeSelectionHandoff = episodeSelectionHandoff, recoveryStartParams = recoveryStartParams, + room = requestRoom, ) val result = loadOwners.withOwner(loadOwner) { videoPlaybackCoordinator.start(request) @@ -2224,6 +2302,12 @@ class TvPlayerViewModel( // invariant that lets exitSessionId read it // first. result.sessionId?.let { lastAdoptedSessionId = it } + if (requestRoom != null) { + // A room session starts paused; only the + // room's command after attach plays it. + roomPreparedRevision = requestRoom.selectionRevision + roomPlaying = false + } _uiState.update { it.copy( isLoading = false, @@ -2261,7 +2345,7 @@ class TvPlayerViewModel( position = result.sourceStartPositionSeconds, duration = result.durationSeconds ?: 0.0, serverDuration = result.durationSeconds ?: 0.0, - isPaused = false, + isPaused = tvPausedWhenReady(inWatchParty = requestRoom != null), subtitleUrls = hydratedSubtitleUrls, preferredAudioLanguage = result.preferredAudioLanguage, preferredTextLanguage = result.preferredTextLanguage, @@ -2364,7 +2448,18 @@ class TvPlayerViewModel( episodeSelectionHandoffSlot.retainForRetry( episodeSelectionHandoffLease, ) - if (preserveCurrentPlaybackOnFailure) { + val refusalReason = result.terminalReason?.takeIf { it.isNotBlank() } + if (requestRoom != null && refusalReason != null) { + // The room decides whether everyone moves to + // another source; the error shows only if not. + _uiState.update { it.copy(isLoading = true, error = null) } + _roomRefusal.value = TvRoomRefusal( + context = requestRoom, + fileId = requestRoom.fileId, + reason = refusalReason, + message = result.message, + ) + } else if (preserveCurrentPlaybackOnFailure) { _uiState.update { failTvReplacementLoad(it, result.message) } } else { fail(result.message) @@ -2663,6 +2758,23 @@ class TvPlayerViewModel( ?: effectiveVersion?.container ?: state.container.takeIf { effectiveFileId == fileId } val effectiveDuration = decision.session.durationSeconds ?: 0.0 + val roomFileId = roomContext?.fileId + if (roomFileId != null && effectiveFileId != roomFileId) { + // A Watch Party plays exactly the room's file. + // The server keeps that pin through every replan, + // so this is a guard, not a fallback path. + withContext(NonCancellable) { + runCatching { + playbackSessionManager.stopSession(decision.session.sessionId) + } + } + onReplanRequestFailed( + classification, + notice, + "The server offered a different version than the Watch Party's.", + ) + return@launch + } var adopted = false try { adopted = sessionLifecycle.adoptActiveSessionIfCurrent( @@ -2773,10 +2885,13 @@ class TvPlayerViewModel( return@launch } lastAdoptedSessionId = null + val refusalContext = roomContext _uiState.update { it.copy( - error = terminalMessage, - isLoading = false, + // In a Watch Party the room decides first + // whether everyone moves to another source. + error = terminalMessage.takeIf { refusalContext == null }, + isLoading = refusalContext != null, isBuffering = false, isPlaying = false, isPaused = true, @@ -2787,6 +2902,14 @@ class TvPlayerViewModel( streamUrl = null, ) } + if (refusalContext != null) { + _roomRefusal.value = TvRoomRefusal( + context = refusalContext, + fileId = fileId, + reason = decision.reason, + message = terminalMessage, + ) + } } VideoSessionStartV3.ServerUpgradeRequired -> { cancelPendingCatalogSubtitle() @@ -3366,27 +3489,29 @@ class TvPlayerViewModel( /** Toggle user-intent pause state. Screen mirrors this to player.play/pause. */ fun onPlayPause() { + // In a Watch Party every toggle goes through the room; a local flip + // here would fork this viewer from everyone else. + if (roomId != null) return _uiState.update { it.copy(isPaused = !it.isPaused) } } /** - * Idempotent pause setter for Watch Together sync-applied commands. Unlike - * [onPlayPause] (a toggle), this sets the absolute desired state, so a - * duplicate room command can't flip the player the wrong way. The screen's - * `state.isPaused` mirror drives `mediaController.playWhenReady`. + * Idempotent pause setter. Unlike [onPlayPause] (a toggle), this sets the + * absolute desired state, so a duplicate command can't flip the player the + * wrong way. The screen's `state.isPaused` mirror drives + * `mediaController.playWhenReady`. */ fun setPaused(paused: Boolean) { _uiState.update { if (it.isPaused == paused) it else it.copy(isPaused = paused) } } /** - * Deadband-free seek for Watch Together corrective seeks - * ([TvRoomSyncController.applyDecision]). Updates `uiState.position` AND - * emits on [seekRequests], which the screen collects and applies to the - * MediaController unconditionally (TV has no position-mirror deadband, so - * `seekRequests` already reaches the player on every emission — sub-second - * sync corrections are never swallowed). Named to mirror the mobile - * `PlayerViewModel.seekImmediate` contract. + * Deadband-free seek, also used for Watch Party room seeks + * ([applyRoomSeek]). Updates `uiState.position` AND emits on + * [seekRequests] (or re-anchors), which the screen applies to the + * MediaController unconditionally, so sub-second corrections are never + * swallowed. Named to mirror the mobile `PlayerViewModel.seekImmediate` + * contract. */ fun seekImmediate(positionSec: Double) { cancelPendingQuickSkip() @@ -3902,9 +4027,251 @@ class TvPlayerViewModel( _uiState.update { it.copy(isBuffering = false, error = message) } } + // ---- Watch Party -------------------------------------------------------------- + + /** + * Prepare the room's playback epoch: its exact content, file and position, + * paused. A new selection revision or file cancels the old epoch; the same + * epoch again is a no-op. Only the room's commands play it. + */ + fun loadRoomPlayback(context: WatchPartyPlaybackContext) { + if (roomId == null || context.roomId != roomId) return + val previous = roomContext + if (previous != null && + previous.selectionRevision == context.selectionRevision && + previous.fileId == context.fileId + ) { + return + } + roomContext = context + _roomRefusal.value = null + // The old epoch is over: hold its picture still until the new one is ready. + roomPlaying = false + setPaused(true) + val contentChanged = context.contentId != contentId + val outgoingSessionId = exitSessionId.takeIf { previous != null && contentChanged } + roomLoadJob?.cancel() + roomLoadJob = viewModelScope.launch { + if (outgoingSessionId != null) { + // Another title: finish the outgoing one (final position, + // committed tracks, server stop) the way an episode change does. + finishCurrentEpisodeForNextUp(outgoingSessionId) + lastAdoptedSessionId = null + } + if (roomContext !== context) return@launch + if (contentChanged) resetForRoomContent(context.contentId) + preferredFileId = context.fileId + loadContent( + startPositionOverride = context.positionSeconds, + preferredFileIdOverride = context.fileId, + suppressResumeRewind = true, + ) + } + publishRoomObservation() + } + + private fun resetForRoomContent(newContentId: String) { + contentId = newContentId + preferredQuality = null + qualityOverride = null + initialAudioTrackIndex = null + pendingInitialSubtitleIndex = null + pendingInitialSubtitleAutoResolved = false + pendingInitialSubtitleAttempts = 0 + autoTextSubtitleSelectionAttempted = false + autoAdvanceHandled = false + pendingApproachingEndVideoEnded = null + episodeSelectionHandoffSlot.invalidate() + _uiState.update { it.copy(contentId = newContentId, nextEpisode = null) } + } + + /** A room seek. Maps onto the mounted stream or re-anchors; never user intent. */ + fun applyRoomSeek(sourceSeconds: Double) { + if (roomId == null || !sourceSeconds.isFinite()) return + seekImmediate(sourceSeconds.coerceAtLeast(0.0)) + publishRoomObservation() + } + + /** The room's play state. A local hold keeps this viewer paused until it clears. */ + fun applyRoomPlaying(playing: Boolean) { + if (roomId == null) return + roomPlaying = playing + setPaused(!playing || roomHolds.isNotEmpty()) + } + + /** The room's temporary correction rate, or null for exactly 1x. */ + fun setRoomCorrectionRate(rate: Double?) { + if (roomId == null) return + _roomCorrectionRate.value = rate?.takeIf { it.isFinite() && it > 0.0 } + } + + /** + * Whether [sourceSeconds] is reachable without new media: a plain native + * seek of the mounted item, between a second behind the playhead and the + * buffered position. Anything uncertain answers false. + */ + fun isRoomPositionBuffered(sourceSeconds: Double): Boolean { + if (roomId == null || !sourceSeconds.isFinite() || sourceSeconds < 0.0) return false + val sample = roomPlayerSample ?: return false + if (SystemClock.elapsedRealtime() - sample.sampledAtMs > ROOM_SAMPLE_MAX_AGE_MS) return false + if (sample.playbackState != androidx.media3.common.Player.STATE_READY && + sample.playbackState != androidx.media3.common.Player.STATE_BUFFERING + ) { + return false + } + val state = _uiState.value + if (state.error != null || roomSeekPending(state)) return false + val timeline = state.playbackPlan?.timeline + val targetPlayerSeconds = if (timeline != null) { + val decision = timeline.decideSeek(sourceSeconds, mountedSeekableSourceRange(state)) + (decision as? PlaybackSeekDecision.NativeSeek)?.targetPlayerPositionSeconds ?: return false + } else { + sourceSeconds + } + return tvRoomPositionBuffered( + targetPlayerSeconds = targetPlayerSeconds, + currentPlayerSeconds = sample.positionMs.coerceAtLeast(0L) / 1000.0, + bufferedPlayerSeconds = sample.bufferedPositionMs.coerceAtLeast(0L) / 1000.0, + ) + } + + /** What Media3 reports right now. Drives [roomObservation]. */ + fun onRoomPlayerSample( + positionMs: Long, + bufferedPositionMs: Long, + playWhenReady: Boolean, + isPlaying: Boolean, + playbackState: Int, + suppressed: Boolean, + ) { + if (roomId == null) return + roomPlayerSample = TvRoomPlayerSample( + positionMs = positionMs, + bufferedPositionMs = bufferedPositionMs, + playWhenReady = playWhenReady, + isPlaying = isPlaying, + playbackState = playbackState, + suppressed = suppressed, + sampledAtMs = SystemClock.elapsedRealtime(), + ) + publishRoomObservation() + } + + /** A mounted player position in source time, or null while a new mount takes over. */ + fun roomSourcePositionForPlayer(playerPositionMs: Long): Double? { + if (transportMountGate.suppressPositionReports) return null + val seconds = playerPositionMs.coerceAtLeast(0L) / 1000.0 + return _uiState.value.playbackPlan?.timeline?.sourcePositionForPlayer(seconds) ?: seconds + } + + /** Playback is held locally for at least one reason. */ + val isRoomHeld: Boolean get() = roomHolds.isNotEmpty() + + /** Hold playback locally (background, audio focus, sleep timer). The room is not asked. */ + fun holdRoomPlayback(hold: TvRoomHold) { + if (roomId == null) return + roomHolds.add(hold) + setPaused(true) + publishRoomObservation() + } + + /** Clear one hold; with none left, follow the room again. */ + fun releaseRoomPlayback(hold: TvRoomHold) { + if (roomId == null || !roomHolds.remove(hold)) return + if (roomHolds.isEmpty()) setPaused(!roomPlaying) + publishRoomObservation() + } + + /** The viewer pressed play: the holds they can clear themselves go away. */ + fun releaseUserRoomHolds() { + if (roomId == null) return + val focus = roomHolds.remove(TvRoomHold.AudioFocus) + val sleep = roomHolds.remove(TvRoomHold.SleepTimer) + if (!focus && !sleep) return + if (roomHolds.isEmpty()) setPaused(!roomPlaying) + publishRoomObservation() + } + + /** Play pressed while held and the room is playing: resume here, ask nothing. */ + fun resumeRoomPlaybackLocally() { + if (roomId == null) return + roomHolds.remove(TvRoomHold.AudioFocus) + roomHolds.remove(TvRoomHold.SleepTimer) + roomPlaying = true + if (roomHolds.isEmpty()) setPaused(false) + publishRoomObservation() + } + + /** + * The room answered a refusal. When it moved everyone to another source a + * new context follows; otherwise the refusal becomes this player's error. + * No other file is ever chosen locally. + */ + fun onRoomRefusalHandled(refusal: TvRoomRefusal, tookOver: Boolean) { + if (_roomRefusal.value != refusal) return + _roomRefusal.value = null + if (tookOver) { + viewModelScope.launch { + delay(ROOM_FALLBACK_WAIT_MS) + if (roomContext?.selectionRevision == refusal.context.selectionRevision && + _uiState.value.streamUrl == null && _uiState.value.error == null + ) { + fail(refusal.message) + } + } + return + } + if (roomContext?.selectionRevision != refusal.context.selectionRevision) return + fail(refusal.message) + } + + /** A short in-player message (Watch Party notices share the remote-message toast). */ + fun showPlayerMessage(text: String) { + if (text.isBlank()) return + _remoteMessage.value = RemoteMessage(++remoteMessageCounter, text) + } + + private fun roomSeekPending(state: UiState): Boolean = + transportMountGate.suppressPositionReports || + state.isLoading || + state.streamUrl == null || + activeSeekTargetSec != null || + quickSkipAccumulator.pending != null || + pendingNativeSeekAfterMount != null || + seekRecoveryQueue.hasInFlight || + recoveryJob?.isActive == true + + private fun publishRoomObservation() { + if (roomId == null) return + val state = _uiState.value + val sample = roomPlayerSample + val hasMedia = state.streamUrl != null && state.error == null + // Engine truth mapped to source time. While a new mount takes over, the + // old item's position cannot be mapped through the new timeline, so + // the last known value stands (and seekPending says it is not a decision). + val position = if (sample != null && hasMedia && !transportMountGate.suppressPositionReports) { + val raw = sample.positionMs.coerceAtLeast(0L) / 1000.0 + val mapped = state.playbackPlan?.timeline?.sourcePositionForPlayer(raw) ?: raw + state.serverDuration.takeIf { it > 0.0 }?.let(mapped::coerceAtMost) ?: mapped + } else { + _roomObservation.value.sourcePositionSeconds + } + _roomObservation.value = RoomPlayerObservation( + sessionId = state.sessionId?.takeIf { hasMedia }, + selectionRevision = roomPreparedRevision, + sourcePositionSeconds = position, + durationSeconds = state.duration, + playWhenReady = sample?.playWhenReady ?: false, + isPlaying = sample?.isPlaying ?: false, + state = tvRoomPlayerState(sample?.playbackState, hasMedia), + seekPending = roomSeekPending(state), + suspended = roomHolds.isNotEmpty() || sample?.suppressed == true, + ) + } + // ---- Remote-control adapters (TvPlaybackRealtimeController calls these) ---- - /** True while in a Watch Together room — remote transport is gated (the room is authoritative). */ - val remoteTransportSuppressed: Boolean get() = roomId != null + /** True while this player belongs to a Watch Party: the room owns transport. */ + val inWatchParty: Boolean get() = roomId != null fun remotePause() = setPaused(true) fun remoteUnpause() = setPaused(false) @@ -3992,6 +4359,8 @@ class TvPlayerViewModel( * immediate next via [nextEpisodeAfter]. */ private fun resolveNextEpisode() { + // The room owns what plays next; nothing here may offer it. + if (roomId != null) return val state = _uiState.value val seriesId = state.seriesId ?: return val curSeason = state.seasonNumber ?: return @@ -4114,6 +4483,8 @@ class TvPlayerViewModel( * does nothing when pressed. */ fun canShowNextUpNow(): Boolean { + // The room owns what plays next; Play Now could never advance. + if (roomId != null) return false val state = _uiState.value return state.nextEpisode != null && !state.showNextUp } @@ -4131,6 +4502,7 @@ class TvPlayerViewModel( * mid-decision is the opposite of what the press asked for. */ fun onUserRequestedNextUp() { + if (roomId != null) return val next = _uiState.value.nextEpisode ?: return if (_uiState.value.showNextUp) return nextUpCountdownJob?.cancel() @@ -4865,14 +5237,15 @@ class TvPlayerViewModel( * current position so the server transcodes to the chosen rung (or returns to * Auto/Original). [wireValue] is a [PlaybackQuality] wire value. */ - fun switchQuality(wireValue: String) { + fun switchQuality(wireValue: String): Boolean { val current = qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue - if (wireValue == current) return + if (wireValue == current) return false val state = _uiState.value playbackMutationFence.beginReplan() launchSubtitleTransaction(state) { subtitleTransactions.selectQuality(wireValue) } + return true } /** @@ -5356,6 +5729,9 @@ class TvPlayerViewModel( // ---- Settings setters (forward to per-profile DataStore) ------------------- fun onSetPlaybackSpeed(value: Double) { + // A Watch Party plays at 1x; its correction rate is never a user + // speed, and the saved preference stays untouched for solo playback. + if (roomId != null) return viewModelScope.launch { playerSettingsStore.setPlaybackSpeed(value) } } @@ -5383,6 +5759,9 @@ class TvPlayerViewModel( fun onSetDolbyVisionEnabled(value: Boolean) { viewModelScope.launch { playerSettingsStore.setDolbyVisionEnabled(value) + // A Watch Party never restarts its session for a local setting; + // the choice applies from the next start. + if (roomId != null) return@launch val state = _uiState.value if (state.streamUrl == null) return@launch val fileId = state.selectedFileId ?: state.mediaFileId @@ -5788,6 +6167,8 @@ class TvPlayerViewModel( * session on the chosen server file version at the current position. */ fun onSelectFileVersion(fileId: Int) { + // A Watch Party plays exactly the room's file. + if (roomId != null) return val state = _uiState.value // Validate BEFORE mutating. A no-op or unknown id used to fall through // after the audio intent had already been dropped, silently losing the @@ -5809,6 +6190,8 @@ class TvPlayerViewModel( * chain. Raw ordinals never cross a file boundary. */ private fun restartSessionInPlace(fileId: Int?) { + // Version and Dolby Vision restarts would choose playback locally. + if (roomId != null) return val state = _uiState.value val currentFileId = state.selectedFileId ?: state.mediaFileId val sourceVersion = state.fileVersions.firstOrNull { it.fileId == currentFileId } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomSyncController.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomSyncController.kt deleted file mode 100644 index b48e2ede83..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomSyncController.kt +++ /dev/null @@ -1,474 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.player - -import android.os.SystemClock -import org.siloserver.silo.RoomSyncEngine -import org.siloserver.silo.SyncDecision -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomPlaybackState -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.TransportAction -import org.siloserver.silo.repository.PongSample -import org.siloserver.silo.repository.ScheduledTransportCommand -import org.siloserver.silo.repository.WatchTogetherRepository -import org.siloserver.silo.watchtogether.RoomSession -import org.siloserver.silo.watchtogether.RoomCommandDispatcher -import org.siloserver.silo.watchtogether.RoomControllerLifetime -import org.siloserver.silo.watchtogether.RoomPendingExecutions -import org.siloserver.silo.watchtogether.externalPlayPauseDecision -import org.siloserver.silo.watchtogether.scheduledCommandStillCurrent -import kotlinx.coroutines.CoroutineStart -import org.siloserver.silo.watchtogether.RoomTransportIntent -import org.siloserver.silo.watchtogether.roomTransportAuthorized -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlin.math.abs -import java.time.Instant - -// ---- Pure transport-authority gate (unit-tested) --------------------------- - -/** - * A local transport intent originating from the TV UI. Named distinctly from - * the shared [TransportAction] (play/pause/seek wire enum) to avoid a clash — - * the UI only distinguishes "is this a play/pause toggle or a seek?" for the - * authority check; the wire action is derived later. - */ -enum class TvTransportIntent { PlayPause, Seek } - -/** Whether a [TvTransportIntent] may be broadcast as a `transport_request`. */ -enum class TransportGate { Send, Blocked } - -/** - * Decide whether the local member may drive [action] right now. Delegates to the - * shared [roomTransportAuthorized] (the single source of truth mirroring the - * server): seek is host-only, play/pause follows `self_can_control_transport`, - * and nothing is allowed outside the Playing phase. - */ -fun tvRoomTransportGate(s: RoomSnapshot?, action: TvTransportIntent): TransportGate { - val intent = when (action) { - TvTransportIntent.PlayPause -> RoomTransportIntent.PlayPause - TvTransportIntent.Seek -> RoomTransportIntent.Seek - } - return if (roomTransportAuthorized(s, intent)) TransportGate.Send else TransportGate.Blocked -} - -// ---- State-report cadence/suppression gate (duplicated from mobile) -------- -// -// Mobile's pure shouldEmitStateReport lives in androidApp, which is NOT a TV -// dependency, so we duplicate the exact logic here (matching the -// notifications/subtitle cross-app duplication convention). Logic must stay in -// lockstep with androidApp's RoomSyncStateReportGate. - -/** - * Emit a `state_report` once per [cadenceMs], EXCEPT inside a +/- - * [suppressWindowMs] window around a pending transport command's local execute - * time — reporting our position right as we are about to seek/play would feed - * the server a stale pre-execute sample and fight the sync barrier. - */ -fun tvShouldEmitStateReport( - nowMs: Long, - lastReportMs: Long, - cadenceMs: Long, - pendingExecuteAtMs: Long?, - suppressWindowMs: Long, -): Boolean { - if (nowMs - lastReportMs < cadenceMs) return false - if (pendingExecuteAtMs != null && abs(nowMs - pendingExecuteAtMs) <= suppressWindowMs) return false - return true -} - -/** - * Binds a Watch Together room to the TV [TvPlayerViewModel] for the lifetime of - * a synced-player screen. Active ONLY when the player route carries a roomId. - * This is the TV mirror of the mobile `RoomSyncController` (M3) in its final - * fixed state. - * - * The controller OWNS a [RoomSyncEngine] (a pure shared class that lives in the - * binding, not the repo). It: - * - launches the repo WS reconnect loop ([WatchTogetherRepository.connect] is - * suspend) and attaches the player's playback session id once it resolves; - * - drives a ping loop (one ping on start + every 15s) so pongs flow, stamps - * `clientReceivedMs` on each pong with the SAME wall clock used for the ping - * `client_sent_at`, and feeds [RoomSyncEngine.recordPongSample]; - * - feeds each [ScheduledTransportCommand] into [RoomSyncEngine.decide] and - * applies the resulting [SyncDecision] (seek / setPlaying) at the engine- - * scheduled local delay, auto-emitting `ready` on the waiting barrier; - * - emits `state_report` on a ~1.5s cadence, suppressed around a pending - * execute (see [tvShouldEmitStateReport]); - * - emits `ready`/`buffering` on buffer transitions while the room is waiting; - * - routes user play/pause/seek through `transport_request`, gated on - * [tvRoomTransportGate]; - * - surfaces `room_closed` ([closedReason], TERMINAL only) so the screen exits. - * Transient server `error` frames do NOT eject the user (they flow on the - * repo's `errors` stream, never feeding [closedReason]). - * - * ## Clock domains (critical) - * - WALL CLOCK ([System.currentTimeMillis]) is used for everything the engine - * sees: the ping `client_sent_at` RFC3339 string, the `clientReceivedMs` - * pong stamp, and `nowLocalMs` passed to [RoomSyncEngine.decide]. The engine - * derives its offset as `server - clientWallClock`, and the server timestamps - * are wall-clock epoch millis, so these MUST share the same clock. - * - MONOTONIC ([SystemClock.elapsedRealtime]) is used only for the local - * state-report cadence/suppression gate + the engine-scheduled execute - * delay's pending marker — pure local timing that must not jump if the wall - * clock is adjusted. - */ -class TvRoomSyncController( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, - private val viewModel: TvPlayerViewModel, - private val scope: CoroutineScope, - private val engine: RoomSyncEngine = RoomSyncEngine(), -) { - companion object { - private const val REPORT_CADENCE_MS = 1_500L - private const val SUPPRESS_WINDOW_MS = 250L - private const val REPORT_TICK_MS = 250L - private const val PING_INTERVAL_MS = 15_000L - } - - /** Live room snapshot for the overlay. */ - val room: StateFlow get() = repository.roomSnapshot - - /** - * Non-null once the server CLOSES the room (host left / explicit close) — the - * screen observes and exits. Transient server `error` frames do NOT set this - * (they would wrongly eject the user); they flow on [WatchTogetherRepository.errors]. - */ - val closedReason: StateFlow get() = repository.roomClosedReason - - // Monotonic-domain state-report cadence bookkeeping. - @Volatile private var lastReportMs = 0L - private val lifetime = RoomControllerLifetime(scope) - private val controllerScope = lifetime.scope - private val deliveryLatch = repository.roomDeliveryLatch - private val commandDispatcher = RoomCommandDispatcher(controllerScope) - private val pendingExecutions = RoomPendingExecutions() - - fun start() { - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> deliveryLatch.keyOrNull(connection, sessionId) } - .distinctUntilChanged() - .collectLatest { key -> - key ?: return@collectLatest - while (isActive && deliveryLatch.needsAttach(key)) { - val delivered = repository.attachSession(key.playbackSessionId) - deliveryLatch.recordAttach(key, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - // Clock-sync: drive pings (once on start + every PING_INTERVAL_MS) and - // fold pongs into the engine's offset estimate. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState.map { it.sessionId }.distinctUntilChanged(), - repository.connectionState, - ) { sessionId, connection -> - deliveryLatch.keyOrNull(connection, sessionId) to connection.writable - } - .distinctUntilChanged() - .collectLatest { (key, writable) -> - if (!writable || key == null) return@collectLatest - while (!deliveryLatch.isAttached(key)) delay(10) - while (isActive) { - repository.ping(clientSentAt = nowWallClockRfc3339()) - delay(PING_INTERVAL_MS) - } - } - } - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.pongs.collect { pong -> recordPong(pong) } - } - - // Apply engine decisions for each scheduled transport command. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - repository.transportCommands.collect { scheduled -> handleCommand(scheduled) } - } - - // Drift reporting loop (suppressed around a pending execute). - controllerScope.launch { - while (isActive) { - val state = viewModel.uiState.value - val sessionId = state.sessionId - val deliveryKey = deliveryLatch.keyOrNull( - repository.connectionState.value, - sessionId, - ) - val now = monotonicMs() - if (deliveryKey != null && - deliveryLatch.isServerAttached( - deliveryKey, - repository.roomDeliveryEcho.value, - ) && - tvShouldEmitStateReport( - now, - lastReportMs, - REPORT_CADENCE_MS, - pendingExecutions.nearestTo(now), - SUPPRESS_WINDOW_MS, - ) - ) { - lastReportMs = now - repository.stateReport( - sessionId = deliveryKey.playbackSessionId, - positionSeconds = state.position, - isPaused = state.isPaused, - ) - } - delay(REPORT_TICK_MS) - } - } - - // ready / buffering during the waiting barrier. - controllerScope.launch(start = CoroutineStart.UNDISPATCHED) { - combine( - viewModel.uiState - .map { it.sessionId to it.isBuffering } - .distinctUntilChanged(), - repository.roomSnapshot - .map { it?.playbackState } - .distinctUntilChanged(), - repository.connectionState, - ) { readiness, playbackState, connection -> Triple(readiness, playbackState, connection) } - .collectLatest { (readiness, playbackState, connection) -> - val (sessionId, buffering) = readiness - val key = deliveryLatch.keyOrNull(connection, sessionId) - if (playbackState != RoomPlaybackState.Waiting || key == null) { - return@collectLatest - } - while ( - !deliveryLatch.isServerAttached( - key, - repository.roomDeliveryEcho.value, - ) - ) { - delay(10) - } - while (isActive && deliveryLatch.needsReadiness(key, buffering)) { - val currentState = viewModel.uiState.value - val delivered = if (buffering) { - repository.buffering( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } else { - repository.ready( - key.playbackSessionId, - currentState.position, - currentState.isPaused, - ) - } - deliveryLatch.recordReadiness(key, buffering, delivered) - if (!delivered) { - delay(500) - } - } - } - } - - roomSession.adopt(roomId) - } - - /** - * Stamp `clientReceivedMs` (wall clock) at collection time and feed the NTP - * sample to the engine. A sample with any missing server timestamp can't - * update the offset, so we drop it. - */ - private fun recordPong(pong: PongSample) { - val clientSent = pong.clientSentMs ?: return - val serverReceived = pong.serverReceivedMs ?: return - val serverSent = pong.serverSentMs ?: return - engine.recordPongSample( - clientSentMs = clientSent, - serverReceivedMs = serverReceived, - serverSentMs = serverSent, - clientReceivedMs = System.currentTimeMillis(), - ) - } - - private fun handleCommand(scheduled: ScheduledTransportCommand) { - if (!scheduled.connection.writable || - scheduled.connection != repository.connectionState.value - ) { - return - } - val command = scheduled.command - val state = viewModel.uiState.value - val snapshot = repository.roomSnapshot.value - // executeAtMs null (malformed wire timestamp) → apply immediately; pass - // nowWallClock so the engine computes a 0 delay. - val nowWall = System.currentTimeMillis() - val serverExecuteAtMs = scheduled.executeAtMs ?: nowWall - val decision = engine.decide( - command = command, - serverExecuteAtMs = serverExecuteAtMs, - currentLocalPositionMs = (state.position * 1000.0).toLong(), - currentIsPlaying = !state.isPaused, - nowLocalMs = nowWall, - currentRevision = snapshot?.selectionRevision ?: command.selectionRevision, - attachedSessionId = snapshot?.attachedSessionId ?: state.sessionId, - ) ?: return // duplicate / revision / session gate → ignore - - commandDispatcher.dispatch { - applyDecision( - decision = decision, - sessionId = state.sessionId, - command = command, - acceptedConnection = scheduled.connection, - ) - } - } - - private suspend fun applyDecision( - decision: SyncDecision, - sessionId: String?, - command: org.siloserver.silo.model.watchtogether.TransportCommand, - acceptedConnection: org.siloserver.silo.repository.WatchTogetherConnectionState, - ) { - val delayMs = decision.localExecuteDelayMs.coerceAtLeast(0L) - // Record the pending execute against the MONOTONIC clock for the - // state-report suppression gate, then wait the engine-computed delay. - val pendingToken = pendingExecutions.record(monotonicMs() + delayMs) - try { - if (delayMs > 0) delay(delayMs) - if ( - !scheduledCommandStillCurrent( - command = command, - acceptedPlaybackSessionId = sessionId, - acceptedRoomId = roomId, - acceptedConnection = acceptedConnection, - currentPlaybackSessionId = viewModel.uiState.value.sessionId, - currentRoom = repository.roomSnapshot.value, - currentConnection = repository.connectionState.value, - ) - ) { - return - } - - // Use the deadband-free immediate-seek path: room corrective seeks can - // be as small as the engine's 0.35s DRIFT_THRESHOLD_MS, and a normal - // seekRequest would be fine on TV (no position-mirror deadband), but we - // route through seekImmediate to match mobile's contract exactly and - // guarantee the MediaController moves regardless of any future deadband. - decision.seekToMs?.let { ms -> viewModel.seekImmediate(ms / 1000.0) } - // Idempotent pause: set the desired state directly (NOT a toggle) so a - // duplicate command can't flip us the wrong way. - viewModel.setPaused(!decision.setPlaying) - } finally { - pendingExecutions.finish(pendingToken) - } - - // The epoch/session-keyed readiness collector owns ready delivery and - // retries. Do not advance an untracked one-shot latch here. - } - - // ---- User-initiated transport: route through the room instead of local apply ---- - - /** - * Route a local play/pause through the room. Gated: a guest under - * host_only is a no-op; the screen also disables the affordance, this is - * the defensive backstop. - */ - fun onUserPlayPause() { - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (tvRoomTransportGate(snapshot, TvTransportIntent.PlayPause) != TransportGate.Send) { - return - } - val state = viewModel.uiState.value - val willPause = !state.isPaused - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (willPause) TransportAction.Pause.wire else TransportAction.Play.wire, - positionSeconds = state.position, - isPaused = willPause, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - /** Route a local seek through the room. Guests never seek (gated). */ - fun onUserSeek(positionSeconds: Double) { - val authorization = repository.currentTransportAuthorization() ?: return - val snapshot = authorization.snapshot - if (tvRoomTransportGate(snapshot, TvTransportIntent.Seek) != TransportGate.Send) { - return - } - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.Seek, - action = TransportAction.Seek.wire, - positionSeconds = positionSeconds, - isPaused = state.isPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - - /** Reconcile a deliberate external MediaSession play/pause through room authority. */ - fun onExternalPlayWhenReadyChanged(localPlayWhenReady: Boolean): Boolean? { - val authorization = repository.currentTransportAuthorization() - val snapshot = authorization?.snapshot - val decision = externalPlayPauseDecision(snapshot, localPlayWhenReady) ?: return null - if (authorization == null) return decision.restorePlayWhenReady - decision.requestIsPaused?.let { requestIsPaused -> - val state = viewModel.uiState.value - controllerScope.launch { - val delivered = repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = if (requestIsPaused) { - TransportAction.Pause.wire - } else { - TransportAction.Play.wire - }, - positionSeconds = state.position, - isPaused = requestIsPaused, - ) - if (!delivered) repository.reportDeliveryFailure("room_transport_unavailable") - } - } - return decision.restorePlayWhenReady - } - - /** Leave the room. Host close tears the room down for everyone; both reset local state. */ - fun leave(closeRoom: Boolean) { - engine.reset() - roomSession.depart(closeRoom) - } - - /** Cancel this screen binding without ending the process-owned room lease. */ - fun dispose() { - engine.reset() - lifetime.dispose() - } - - /** Wall-clock RFC3339 string for the ping `client_sent_at`. */ - private fun nowWallClockRfc3339(): String = Instant.ofEpochMilli(System.currentTimeMillis()).toString() - - /** Monotonic clock for local-only cadence/scheduling. */ - private fun monotonicMs(): Long = SystemClock.elapsedRealtime() -} - -/** Convenience: is the local member the room host? */ -val RoomSnapshot.isHost: Boolean get() = selfRole == MemberRole.Host diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt index 515f0eff9c..b01247ac19 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -106,8 +106,24 @@ class TvVideoPlaybackStarter( val preferredQuality = request.preferredQualityOverride ?: playerSettingsStore.preferredQualityFlow.first() val playbackQualityIntent = request.playbackQualityIntent ?: preferredQuality + val room = request.room + if (room != null && watchDetail.versions.none { it.fileId == room.fileId }) { + return failure( + request.contentId, + "The Watch Party's version of this title isn't available to this profile.", + diagnosticsCode = PlaybackDiagnosticsCode.NO_VERSIONS, + ) + } + // Room starts keep the file through every replan; renewals keep + // whatever the original start chose. + val allowAlternateVersions = if (room != null) { + false + } else { + request.recoveryStartParams?.allowAlternateVersions + } val resolvedEpisodeSelection = resolveTvPlaybackStartSelection( - preferredFileId = request.preferredFileId, + // A room plays exactly its file; never a preferred or remembered one. + preferredFileId = room?.fileId ?: request.preferredFileId, episodeSelectionHandoff = request.episodeSelectionHandoff, targetVersions = watchDetail.versions, targetLastFileId = watchDetail.userData?.lastFileId, @@ -184,7 +200,7 @@ class TvVideoPlaybackStarter( // Suppressed for Start Over / retry (request flag) and Watch Together // (roomId); the one rewound value drives both the server seek and the // player start so a transcode cut and the player position never disagree. - val suppressRewind = request.suppressResumeRewind || request.roomId != null || + val suppressRewind = request.suppressResumeRewind || request.roomId != null || room != null || org.siloserver.silo.model.playback.isExplicitStartOver(request.resumePositionOverride) // Per-profile setting (default 7; 0 = off). Read once per start. val rewindSeconds = playerSettingsStore.resumeRewindSecondsFlow.first().toDouble() @@ -195,7 +211,9 @@ class TvVideoPlaybackStarter( rewindSeconds = rewindSeconds, ) } - val startRequestPosition = rewound( + // A room starts at the room position, including an explicit zero; + // personal resume never applies. + val startRequestPosition = room?.positionSeconds ?: rewound( resolvePlaybackStartRequestPosition( overridePosition = request.resumePositionOverride, detailPosition = watchDetail.userData?.positionSeconds, @@ -228,6 +246,7 @@ class TvVideoPlaybackStarter( maxBitrateKbps = maxBitrateKbps, deferPublication = true, expectedMetadataOwner = expectedMetadataOwner, + allowAlternateVersions = allowAlternateVersions, ) ) { is ApiResult.Success -> r.data @@ -249,6 +268,7 @@ class TvVideoPlaybackStarter( request.contentId, serverTerminalUserMessage(v3Start.message), diagnosticsCode = PlaybackDiagnosticsCode.serverTerminal(v3Start.reason), + terminalReason = v3Start.reason, ) VideoSessionStartV3.ServerUpgradeRequired -> return failure( request.contentId, @@ -266,6 +286,15 @@ class TvVideoPlaybackStarter( val effectiveFileId = resolved.mediaFileId.takeIf { it > 0 } ?: readyV3.plan.effectiveMediaFileId ?: version.fileId + if (room != null && effectiveFileId != room.fileId) { + discardUnpublishedSession(unpublishedSessionId, lifecycleAdopted) + unpublishedSessionId = null + return failure( + request.contentId, + "The server offered a different version than the Watch Party's.", + diagnosticsCode = PlaybackDiagnosticsCode.START_REQUEST, + ) + } val effectiveVersion = watchDetail.versions.firstOrNull { it.fileId == effectiveFileId } val resolvedDelivery = resolved.resolvedPlaybackDelivery() val resolvedStreamUrl = resolved.playbackPlan?.stream?.url @@ -311,6 +340,7 @@ class TvVideoPlaybackStarter( qualityPreference = playbackQualityIntent, startPosition = sourceStartPos, clientPlaybackContext = readyV3.clientPlaybackContext, + allowAlternateVersions = allowAlternateVersions, ), session = resolved, deferPublication = true, @@ -427,6 +457,7 @@ class TvVideoPlaybackStarter( message: String, cause: Throwable? = null, diagnosticsCode: PlaybackDiagnosticsCode? = null, + terminalReason: String? = null, ): VideoPlaybackStartResult.Error { // Log the throwable here instead of stashing it on the (unread) result — // the message already carries the human-facing detail. @@ -435,6 +466,7 @@ class TvVideoPlaybackStarter( contentId = contentId, message = message, diagnosticsCode = diagnosticsCode, + terminalReason = terminalReason, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt new file mode 100644 index 0000000000..21941d2cd1 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt @@ -0,0 +1,503 @@ +package org.siloserver.silo.tv.ui.screens.player + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import org.koin.compose.koinInject +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.isActive +import org.siloserver.silo.common.player.watchparty.IssuedSeekTracker +import org.siloserver.silo.common.player.watchparty.WatchPartyPlayback +import org.siloserver.silo.common.player.watchparty.watchPartyNoticeText +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.playback.PlaybackAction +import org.siloserver.silo.watchtogether.RoomPlayerObservation +import org.siloserver.silo.watchtogether.RoomPlayerPort +import org.siloserver.silo.watchtogether.RoomPlayerState +import org.siloserver.silo.watchtogether.RoomTransportIntent +import org.siloserver.silo.watchtogether.RoomTransportResult +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext + +/** Why a Watch Party viewer's playback is held locally, without asking the room. */ +enum class TvRoomHold { + /** The app left the foreground (ON_PAUSE / ON_STOP). */ + Background, + + /** Audio focus was lost or the output became noisy. */ + AudioFocus, + + /** The sleep timer fired. */ + SleepTimer, +} + +/** A room start or replan the server refused, waiting for the room to decide. */ +data class TvRoomRefusal( + val context: WatchPartyPlaybackContext, + val fileId: Int, + val reason: String, + val message: String, +) + +/** The room playback-control port, mapped onto the TV player's ViewModel. */ +internal class TvRoomPlayerPort(private val viewModel: TvPlayerViewModel) : RoomPlayerPort { + override val observations: StateFlow = viewModel.roomObservation + + override fun seekTo(sourceSeconds: Double) = viewModel.applyRoomSeek(sourceSeconds) + + override fun setPlaying(playing: Boolean) = viewModel.applyRoomPlaying(playing) + + override fun setCorrectionRate(rate: Double?) = viewModel.setRoomCorrectionRate(rate) + + override fun isBuffered(sourceSeconds: Double): Boolean = viewModel.isRoomPositionBuffered(sourceSeconds) +} + +/** + * The TV player screen's side of a Watch Party: routes every user, remote, + * SiloCast and MediaSession transport input through [playback], recognizes + * seeks this screen did not issue, and feeds the ViewModel the player samples + * the room observation is built from. + * + * Kept out of the composable so the player screen's generated method stays + * within ART's JIT limit. + */ +internal class TvWatchPartyScreenController( + val playback: WatchPartyPlayback, + private val viewModel: TvPlayerViewModel, + private val nowMs: () -> Long = SystemClock::elapsedRealtime, +) { + private val issuedSeeks = IssuedSeekTracker(nowMs) + private val mainHandler = Handler(Looper.getMainLooper()) + private var departed = false + + fun start() = playback.start() + + /** The screen is going away; the room engagement continues. */ + fun dispose() = playback.dispose() + + fun canSeek(): Boolean = playback.allows(RoomTransportIntent.Seek) + + fun canPlayPause(): Boolean = playback.allows(RoomTransportIntent.PlayPause) + + /** A play/pause toggle from the remote, the transport, SiloCast or an admin. */ + fun togglePlayPause(): Boolean = setPlaying(play = viewModel.uiState.value.isPaused) + + /** + * Ask the room to play or pause. Pressing play while playback is held + * locally clears the hold without asking the room when the room never + * paused: a playing room resumes this viewer, and a waiting room keeps + * its own barrier. Returns true when the input was accepted. + */ + fun setPlaying(play: Boolean): Boolean { + if (play && viewModel.isRoomHeld) { + val room = playback.room.value + when { + room.isPlayingRoom() -> { + viewModel.resumeRoomPlaybackLocally() + return true + } + room?.playbackState == RoomPlaybackState.Waiting -> { + viewModel.releaseUserRoomHolds() + return true + } + else -> viewModel.releaseUserRoomHolds() + } + } + return playback.requestPlayPause(pause = !play) == RoomTransportResult.Sent + } + + /** Ask the room to seek (host only). Returns true when the request was sent. */ + fun seek(sourceSeconds: Double): Boolean = + playback.requestSeek(sourceSeconds) == RoomTransportResult.Sent + + /** Every seek this screen hands to Media3, so its discontinuity is not mistaken for an outside seek. */ + fun recordIssuedSeek(playerPositionMs: Long) = issuedSeeks.note(playerPositionMs) + + /** A Media3 seek discontinuity. Seeks from outside the app become room requests or are undone. */ + fun onSeekDiscontinuity(fromPlayerMs: Long, toPlayerMs: Long) { + if (!issuedSeeks.isExternal(toPlayerMs)) return + val from = viewModel.roomSourcePositionForPlayer(fromPlayerMs) ?: return + val to = viewModel.roomSourcePositionForPlayer(toPlayerMs) ?: return + playback.onExternalSeek(from, to) { restore -> viewModel.applyRoomSeek(restore) } + } + + /** + * A deliberate MediaSession play/pause (notification, headset, Assistant). + * The room decides; returns the local play state the player must go back + * to meanwhile. + */ + fun onExternalPlayWhenReady(playWhenReady: Boolean): Boolean { + setPlaying(play = playWhenReady) + return !viewModel.uiState.value.isPaused + } + + /** + * A transport command from the playback-control socket. Transport follows + * the room's permission decision; Stop and terminate end only this + * device's engagement. Returns whether the command was accepted. + */ + fun onRemoteTransport(action: PlaybackAction, exit: () -> Unit): Boolean = when (action) { + PlaybackAction.Pause -> setPlaying(play = false) + PlaybackAction.Unpause -> setPlaying(play = true) + PlaybackAction.TogglePlayPause -> togglePlayPause() + is PlaybackAction.SeekTo -> seek(action.positionSeconds) + PlaybackAction.Stop -> { + leave() + exit() + true + } + else -> false + } + + /** + * A phone's SiloCast launch would replace this party player. Refuse it + * and say why on the TV too. Called off the main thread. + */ + fun refuseLaunch(): String { + mainHandler.post { viewModel.showPlayerMessage(TV_WATCH_PARTY_LAUNCH_REFUSAL) } + return TV_WATCH_PARTY_LAUNCH_REFUSAL + } + + /** The viewer changed quality: the room's stall count and reload pacing start over. */ + fun onQualityChanged() = playback.binding.onQualityChanged() + + /** Leave this device's membership, once. For a host the room ends two minutes later. */ + fun leave() { + if (departed) return + departed = true + playback.leave() + } + + /** End the party for everyone (host), once. */ + fun endForEveryone() { + if (departed) return + departed = true + playback.endForEveryone() + } + + /** The engagement already ended on the server; leaving would only erase that record. */ + fun markEnded() { + departed = true + } + + /** Publish what the player is actually doing. */ + fun sample(player: Player) { + viewModel.onRoomPlayerSample( + positionMs = player.currentPosition, + bufferedPositionMs = player.bufferedPosition, + playWhenReady = player.playWhenReady, + isPlaying = player.isPlaying, + playbackState = player.playbackState, + suppressed = player.playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE, + ) + } +} + +/** + * The Watch Party binding for one player screen, or null for solo playback. + * Built once per room; started and disposed with the screen. Disposing never + * leaves the room. + */ +@Composable +internal fun rememberTvWatchParty(roomId: String?, viewModel: TvPlayerViewModel): TvWatchPartyScreenController? { + val repository: WatchTogetherRepository = koinInject() + val roomSession: RoomSession = koinInject() + val availability: WatchPartyAvailabilityRepository = koinInject() + val scope = rememberCoroutineScope() + val watchParty = remember(roomId) { + roomId?.takeIf { it.isNotBlank() }?.let { id -> + TvWatchPartyScreenController( + playback = WatchPartyPlayback( + roomId = id, + repository = repository, + roomSession = roomSession, + availability = availability, + player = TvRoomPlayerPort(viewModel), + scope = scope, + ), + viewModel = viewModel, + ) + } + } + DisposableEffect(watchParty) { + watchParty?.start() + onDispose { watchParty?.dispose() } + } + return watchParty +} + +/** + * The speed the player runs at. Solo: the saved preference. A Watch Party + * ignores it: exactly 1x, or the room's temporary correction rate, which is + * never saved or shown. + */ +@Composable +internal fun TvPlaybackSpeedEffect( + player: Player?, + inWatchParty: Boolean, + viewModel: TvPlayerViewModel, + savedSpeed: Double, +) { + val correctionRate by viewModel.roomCorrectionRate.collectAsState() + val speed = if (inWatchParty) correctionRate ?: 1.0 else savedSpeed + // PlaybackParameters because MediaController has no setter that keeps + // pitch correction defaults. + LaunchedEffect(player, speed) { + player?.playbackParameters = PlaybackParameters(speed.toFloat()) + } +} + +/** Whether the viewer dismissed the current lower-quality offer. */ +@Stable +internal class TvWatchPartyQualityOfferState { + var dismissed by mutableStateOf(false) +} + +/** + * After repeated stalls in a room: one step down the same file's quality + * ladder, or null when there is no offer, it was dismissed, or no lower rung + * remains. A new offer after a quality change can show again. + */ +@Composable +internal fun tvWatchPartyLowerQuality( + watchParty: TvWatchPartyScreenController?, + offerState: TvWatchPartyQualityOfferState, + videoQualities: List, + selectedFileResolution: String?, +): VideoQualityOption? { + val offered = watchParty?.playback?.offerLowerQuality?.collectAsState()?.value ?: false + LaunchedEffect(offered) { if (!offered) offerState.dismissed = false } + if (!offered || offerState.dismissed) return null + return tvLowerQualityOption(videoQualities, tvQualityHeight(selectedFileResolution)) +} + +/** Switch the same file's quality; in a room the binding's stall pacing starts over. */ +internal fun switchTvPlaybackQuality( + viewModel: TvPlayerViewModel, + watchParty: TvWatchPartyScreenController?, + id: String, +) { + if (viewModel.switchQuality(id)) watchParty?.onQualityChanged() +} + +/** How often the room observation samples the player. */ +private const val ROOM_SAMPLE_INTERVAL_MS = 200L + +/** A player route whose room never appears (no live membership) gives up after this. */ +private const val ROOM_MISSING_EXIT_MS = 10_000L + +/** + * The Watch Party side effects of one player screen: loads each room epoch, + * samples the player for the room observation, shows notices, reports + * refusals to the room, and leaves the player when the room stops playing + * (back to the lobby, membership kept) or the party ends. + */ +@Composable +internal fun TvWatchPartyEffects( + watchParty: TvWatchPartyScreenController, + roomId: String, + viewModel: TvPlayerViewModel, + player: Player?, + onReturnToLobby: () -> Unit, + onPartyEnded: () -> Unit, + repository: WatchTogetherRepository = koinInject(), +) { + val context = LocalContext.current + val latestReturnToLobby by rememberUpdatedState(onReturnToLobby) + val latestPartyEnded by rememberUpdatedState(onPartyEnded) + + // Each new room epoch (selection revision or file) prepares the exact + // file at the room position, paused. Anchor and membership updates keep + // the current player. + LaunchedEffect(watchParty) { + watchParty.playback.playbackContext.collect { playback -> + if (playback != null) viewModel.loadRoomPlayback(playback) + } + } + + // Host Stop keeps the membership and returns to the lobby; an ended + // party leaves the player. + LaunchedEffect(watchParty) { + watchParty.playback.room.collect { room -> + if (room?.phase == RoomPhase.Lobby) latestReturnToLobby() + } + } + LaunchedEffect(watchParty) { + watchParty.playback.ended.filterNotNull().collect { ended -> + if (ended.roomId != roomId) return@collect + watchParty.markEnded() + // The hub this leads to says why the party ended and offers Rejoin. + latestPartyEnded() + } + } + // A route with no live membership behind it (the room token is memory + // only) would otherwise wait on the loading screen forever. + LaunchedEffect(watchParty) { + watchParty.playback.room.collectLatest { room -> + if (room != null) return@collectLatest + delay(ROOM_MISSING_EXIT_MS) + if (watchParty.playback.ended.value == null) { + Toast.makeText(context, "This Watch Party isn't available anymore.", Toast.LENGTH_SHORT).show() + latestPartyEnded() + } + } + } + + LaunchedEffect(watchParty) { + watchParty.playback.notices.collect { notice -> + viewModel.showPlayerMessage(watchPartyNoticeText(notice)) + } + } + // Transient server rejections (a refused request) never end the party. + LaunchedEffect(watchParty) { + repository.errors.collect { message -> + if (message.isNotBlank()) Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + // A refused start or replan goes to the room first. Only the approved + // reasons move everyone to another source; otherwise it is this + // player's error. No other file is ever chosen here. + LaunchedEffect(watchParty) { + viewModel.roomRefusal.filterNotNull().collect { refusal -> + val tookOver = watchParty.playback.reportRefusal( + context = refusal.context, + failedFileId = refusal.fileId, + reason = refusal.reason, + ) + viewModel.onRoomRefusalHandled(refusal, tookOver) + } + } + + // Engine truth for the room binding, sampled between Media3 callbacks. + LaunchedEffect(watchParty, player) { + val sampled = player ?: return@LaunchedEffect + while (isActive) { + watchParty.sample(sampled) + delay(ROOM_SAMPLE_INTERVAL_MS) + } + } +} + +private fun org.siloserver.silo.model.watchtogether.RoomSnapshot?.isPlayingRoom(): Boolean = + this != null && + phase == RoomPhase.Playing && + !isPaused && + playbackState == RoomPlaybackState.Playing + +internal const val TV_WATCH_PARTY_LAUNCH_REFUSAL = "Leave the Watch Party to play something else." + +/** Whether a freshly published session starts paused: always in a Watch Party, where only the room plays. */ +internal fun tvPausedWhenReady(inWatchParty: Boolean): Boolean = inWatchParty + +/** + * The context a room start sends. The first start of a revision uses the room + * position as sent. A restart of a session already prepared for this + * revision (retry, reachability retry, 404 renewal) resumes where this player + * was: the requested position, else the current one. Positions from an + * earlier revision never carry over, and the file never changes. + */ +internal fun tvRoomStartContext( + context: WatchPartyPlaybackContext?, + startPositionOverride: Double?, + currentSourcePositionSeconds: Double, + preparedRevision: Long, +): WatchPartyPlaybackContext? { + val room = context ?: return null + if (preparedRevision != room.selectionRevision) return room + val position = startPositionOverride?.takeIf { it.isFinite() && it >= 0.0 } + ?: currentSourcePositionSeconds.takeIf { it.isFinite() && it > 0.0 } + ?: room.positionSeconds + return room.copy(positionSeconds = position) +} + +/** + * Whether reaching [targetPlayerSeconds] needs no new media: it must lie + * between a second behind the playhead and the buffered position of the + * mounted item, all in player time. + */ +internal fun tvRoomPositionBuffered( + targetPlayerSeconds: Double, + currentPlayerSeconds: Double, + bufferedPlayerSeconds: Double, + behindToleranceSeconds: Double = 1.0, +): Boolean { + if (!targetPlayerSeconds.isFinite() || !currentPlayerSeconds.isFinite() || !bufferedPlayerSeconds.isFinite()) { + return false + } + if (bufferedPlayerSeconds < currentPlayerSeconds) return false + return targetPlayerSeconds >= currentPlayerSeconds - behindToleranceSeconds && + targetPlayerSeconds <= bufferedPlayerSeconds +} + +/** The room player state for a Media3 playback state. */ +internal fun tvRoomPlayerState(playbackState: Int?, hasMedia: Boolean): RoomPlayerState { + if (!hasMedia) return RoomPlayerState.Idle + return when (playbackState) { + Player.STATE_BUFFERING -> RoomPlayerState.Buffering + Player.STATE_READY -> RoomPlayerState.Ready + Player.STATE_ENDED -> RoomPlayerState.Ended + else -> RoomPlayerState.Idle + } +} + +/** + * The speed a SiloCast `setPlaybackSpeed` may apply, or null to refuse it. + * A Watch Party plays at 1x (plus the room's own correction), so a phone + * cannot change it there. + */ +internal fun tvSiloCastPlaybackSpeed(inWatchParty: Boolean, requested: Double): Double? = + requested.takeIf { !inWatchParty && it.isFinite() && it > 0.0 } + +/** + * One step down the same file's quality ladder: the highest rung below the + * current height. With no known height (Auto or Original), the top rung. + */ +internal fun tvLowerQualityOption( + options: List, + fallbackCurrentHeight: Int?, +): VideoQualityOption? { + val rungs = options + .mapNotNull { option -> tvQualityHeight(option.resolution)?.let { option to it } } + .sortedByDescending { it.second } + if (rungs.isEmpty()) return null + val selected = options.firstOrNull { it.isSelected } + val current = tvQualityHeight(selected?.resolution) ?: fallbackCurrentHeight + return if (current != null) { + rungs.firstOrNull { it.second < current }?.first + } else { + rungs.first().first + } +} + +/** A vertical resolution from labels such as "720p", "1080p" or "4K". */ +internal fun tvQualityHeight(label: String?): Int? { + val text = label?.trim()?.lowercase() ?: return null + Regex("(\\d{3,4})p").find(text)?.groupValues?.get(1)?.toIntOrNull()?.let { return it } + return when { + "8k" in text -> 4320 + "4k" in text || "uhd" in text -> 2160 + else -> null + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt new file mode 100644 index 0000000000..0216f7a3c5 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt @@ -0,0 +1,379 @@ +package org.siloserver.silo.tv.ui.screens.player + +import android.widget.Toast +import androidx.compose.foundation.background +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.runtime.Composable +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.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +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 +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.zIndex +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.siloserver.silo.common.ui.components.rememberProfileServerUrl +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus +import org.siloserver.silo.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.focus.tvModalFocusBoundary +import org.siloserver.silo.tv.ui.screens.watchparty.PARTY_KEY_OPTIONS +import org.siloserver.silo.tv.ui.screens.watchparty.TV_WATCH_PARTY_HOST_AWAY_NOTE +import org.siloserver.silo.tv.ui.screens.watchparty.TV_WATCH_PARTY_HOST_LEAVE_NOTE +import org.siloserver.silo.tv.ui.screens.watchparty.TvPartyNotice +import org.siloserver.silo.tv.ui.screens.watchparty.TvPartyRoomActions +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyConfirmDialog +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyInvitePage +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyOptionsOverlay +import org.siloserver.silo.tv.ui.screens.watchparty.TvWatchPartyRoomView +import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvPartyFocus +import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvStagedPreview +import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvWatchPartyReconnectNotice +import org.siloserver.silo.tv.ui.screens.watchparty.tvPartyAnchorKey +import org.siloserver.silo.tv.ui.screens.watchparty.tvWatchPartyLeftBehind +import org.siloserver.silo.tv.ui.screens.watchparty.tvWatchPartyNameList +import org.siloserver.silo.tv.ui.screens.watchparty.tvWatchPartyWaitingFor +import org.siloserver.silo.tv.ui.theme.DarkBackground +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.watchPartyEligibility +import org.siloserver.silo.watchtogether.watchPartyErrorMessage +import org.siloserver.silo.watchtogether.watchPartyInviteUrl + +/** + * The Watch Party layer over the TV player: the non-focusable status overlay + * and the party panel that Back opens over the retained player. + * + * The panel is a focusable popup; the player keeps running underneath and + * closing the panel never tears it down. Focus returns to the player when it + * closes. Kept out of [TvPlayerScreen] so that composable stays within ART's + * JIT method-size limit. + */ +@Composable +internal fun TvWatchPartyPlayerOverlays( + watchParty: TvWatchPartyScreenController, + panelOpen: Boolean, + isInPictureInPictureMode: Boolean, + playerFocus: FocusRequester, + playerFocused: Boolean, + onPanelOpenChange: (Boolean) -> Unit, + /** The player's terminal exit; it leaves the party on the way out. */ + exit: () -> Unit, + repository: WatchTogetherRepository = koinInject(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val room by watchParty.playback.room.collectAsState() + val latestPlayerFocused by rememberUpdatedState(playerFocused) + if (!isInPictureInPictureMode && !panelOpen) { + TvWatchPartyStatusOverlay(watchParty = watchParty, room = room, repository = repository) + } + if (!isInPictureInPictureMode && panelOpen) { + TvWatchPartyPanel( + room = room, + repository = repository, + onClose = { onPanelOpenChange(false) }, + onReturnToLobby = { + onPanelOpenChange(false) + scope.launch { + val result = repository.stopPlayback() + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't stop playback."), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + onLeave = { + onPanelOpenChange(false) + exit() + }, + onEndForEveryone = { + onPanelOpenChange(false) + watchParty.endForEveryone() + exit() + }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = panelOpen && !isInPictureInPictureMode, + opener = playerFocus, + isOpenerFocused = { latestPlayerFocused }, + ) +} + +/** + * Status that explains what the party is doing, top-center. Never focusable + * and never announced on every update; it only reports. + */ +@Composable +private fun TvWatchPartyStatusOverlay( + watchParty: TvWatchPartyScreenController, + room: RoomSnapshot?, + repository: WatchTogetherRepository, +) { + val catchingUp by watchParty.playback.catchingUp.collectAsState() + val connection by repository.connectionState.collectAsState() + val reconnect = rememberTvWatchPartyReconnectNotice(connection = connection, inRoom = room != null) + + // "Continuing without …": members the room stopped waiting for, shown + // for a few seconds each time the room leaves someone behind. + var leftBehind by remember { mutableStateOf>(emptyList()) } + var leftBehindNonce by remember { mutableIntStateOf(0) } + LaunchedEffect(watchParty) { + var previous: RoomSnapshot? = null + watchParty.playback.room.collect { next -> + val names = tvWatchPartyLeftBehind(previous, next) + if (names.isNotEmpty()) { + leftBehind = names + leftBehindNonce++ + } + previous = next + } + } + LaunchedEffect(leftBehindNonce) { + if (leftBehindNonce == 0) return@LaunchedEffect + delay(LEFT_BEHIND_NOTICE_MS) + leftBehind = emptyList() + } + + val playing = room?.phase == RoomPhase.Playing + val hostAway = playing && room?.selfRole != MemberRole.Host && room?.hostConnected == false + val waitingFor = if (catchingUp) null else tvWatchPartyWaitingFor(room) + if (reconnect == null && !hostAway && !catchingUp && waitingFor == null && leftBehind.isEmpty()) return + + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp) + .zIndex(6f), + contentAlignment = Alignment.TopCenter, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (reconnect != null) TvPartyNotice(title = reconnect) + if (hostAway) TvPartyNotice(title = "The host disconnected", detail = TV_WATCH_PARTY_HOST_AWAY_NOTE) + if (catchingUp) { + TvPartyNotice( + title = "Catching up to the party", + detail = "The party kept playing".takeIf { room?.selfIgnoreWait == true }, + ) + } + if (waitingFor != null) { + TvPartyNotice( + title = if (waitingFor.isEmpty()) { + "Syncing playback" + } else { + "Syncing playback · Waiting for ${tvWatchPartyNameList(waitingFor)}" + }, + ) + } + if (leftBehind.isNotEmpty()) { + TvPartyNotice(title = "Continuing without ${tvWatchPartyNameList(leftBehind)}") + } + } + } +} + +/** + * The party room over the retained player, as tvOS does it: Back inside party + * playback opens the lobby itself, with Return to playback as the primary + * action. Invitation, playback policy, Return everyone to lobby, End, and + * Leave all live where they do in the lobby (the code pill and "···"). + */ +@Composable +private fun TvWatchPartyPanel( + room: RoomSnapshot?, + repository: WatchTogetherRepository, + onClose: () -> Unit, + onReturnToLobby: () -> Unit, + onLeave: () -> Unit, + onEndForEveryone: () -> Unit, + availability: WatchPartyAvailabilityRepository = koinInject(), +) { + val pending by repository.pendingAction.collectAsState() + val connection by repository.connectionState.collectAsState() + val suggestions by repository.suggestions.collectAsState() + val features = availability.features + val eligibility = watchPartyEligibility( + room = room, + features = features, + busy = pending != null, + personalVotesKnown = false, + ) + val isHost = eligibility.isHost + val serverUrl = rememberProfileServerUrl() + val inviteUrl = room?.takeIf { isHost }?.let { watchPartyInviteUrl(serverUrl, it.invitePath) } + val reconnect = rememberTvWatchPartyReconnectNotice(connection = connection, inRoom = room != null) + val staged = rememberTvStagedPreview( + contentId = room?.selectedContentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + suggestions = suggestions, + ) + val focus = rememberTvPartyFocus() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var optionsOpen by remember { mutableStateOf(false) } + var inviteOpen by remember { mutableStateOf(false) } + var confirmEnd by remember { mutableStateOf(false) } + var confirmLeave by remember { mutableStateOf(false) } + val modalOpen = optionsOpen || inviteOpen || confirmEnd || confirmLeave + var opener by remember { mutableStateOf(null) } + fun openModal(open: () -> Unit) { + opener = focus.focusedKey + open() + } + val anchor = room?.let { tvPartyAnchorKey(it, features, suggestions) } ?: PARTY_KEY_OPTIONS + // The focusable popup hands focus to its first control (the code pill) + // on its own, so put it on Return to playback once the room is laid out. + LaunchedEffect(anchor, room != null) { + if (room == null || optionsOpen || inviteOpen || confirmEnd || confirmLeave) return@LaunchedEffect + requestFocusUntilObserved( + maxAttempts = PANEL_FOCUS_ATTEMPTS, + awaitAttempt = { withFrameNanos { } }, + requestFocus = focus.requester(anchor)::requestFocus, + isFocused = { focus.isFocused(anchor) }, + ) + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = onClose, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(DarkBackground) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(focus.requester(anchor))), + ) { + if (room != null) { + TvWatchPartyRoomView( + room = room, + suggestions = suggestions, + features = features, + eligibility = eligibility, + personalVotesKnown = false, + connection = connection, + staged = staged, + focus = focus, + notice = reconnect, + actions = TvPartyRoomActions( + onInvite = { openModal { inviteOpen = true } }, + onOptions = { openModal { optionsOpen = true } }, + onReturnToPlayback = onClose, + ), + ) + } + } + } + + if (optionsOpen && room != null) { + TvWatchPartyOptionsOverlay( + room = room, + features = features, + eligibility = eligibility, + onSetMode = {}, + onSetPolicy = { policy -> + scope.launch { + val result = repository.updatePolicy(policy) + if (result !is ApiResult.Success) { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't change who can play and pause."), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + onStopPlayback = { + optionsOpen = false + onReturnToLobby() + }, + onEnd = { + optionsOpen = false + confirmEnd = true + }, + onLeave = { + optionsOpen = false + if (isHost) confirmLeave = true else onLeave() + }, + onDismiss = { optionsOpen = false }, + ) + } + if (inviteOpen && room != null) { + TvWatchPartyInvitePage( + code = room.code, + inviteUrl = inviteUrl, + backdropUrl = staged?.backdropUrl, + backdropThumbhash = staged?.backdropThumbhash, + onDismiss = { inviteOpen = false }, + ) + } + if (confirmEnd) { + TvWatchPartyConfirmDialog( + title = "End this party for everyone?", + message = "Playback stops for everyone and the party closes.", + confirmLabel = "End party", + destructive = true, + onConfirm = { + confirmEnd = false + onEndForEveryone() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmLeave) { + TvWatchPartyConfirmDialog( + title = "Leave the party?", + message = TV_WATCH_PARTY_HOST_LEAVE_NOTE, + confirmLabel = "Leave party", + destructive = true, + onConfirm = { + confirmLeave = false + onLeave() + }, + onDismiss = { confirmLeave = false }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = modalOpen, + opener = opener?.let(focus::requester), + isOpenerFocused = { focus.isFocused(opener) }, + ) +} + +private const val LEFT_BEHIND_NOTICE_MS = 6_000L +private const val PANEL_FOCUS_ATTEMPTS = 10 diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 2e5824d8c8..8a167081ff 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -128,6 +128,7 @@ import org.siloserver.silo.tv.ui.theme.FocusedContent import org.siloserver.silo.tv.ui.theme.Spacing import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.watchparty.WatchPartyExperiment import org.siloserver.silo.viewmodel.HomeViewModel import kotlinx.coroutines.delay @@ -1039,6 +1040,11 @@ private fun TvGeneralSettingsPane( ) } } + item { + // Settings → Experimental (D1): device-local, never synced. Turning + // Watch Party off hides every entry point and leaves any party. + TvExperimentalSettingsGroup() + } // No Library group — tvOS parity: Apple's TVSettingsView has no such // section (it is iOS-only). On TV these destinations live in the // For You dropdown (Watchlist/Favorites), the profile menu @@ -2349,6 +2355,24 @@ private fun DialogButton( } } +/** The Experimental group: features that are not finished yet. */ +@Composable +private fun TvExperimentalSettingsGroup( + watchPartyExperiment: WatchPartyExperiment = koinInject(), +) { + val watchPartyEnabled by watchPartyExperiment.enabled.collectAsState() + SettingsGroup(title = "Experimental") { + SettingsToggleRow( + label = "Watch Party", + checked = watchPartyEnabled, + onCheckedChange = watchPartyExperiment::setEnabled, + ) + SettingsFooterText( + text = "Try Watch Party before it's finished. Turning it off leaves any party you're in.", + ) + } +} + // --------------------------------------------------------------------------- // Shared row primitives — inverted-capsule focus chrome // --------------------------------------------------------------------------- diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvJoinCodeDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvJoinCodeDialog.kt new file mode 100644 index 0000000000..4b5c918bba --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvJoinCodeDialog.kt @@ -0,0 +1,291 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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 +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +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.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus +import org.siloserver.silo.tv.ui.focus.TvControlState +import org.siloserver.silo.tv.ui.focus.tvControlSemantics +import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated +import org.siloserver.silo.tv.ui.theme.FocusedContainer +import org.siloserver.silo.tv.ui.theme.FocusedContent +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.watchtogether.WATCH_PARTY_CODE_ALPHABET +import org.siloserver.silo.watchtogether.WATCH_PARTY_CODE_LENGTH +import org.siloserver.silo.watchtogether.canDismissRoomEntry +import org.siloserver.silo.watchtogether.normalizeWatchPartyCode + +/** + * Pure D-pad join-code accumulator. Appends only characters from the room + * code alphabet (uppercasing letters; 0, 1, I and O never appear in a code), + * caps at [LENGTH], and supports backspace/clear. Kept free of Android types + * so it is unit-testable without Robolectric. + */ +data class JoinCodeState(val code: String = "") { + /** A complete, well-formed code, normalized the same way as a typed or pasted one. */ + val normalized: String? get() = normalizeWatchPartyCode(code) + val isComplete: Boolean get() = normalized != null + fun append(c: Char): JoinCodeState { + if (code.length >= LENGTH) return this + val up = c.uppercaseChar() + return if (up in WATCH_PARTY_CODE_ALPHABET) copy(code = code + up) else this + } + fun backspace() = if (code.isEmpty()) this else copy(code = code.dropLast(1)) + fun clear() = JoinCodeState() + companion object { const val LENGTH = WATCH_PARTY_CODE_LENGTH } +} + +/** Keys laid out in the focusable code-alphabet grid, eight per row. */ +private val JoinCodeKeys: List = WATCH_PARTY_CODE_ALPHABET.toList() +private const val KEYS_PER_ROW = 8 + +/** + * D-pad Watch Party join-by-code dialog on the party's card: one slot per + * code character, a focusable pill-key grid of the code alphabet (each key + * appends into a [JoinCodeState]), Delete (backspace), and Join party, which + * acts once the code is complete and nothing is in flight. [onJoin] receives + * the normalized code. + */ +@Composable +fun TvJoinCodeDialog( + isBusy: Boolean, + error: String?, + onJoin: (String) -> Unit, + onDismiss: () -> Unit, +) { + var state by remember { mutableStateOf(JoinCodeState()) } + val firstKeyFocus = remember { FocusRequester() } + + Popup( + alignment = Alignment.Center, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), + clippingEnabled = false, + ), + ) { + // A scrim and the party's card: the hub's text sits right behind the keypad. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .padding(start = 36.dp, top = 30.dp, end = 36.dp, bottom = 30.dp), + contentAlignment = Alignment.Center, + ) { + val panelShape = RoundedCornerShape(15.dp) + Column( + modifier = Modifier + .width(420.dp) + .background(color = DarkSurfaceElevated, shape = panelShape) + .border(1.dp, PartyChromeBorder, panelShape) + .padding(horizontal = 22.dp, vertical = 18.dp) + .then(rememberTvDialogInitialFocus(firstKeyFocus)), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + TvPartyEyebrow("Join a Watch Party") + Text( + text = "Enter the party code", + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + ) + + // One slot per character; the next one to fill is outlined. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + (0 until JoinCodeState.LENGTH).forEach { i -> + val char = state.code.getOrNull(i) + val next = i == state.code.length + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(8.dp)) + .border( + width = if (next) 1.5.dp else 1.dp, + color = if (next) SiloOnSurface.copy(alpha = 0.7f) else PartyChromeBorder, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = char?.toString() ?: "", + fontSize = 20.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = SiloOnSurface, + ) + } + } + } + + // Focusable code-alphabet grid. + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) { + JoinCodeKeys.chunked(KEYS_PER_ROW).forEachIndexed { rowIndex, rowKeys -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + rowKeys.forEachIndexed { colIndex, ch -> + JoinCodeKey( + char = ch, + // Joining is in flight, not a dead end — the + // grid keeps its focus so the ring survives a + // failed join. + controlState = TvControlState.transient(!isBusy), + onClick = { state = state.append(ch) }, + modifier = if (rowIndex == 0 && colIndex == 0) { + Modifier + .weight(1f) + .focusRequester(firstKeyFocus) + } else { + Modifier.weight(1f) + }, + ) + } + // Pad the final partial row so keys keep a consistent width. + repeat(KEYS_PER_ROW - rowKeys.size) { + Box(modifier = Modifier.weight(1f)) + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + ) { + TvPartyButton( + label = "Delete", + kind = TvPartyButtonKind.Secondary, + state = TvControlState.transient(!isBusy && state.code.isNotEmpty()), + onClick = { state = state.backspace() }, + height = 34.dp, + ) + TvPartyButton( + label = if (isBusy) "Joining…" else "Join party", + state = TvControlState.transient(state.isComplete && !isBusy), + onClick = { state.normalized?.let(onJoin) }, + height = 34.dp, + fontSize = 15.sp, + ) + } + + error?.let { message -> TvPartyBanner(message = message, warning = true) } + } + } + } +} + +/** Single focusable key tile in the join-code grid. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun JoinCodeKey( + char: Char, + controlState: TvControlState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + val enabled = controlState.actionable + + Surface( + onClick = { controlState.perform(onClick) }, + enabled = controlState.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + // Dimmed from `actionable`, not from the Surface's disabled slots: the + // key stays focusable while a join is in flight, so it never enters the + // disabled colour path. + colors = ClickableSurfaceDefaults.colors( + containerColor = if (enabled) { + PartyChromeFill + } else { + Color.White.copy(alpha = 0.03f) + }, + contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.42f), + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + disabledContainerColor = Color.White.copy(alpha = 0.03f), + disabledContentColor = Color.White.copy(alpha = 0.38f), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(0.dp, Color.Transparent), + shape = shape, + ), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow( + elevationColor = Color.White.copy(alpha = 0.18f), + elevation = 14.dp, + ), + ), + modifier = modifier + .height(30.dp) + .tvControlSemantics(controlState), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = char.toString(), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + ), + color = if (isFocused) FocusedContent else Color.White, + ) + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt new file mode 100644 index 0000000000..e80dc40bff --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt @@ -0,0 +1,194 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +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.Row +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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 +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +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.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.siloserver.silo.repository.WatchTogetherConnectionState +import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.focus.tvModalFocusBoundary +import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.theme.SiloSecondaryText + +/** A non-focusable one- or two-line notice (reconnecting, host away, host stopped). */ +@Composable +internal fun TvPartyNotice(title: String, modifier: Modifier = Modifier, detail: String? = null) { + Column( + modifier = modifier + .background(Color.Black.copy(alpha = 0.72f), RoundedCornerShape(14.dp)) + .border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(14.dp)) + .padding(horizontal = 18.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold), + color = Color.White, + ) + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = Color.White.copy(alpha = 0.78f), + ) + } + } +} + +/** Frames to keep asking for the confirmation's initial focus. */ +private const val CONFIRM_FOCUS_ATTEMPTS = 6 + +/** + * Two-button confirmation on the party's card. Initial focus goes to Cancel + * unless [focusConfirm], so a stray Select never runs a destructive action. + */ +@Composable +internal fun TvWatchPartyConfirmDialog( + title: String, + message: String?, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + cancelLabel: String = "Cancel", + destructive: Boolean = false, + focusConfirm: Boolean = false, +) { + val confirmFocus = remember { FocusRequester() } + val cancelFocus = remember { FocusRequester() } + var confirmFocused by remember { mutableStateOf(false) } + var cancelFocused by remember { mutableStateOf(false) } + // The focusable popup hands focus to its first control on its own, and + // the shared initial-focus helper then stands down, so move focus to the + // intended button once the buttons exist. + LaunchedEffect(focusConfirm) { + requestFocusUntilObserved( + maxAttempts = CONFIRM_FOCUS_ATTEMPTS, + awaitAttempt = { withFrameNanos { } }, + requestFocus = (if (focusConfirm) confirmFocus else cancelFocus)::requestFocus, + isFocused = { if (focusConfirm) confirmFocused else cancelFocused }, + ) + } + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)), + contentAlignment = Alignment.Center, + ) { + val shape = RoundedCornerShape(15.dp) + Column( + modifier = Modifier + .width(440.dp) + .background(DarkSurfaceElevated, shape) + .border(1.dp, PartyChromeBorder, shape) + .padding(28.dp) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(if (focusConfirm) confirmFocus else cancelFocus)), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + textAlign = TextAlign.Center, + ) + if (message != null) { + Text( + text = message, + fontSize = TvPartyMetrics.body, + color = SiloSecondaryText, + textAlign = TextAlign.Center, + ) + } + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TvPartyButton( + label = cancelLabel, + kind = TvPartyButtonKind.Secondary, + onClick = onDismiss, + modifier = Modifier + .focusRequester(cancelFocus) + .onFocusChanged { cancelFocused = it.isFocused }, + ) + TvPartyButton( + label = confirmLabel, + kind = if (destructive) TvPartyButtonKind.Secondary else TvPartyButtonKind.Primary, + onClick = onConfirm, + modifier = Modifier + .focusRequester(confirmFocus) + .onFocusChanged { confirmFocused = it.isFocused }, + ) + } + } + } + } +} + +/** + * The reconnect notice text, or null. It appears only after the room socket + * has been down for [delayMs], timed from when this screen first saw it down, + * and clears as soon as the socket is writable again. + */ +@Composable +internal fun rememberTvWatchPartyReconnectNotice( + connection: WatchTogetherConnectionState, + inRoom: Boolean, + delayMs: Long = TV_WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS, +): String? { + var shown by remember { mutableStateOf(false) } + val down = inRoom && !connection.writable && connection.disconnectedAtMs != null + LaunchedEffect(down, connection.disconnectedAtMs) { + shown = false + if (down) { + delay(delayMs) + shown = true + } + } + if (!down || !shown) return null + return if (connection.epoch == 0L) "Connecting to the party…" else "Reconnecting to the party…" +} + +internal const val TV_WATCH_PARTY_RECONNECT_NOTICE_DELAY_MS = 2_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyDetailEntry.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyDetailEntry.kt new file mode 100644 index 0000000000..4effd2de64 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyDetailEntry.kt @@ -0,0 +1,371 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.model.feature.WatchPartyExposure +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.viewmodel.WatchPartyHubViewModel +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.viewmodel.WatchPartyLobbyViewModel +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.WatchPartyAvailability +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.WatchPartyDestination +import org.siloserver.silo.watchtogether.watchPartyErrorMessage +import org.siloserver.silo.watchtogether.watchPartyHostingOffered + +/** The detail page's Watch Party row in its More menu. */ +internal data class TvWatchPartyDetailOption( + val title: String, + val subtitle: String, + val onSelect: () -> Unit, +) + +/** What the viewer asked the detail page to do with the party. */ +internal sealed interface TvDetailPartyRequest { + val item: WatchPartyItem + + /** Not engaged: host a Host Picks party with this item staged. */ + data class Host(override val item: WatchPartyItem) : TvDetailPartyRequest + + /** Host in a Host Picks lobby: stage this item. */ + data class Stage(val roomId: String, override val item: WatchPartyItem) : TvDetailPartyRequest + + /** Any member: suggest this item. */ + data class Suggest(val roomId: String, override val item: WatchPartyItem) : TvDetailPartyRequest + + /** Host while the party plays: confirm, then switch everyone to this item. */ + data class Select(val roomId: String, override val item: WatchPartyItem) : TvDetailPartyRequest +} + +/** Holds the detail page's pending party request; the dialogs and effects run it. */ +@Stable +internal class TvWatchPartyDetailEntry internal constructor() { + internal var request by mutableStateOf(null) + internal var selectConfirmed by mutableStateOf(false) +} + +@Composable +internal fun rememberTvWatchPartyDetailEntry(): TvWatchPartyDetailEntry = remember { TvWatchPartyDetailEntry() } + +/** + * The Watch Party row for a detail page showing [item] (null when the page + * is not a movie, an episode, or a series whose next-up episode is known). + * Not engaged: "Watch Party" hosts with the item staged. Engaged: a Host + * Picks host in the lobby adds it ("Add to party"), a host while playing + * switches everyone to it after a confirmation, and everyone else suggests it. + */ +@Composable +internal fun rememberTvWatchPartyDetailOption( + entry: TvWatchPartyDetailEntry, + item: WatchPartyItem?, + exposure: WatchPartyExposure = koinInject(), + repository: WatchTogetherRepository = koinInject(), + availability: WatchPartyAvailabilityRepository = koinInject(), +): TvWatchPartyDetailOption? { + val enabled by exposure.enabled.collectAsState() + val room by repository.roomSnapshot.collectAsState() + val available by availability.availability.collectAsState() + if (!enabled || item == null) return null + val current = room + if (current == null) { + if (!watchPartyHostingOffered(available)) return null + return TvWatchPartyDetailOption( + title = "Watch Party", + subtitle = "Host a party with this title", + onSelect = { entry.request = TvDetailPartyRequest.Host(item) }, + ) + } + val host = current.selfRole == MemberRole.Host && current.selfCanManageRoom + // As on phone: never offer an operation the server does not advertise. + val stagingSupported = (available as? WatchPartyAvailability.Available)?.features?.stagedSelection == true + val knownMode = current.selectionMode == RoomSelectionMode.HostPick || current.selectionMode == RoomSelectionMode.Vote + return when { + host && current.phase == RoomPhase.Lobby && current.selectionMode == RoomSelectionMode.HostPick && + stagingSupported -> + TvWatchPartyDetailOption( + title = "Add to party", + subtitle = "Make this the party's next title", + onSelect = { entry.request = TvDetailPartyRequest.Stage(current.roomId, item) }, + ) + // The server refuses a direct selection in a voting room; those suggest instead. + host && current.phase == RoomPhase.Playing && current.selectionMode == RoomSelectionMode.HostPick -> + TvWatchPartyDetailOption( + title = "Play for everyone", + subtitle = "Switch the party to this title", + onSelect = { + entry.selectConfirmed = false + entry.request = TvDetailPartyRequest.Select(current.roomId, item) + }, + ) + current.selfRole != MemberRole.Unknown && knownMode && + (current.phase == RoomPhase.Lobby || current.phase == RoomPhase.Playing) -> + TvWatchPartyDetailOption( + title = "Suggest to Party", + subtitle = "Add it to the party's suggestions", + onSelect = { entry.request = TvDetailPartyRequest.Suggest(current.roomId, item) }, + ) + else -> null + } +} + +/** + * Runs the detail page's party request: its confirmation, the room or hub + * operation, and the navigation that follows. [onNavigate] receives the + * party screen to show, or null for the hub. + */ +@Composable +internal fun TvWatchPartyDetailEffects( + entry: TvWatchPartyDetailEntry, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + when (val request = entry.request) { + null -> Unit + is TvDetailPartyRequest.Host -> DetailHostRunner(entry, request, onNavigate) + is TvDetailPartyRequest.Stage -> DetailStageRunner(entry, request, onNavigate) + is TvDetailPartyRequest.Suggest -> DetailSuggestRunner(entry, request) + is TvDetailPartyRequest.Select -> if (!entry.selectConfirmed) { + TvWatchPartyConfirmDialog( + title = "Play this for everyone?", + message = "The party switches to ${request.item.title} now.", + confirmLabel = "Play for everyone", + focusConfirm = true, + onConfirm = { entry.selectConfirmed = true }, + onDismiss = { entry.request = null }, + ) + } else { + DetailSelectRunner(entry, request, onNavigate) + } + } +} + +@Composable +private fun DetailHostRunner( + entry: TvWatchPartyDetailEntry, + request: TvDetailPartyRequest.Host, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + val context = LocalContext.current + val availability: WatchPartyAvailabilityRepository = koinInject() + val hub: WatchPartyHubViewModel = koinViewModel(key = "tv-detail-watch-party-hub") + val state by hub.uiState.collectAsState() + val latestNavigate by rememberUpdatedState(onNavigate) + var started by remember(request) { mutableStateOf(false) } + // Re-check first, as the hub does: the cached answer may predate the + // server turning Watch Party off. `checking` is set before this returns. + var rechecked by remember(request) { mutableStateOf(false) } + LaunchedEffect(request) { + hub.refresh(force = true) + rechecked = true + } + + LaunchedEffect(request, state.availability, rechecked, state.checking) { + if (started || !rechecked || state.checking) return@LaunchedEffect + when (state.availability ?: availability.availability.value) { + null -> Unit // The hub's first probe is still running. + is WatchPartyAvailability.Available -> { + started = true + TvWatchPartyPreviews.put(request.item) + hub.clearError() + hub.hostWithItem(request.item) + } + // Unsupported, not allowed, or a failed probe: the hub explains and offers Retry. + else -> { + entry.request = null + latestNavigate(null) + } + } + } + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + hub.consumeDestination() + entry.request = null + latestNavigate(destination) + } + LaunchedEffect(state.error) { + val error = state.error ?: return@LaunchedEffect + if (!started) return@LaunchedEffect + Toast.makeText(context, error, Toast.LENGTH_LONG).show() + hub.clearError() + // A staging failure after the room exists still lands in the lobby + // (the destination above); anything else ends this request. + if (state.destination == null) entry.request = null + } +} + +@Composable +private fun DetailStageRunner( + entry: TvWatchPartyDetailEntry, + request: TvDetailPartyRequest.Stage, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + val context = LocalContext.current + val repository: WatchTogetherRepository = koinInject() + val lobby: WatchPartyLobbyViewModel = koinViewModel( + key = "tv-detail-watch-party-lobby-${request.roomId}", + parameters = { parametersOf(request.roomId) }, + ) + val latestNavigate by rememberUpdatedState(onNavigate) + LaunchedEffect(request) { + TvWatchPartyPreviews.put(request.item) + lobby.stage(request.item) + val staged = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + repository.roomSnapshot.first { room -> + room == null || room.roomId != request.roomId || room.selectedContentId == request.item.contentId + } + } + entry.request = null + if (staged != null && staged.roomId == request.roomId && staged.selectedContentId == request.item.contentId) { + Toast.makeText(context, "Added ${request.item.title} to the party.", Toast.LENGTH_SHORT).show() + latestNavigate(WatchPartyDestination.Lobby(request.roomId)) + } + } + LaunchedEffect(lobby) { + lobby.messages.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + entry.request = null + } + } +} + +@Composable +private fun DetailSuggestRunner(entry: TvWatchPartyDetailEntry, request: TvDetailPartyRequest.Suggest) { + val context = LocalContext.current + val repository: WatchTogetherRepository = koinInject() + val lobby: WatchPartyLobbyViewModel = koinViewModel( + key = "tv-detail-watch-party-lobby-${request.roomId}", + parameters = { parametersOf(request.roomId) }, + ) + LaunchedEffect(request) { + val before = repository.suggestions.value.count { it.contentId == request.item.contentId } + lobby.suggest(request.item) + val added = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + repository.suggestions.first { list -> list.count { it.contentId == request.item.contentId } > before } + } + entry.request = null + if (added != null) { + Toast.makeText(context, "Suggested ${request.item.title} to the party.", Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(lobby) { + lobby.messages.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + entry.request = null + } + } +} + +@Composable +private fun DetailSelectRunner( + entry: TvWatchPartyDetailEntry, + request: TvDetailPartyRequest.Select, + onNavigate: (WatchPartyDestination?) -> Unit, +) { + val context = LocalContext.current + val repository: WatchTogetherRepository = koinInject() + val latestNavigate by rememberUpdatedState(onNavigate) + LaunchedEffect(request) { + val result = repository.setSelection( + SetSelectionRequest( + contentId = request.item.contentId, + fileId = request.item.fileId, + libraryId = request.item.libraryId, + ), + ) + entry.request = null + entry.selectConfirmed = false + if (result is ApiResult.Success) { + latestNavigate(WatchPartyDestination.Player(request.roomId)) + } else { + Toast.makeText( + context, + watchPartyErrorMessage(result, "Couldn't switch the party to ${request.item.title}."), + Toast.LENGTH_LONG, + ).show() + } + } +} + +/** + * D5: while this device is in a Watch Party, starting other playback first + * asks the viewer to leave the party. Leaving is explicit, never hidden. + */ +@Stable +internal class TvWatchPartyPlayGuard internal constructor( + private val engaged: () -> Boolean, + /** Returns once the party is torn down, so no room command reaches the solo player. */ + private val leave: suspend () -> Unit, + private val scope: CoroutineScope, +) { + internal var pending by mutableStateOf<(() -> Unit)?>(null) + private set + + /** Run [play] now, or ask first when engaged. */ + fun requestPlay(play: () -> Unit) { + if (engaged()) pending = play else play() + } + + internal fun confirm() { + val play = pending ?: return + pending = null + scope.launch { + leave() + play() + } + } + + internal fun cancel() { + pending = null + } +} + +@Composable +internal fun rememberTvWatchPartyPlayGuard( + repository: WatchTogetherRepository = koinInject(), + roomSession: RoomSession = koinInject(), +): TvWatchPartyPlayGuard { + val scope = rememberCoroutineScope() + return remember(repository, roomSession, scope) { + TvWatchPartyPlayGuard( + engaged = { repository.roomSnapshot.value != null }, + leave = { roomSession.depart(closeRoom = false).join() }, + scope = scope, + ) + } +} + +@Composable +internal fun TvWatchPartyPlayGuardDialog(guard: TvWatchPartyPlayGuard) { + if (guard.pending == null) return + TvWatchPartyConfirmDialog( + title = "Leave the Watch Party to play this?", + message = "You're in a Watch Party. Leaving it stops the party on this TV.", + confirmLabel = "Leave and play", + onConfirm = guard::confirm, + onDismiss = guard::cancel, + ) +} + +private const val REQUEST_TIMEOUT_MS = 15_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyHubScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyHubScreen.kt new file mode 100644 index 0000000000..cb840ab13d --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyHubScreen.kt @@ -0,0 +1,313 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.repository.WatchPartyPendingAction +import org.siloserver.silo.tv.ui.focus.TvControlState +import org.siloserver.silo.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.theme.SiloSecondaryText +import org.siloserver.silo.viewmodel.WatchPartyHubViewModel +import org.siloserver.silo.watchtogether.WatchPartyAvailability +import org.siloserver.silo.watchtogether.WatchPartyDestination + +private enum class HubFocus { Return, Rejoin, Host, Join, Retry } + +/** + * The Watch Party entry, reached from the profile menu, laid out as the tvOS + * entry: the pitch on the left; Host (start a party, start one where everyone + * votes, Return or Rejoin) and Join (by code, or Check again when this server + * can't host) on the right. Navigation follows the hub ViewModel's + * destination only. + * + * [showEnded] shows why the last party ended (the lobby and player open the + * hub this way when a party ends under them). + */ +@Composable +fun TvWatchPartyHubScreen( + showEnded: Boolean, + onDestination: (WatchPartyDestination) -> Unit, + onBack: () -> Unit, + viewModel: WatchPartyHubViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + var joinOpen by rememberSaveable { mutableStateOf(false) } + val returnFocus = remember { FocusRequester() } + val rejoinFocus = remember { FocusRequester() } + val hostFocus = remember { FocusRequester() } + val joinFocus = remember { FocusRequester() } + val retryFocus = remember { FocusRequester() } + var joinFocused by remember { mutableStateOf(false) } + + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + joinOpen = false + onDestination(destination) + } + + // The recent party can change while this screen is in the back stack + // (a party ended, another identity signed in), and the server can turn + // Watch Party off at any time. Probe again whenever the hub is shown, as + // Apple does; a failed probe keeps the last known answer. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh(force = true) + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + BackHandler(enabled = !joinOpen) { onBack() } + + val availability = state.availability + val available = availability is WatchPartyAvailability.Available + val busy = state.busy != null + // Still checking is a wait, not a dead end: the buttons stay focusable + // and dimmed. A server that can't host drops them from the focus path. + val entryState = when { + available -> TvControlState.transient(!busy) + availability == null -> TvControlState.transient(false) + else -> TvControlState.structural(false) + } + // Hosting needs staged selection; a server without it can still be joined. + val canHost = (availability as? WatchPartyAvailability.Available)?.features?.stagedSelection == true + val current = state.current + val recent = state.recent.takeIf { available } + val primary = when { + current != null -> HubFocus.Return + recent != null -> HubFocus.Rejoin + canHost || availability == null -> HubFocus.Host + available -> HubFocus.Join + else -> HubFocus.Retry + } + val primaryTarget = when (primary) { + HubFocus.Return -> returnFocus + HubFocus.Rejoin -> rejoinFocus + HubFocus.Host -> hostFocus + HubFocus.Join -> joinFocus + HubFocus.Retry -> retryFocus + } + val contentFocus = rememberTvContentInitialFocus(target = primaryTarget, contentKey = primary) + // The probe and the recent party land a moment apart, so Start can take + // focus just before Rejoin appears. While the viewer still sits on the + // old default, the new default takes over; once they move, it doesn't. + var focusedDefault by remember { mutableStateOf(null) } + var previousPrimary by remember { mutableStateOf(primary) } + LaunchedEffect(primary) { + if (focusedDefault != null && focusedDefault == previousPrimary && primary != previousPrimary) { + requestFocusUntilObserved( + maxAttempts = 10, + awaitAttempt = { delay(60) }, + requestFocus = primaryTarget::requestFocus, + isFocused = { focusedDefault == primary }, + ) + } + previousPrimary = primary + } + fun Modifier.defaultTarget(which: HubFocus, requester: FocusRequester): Modifier = this + .focusRequester(requester) + .onFocusChanged { + if (it.isFocused) focusedDefault = which else if (focusedDefault == which) focusedDefault = null + } + + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop(url = null) + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 70.dp, end = 70.dp, top = 110.dp) + .then(contentFocus), + ) { + // ---- The pitch and whatever the viewer should know -------------- + Column(modifier = Modifier.width(380.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TvPartyEyebrow("Watch Party") + Text( + text = "Watch something\ntogether", + fontSize = TvPartyMetrics.heroTitle, + lineHeight = 54.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.8).sp, + color = SiloOnSurface, + ) + Text( + text = "Pick a title, share the code, and everyone's playback stays in step.", + fontSize = TvPartyMetrics.body, + lineHeight = 21.sp, + color = SiloSecondaryText, + ) + } + val ended = state.ended + if (showEnded && ended != null && current == null) { + TvPartyBanner(message = tvWatchPartyEndedMessage(ended.reason)) + } + val error = state.error + when { + error != null && !joinOpen -> TvPartyBanner(message = error, warning = true) + current != null -> Unit + availability == null -> TvPartyBanner(message = "Checking Watch Party support…") + availability is WatchPartyAvailability.Unsupported -> + TvPartyBanner(message = "This server doesn't support Watch Party yet.") + availability == WatchPartyAvailability.NotAllowed -> + TvPartyBanner(message = "Watch Party is not available for this profile on this server.") + availability is WatchPartyAvailability.ProbeFailed -> + TvPartyBanner(message = "Couldn't check whether this server supports Watch Party.", warning = true) + else -> Unit + } + } + Spacer(modifier = Modifier.weight(1f)) + + // ---- Host and Join -------------------------------------------- + Column(modifier = Modifier.width(320.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (current != null) { + TvPartyEyebrow("Your party") + TvPartyButton( + label = "Return to party", + icon = Icons.Filled.PlayArrow, + onClick = viewModel::returnToParty, + state = TvControlState.transient(!busy), + modifier = Modifier.defaultTarget(HubFocus.Return, returnFocus), + ) + Text( + text = "Party code ${tvWatchPartyDisplayCode(current.code)}", + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText, + ) + } else { + TvPartyEyebrow("Host") + if (available && !canHost) { + Text( + text = "This server can't host a Watch Party. You can still join one.", + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText, + ) + } else { + TvPartyButton( + label = if (state.busy == WatchPartyPendingAction.Create) "Starting your party…" else "Start a party", + icon = Icons.Filled.Add, + onClick = { viewModel.host() }, + state = entryState, + modifier = Modifier.defaultTarget(HubFocus.Host, hostFocus), + ) + TvPartyButton( + label = "Start a party and let everyone vote", + kind = TvPartyButtonKind.Secondary, + onClick = { viewModel.host(RoomSelectionMode.Vote) }, + state = entryState, + ) + } + if (recent != null) { + TvPartyButton( + label = if (state.busy == WatchPartyPendingAction.Join) { + "Joining…" + } else { + "Rejoin ${recent.title ?: "recent party"}" + }, + icon = Icons.Filled.Refresh, + kind = TvPartyButtonKind.Outlined, + onClick = viewModel::rejoinRecent, + state = entryState, + modifier = Modifier.defaultTarget(HubFocus.Rejoin, rejoinFocus), + ) + } + if (state.createRetryable) { + TvPartyButton( + label = "Try again", + kind = TvPartyButtonKind.Secondary, + onClick = viewModel::retryCreate, + state = entryState, + ) + Text( + text = "Finish creating the party you started.", + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText, + ) + } + } + } + if (current == null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TvPartyEyebrow("Join") + TvPartyButton( + label = "Join with a code", + kind = TvPartyButtonKind.Secondary, + onClick = { + viewModel.clearError() + joinOpen = true + }, + state = entryState, + modifier = Modifier + .defaultTarget(HubFocus.Join, joinFocus) + .onFocusChanged { joinFocused = it.isFocused }, + ) + if (!available) { + TvPartyButton( + label = "Check again", + kind = TvPartyButtonKind.Secondary, + onClick = { viewModel.refresh(force = true) }, + state = TvControlState.transient(availability != null), + modifier = Modifier.defaultTarget(HubFocus.Retry, retryFocus), + ) + } + } + } + } + } + } + + if (joinOpen) { + TvJoinCodeDialog( + isBusy = state.busy == WatchPartyPendingAction.Join, + error = state.error, + onJoin = viewModel::join, + onDismiss = { + viewModel.clearError() + joinOpen = false + }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = joinOpen, + opener = joinFocus, + isOpenerFocused = { joinFocused }, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt new file mode 100644 index 0000000000..acf384c8ba --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt @@ -0,0 +1,514 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.siloserver.silo.common.ui.components.rememberProfileServerUrl +import org.siloserver.silo.model.watchtogether.ItemMemberState +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.tv.ui.focus.TvControlState +import org.siloserver.silo.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.theme.SiloSecondaryText +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.viewmodel.WatchPartyLobbyViewModel +import org.siloserver.silo.watchtogether.WatchPartyDestination +import org.siloserver.silo.watchtogether.WatchPartyFeatures +import org.siloserver.silo.watchtogether.canRemoveSuggestion +import org.siloserver.silo.watchtogether.roomVoteWinner +import org.siloserver.silo.watchtogether.watchPartyInviteUrl + +/** + * The Watch Party lobby, laid out as the tvOS lobby ([TvWatchPartyRoomView]): + * the staged film full-bleed, the hero, the ballot or suggestions, seats, and + * the one foregrounded action. Settings, End, and Leave sit behind "···"; the + * code pill and the Invite seat open the invitation page. + * + * Only the room's phase opens the player ([WatchPartyLobbyViewModel.destination]). + * Every control is composed from role and room alone and gated transiently, + * and cards are keyed by suggestion id, so live snapshots and vote reordering + * never move or drop focus. If a focused card disappears (removed, promoted), + * focus goes to the nearest card that still has an action, else the primary + * action. Modals hand focus back to whatever opened them. + */ +@Composable +fun TvWatchPartyLobbyScreen( + roomId: String, + /** Opened because the host stopped playback. */ + hostStopped: Boolean, + onOpenPlayer: (WatchPartyDestination.Player) -> Unit, + onOpenDetail: (contentId: String) -> Unit, + onEnded: () -> Unit, + onLeft: () -> Unit, + onBack: () -> Unit, + viewModel: WatchPartyLobbyViewModel = koinViewModel( + key = "tv-watch-party-lobby-$roomId", + parameters = { parametersOf(roomId) }, + ), + repository: WatchTogetherRepository = koinInject(), +) { + val state by viewModel.state.collectAsState() + val destination by viewModel.destination.collectAsState() + val context = LocalContext.current + val latestOnOpenPlayer by rememberUpdatedState(onOpenPlayer) + val latestOnEnded by rememberUpdatedState(onEnded) + + LaunchedEffect(destination) { + (destination as? WatchPartyDestination.Player)?.let { latestOnOpenPlayer(it) } + } + LaunchedEffect(state.ended) { + val ended = state.ended ?: return@LaunchedEffect + if (ended.roomId == roomId) latestOnEnded() + } + // The membership can also vanish without an ended record (another screen + // left, the identity changed); the hub then offers what is possible. + var hadRoom by remember { mutableStateOf(false) } + val hasRoom = state.room?.roomId == roomId + LaunchedEffect(hasRoom) { + if (hasRoom) { + hadRoom = true + } else if (hadRoom && state.ended == null) { + latestOnEnded() + } + } + LaunchedEffect(viewModel) { + viewModel.messages.collect { message -> + if (message.isNotBlank()) Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + /** "select" stages a pick, "suggest" adds a suggestion; null is closed. */ + var pickerPurpose by rememberSaveable { mutableStateOf(null) } + var optionsOpen by rememberSaveable { mutableStateOf(false) } + var inviteOpen by rememberSaveable { mutableStateOf(false) } + var confirmEnd by rememberSaveable { mutableStateOf(false) } + var confirmLeave by rememberSaveable { mutableStateOf(false) } + var menuSuggestionId by remember { mutableStateOf(null) } + val modalOpen = pickerPurpose != null || optionsOpen || inviteOpen || confirmEnd || confirmLeave || + menuSuggestionId != null + + BackHandler(enabled = !modalOpen) { onBack() } + + val room = state.room?.takeIf { it.roomId == roomId } + val eligibility = state.eligibility + val suggestions = state.suggestions + // Role comes from the snapshot, so the layout is right before the + // capability probe (which eligibility also needs) has answered. + val isHost = room?.let { it.selfRole == MemberRole.Host && it.selfCanManageRoom } == true + + val focus = rememberTvPartyFocus() + var lobbyHasFocus by remember { mutableStateOf(false) } + var lastFocusedSuggestion by remember { mutableStateOf(null) } + val actionableIds = if (room == null) { + emptyList() + } else { + suggestions.filter { tvPartySuggestionFocusable(room, it) }.map { it.id } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + val latestActionableIds by rememberUpdatedState(actionableIds) + val latestSuggestionIds by rememberUpdatedState(suggestions.map { it.id }) + val anchorKey = room?.let { tvPartyAnchorKey(it, state.features, suggestions) } ?: PARTY_KEY_BACK + val latestAnchorKey by rememberUpdatedState(anchorKey) + + // Remember which card had focus, so a card that vanishes under focus + // hands it to its neighbour rather than the page. + val focusedKey = focus.focusedKey + LaunchedEffect(focusedKey) { + val key = focusedKey ?: return@LaunchedEffect + lastFocusedSuggestion = if (key.startsWith("s:")) { + latestSuggestionIds.indexOf(key.removePrefix("s:")).takeIf { it >= 0 } + } else { + null + } + } + + // One focus owner for first entry and for recovery: on entry, and + // whenever focus leaves the lobby without a modal taking it (a focused + // suggestion was removed, promoted, or reordered away; a button's action + // swapped it for another), land on the nearest suggestion that still has + // actions, else the anchor action. + LaunchedEffect(lobbyHasFocus, modalOpen, room != null) { + if (lobbyHasFocus || modalOpen) return@LaunchedEffect + delay(LOBBY_FOCUS_RESCUE_DELAY_MS) + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return@LaunchedEffect + val target = lastFocusedSuggestion + ?.let { index -> nearestActionable(latestSuggestionIds, latestActionableIds, index) } + ?.let { id -> focus.requester(tvPartySuggestionKey(id)) } + ?: focus.requester(latestAnchorKey) + requestFocusUntilObserved( + maxAttempts = 20, + awaitAttempt = { delay(60) }, + requestFocus = target::requestFocus, + isFocused = { lobbyHasFocus }, + ) + } + + // Entry focus goes to the anchor (the primary action). The anchor can + // change a moment after entry (the capability probe answers, a pick + // lands); while the viewer still sits on the old one, focus follows. + var initialFocusClaimed by rememberSaveable { mutableStateOf(false) } + var previousAnchor by remember { mutableStateOf(null) } + LaunchedEffect(room != null, anchorKey, modalOpen) { + if (room == null || modalOpen) return@LaunchedEffect + val previous = previousAnchor + previousAnchor = anchorKey + val follow = previous != null && previous != anchorKey && focus.isFocused(previous) + if (initialFocusClaimed && !follow) return@LaunchedEffect + val target = focus.requester(anchorKey) + requestFocusUntilObserved( + maxAttempts = 20, + awaitAttempt = { delay(60) }, + requestFocus = target::requestFocus, + isFocused = { focus.isFocused(latestAnchorKey) }, + ) + initialFocusClaimed = true + } + + // Modals return focus to the control that opened them. + var opener by remember { mutableStateOf(null) } + fun openModal(open: () -> Unit) { + opener = focus.focusedKey + open() + } + TvRestoreFocusOnModalDismiss( + visible = modalOpen, + opener = opener?.let(focus::requester), + isOpenerFocused = { focus.isFocused(opener) }, + ) + + val serverUrl = rememberProfileServerUrl() + val inviteUrl = room?.takeIf { isHost }?.let { watchPartyInviteUrl(serverUrl, it.invitePath) } + val connection by repository.connectionState.collectAsState() + val reconnect = rememberTvWatchPartyReconnectNotice(connection = connection, inRoom = room != null) + var showStopped by remember { mutableStateOf(hostStopped) } + LaunchedEffect(showStopped) { + if (showStopped) { + delay(HOST_STOPPED_NOTICE_MS) + showStopped = false + } + } + val staged = rememberTvStagedPreview( + contentId = room?.selectedContentId, + fileId = room?.selectedFileId, + libraryId = room?.selectedLibraryId, + suggestions = suggestions, + ) + val stagedId = room?.selectedContentId?.takeIf { it.isNotBlank() } + val memberState by produceState(initialValue = null, stagedId) { + value = null + stagedId?.let { value = viewModel.memberState(it) } + } + + Box( + modifier = Modifier + .fillMaxSize() + .onFocusChanged { lobbyHasFocus = it.hasFocus }, + ) { + if (room == null) { + ConnectingRoom(focus = focus, onBack = onBack) + } else { + TvWatchPartyRoomView( + room = room, + suggestions = suggestions, + features = state.features, + eligibility = eligibility, + personalVotesKnown = state.personalVotesKnown, + connection = connection, + staged = staged, + focus = focus, + notice = reconnect ?: "The host stopped playback".takeIf { showStopped && !isHost }, + memberStateLine = memberStateLine(room, memberState), + footer = state.suggestionRetry?.let { draft -> + { + SuggestionRetryRow( + title = draft.title, + onRetry = viewModel::retrySuggestion, + onDismiss = viewModel::dismissSuggestionRetry, + ) + } + }, + actions = TvPartyRoomActions( + onInvite = { openModal { inviteOpen = true } }, + onOptions = { openModal { optionsOpen = true } }, + onChooseTitle = { openModal { pickerPurpose = PICK_SELECT } }, + onSuggest = { openModal { pickerPurpose = PICK_SUGGEST } }, + onStart = { + if (room.selectionMode == RoomSelectionMode.Vote) { + roomVoteWinner(suggestions)?.let(viewModel::promote) + } else { + viewModel.start() + } + }, + onReady = viewModel::setLobbyReady, + onVote = viewModel::vote, + onQueue = { suggestion -> queue(viewModel, suggestion) }, + onSuggestionMenu = { suggestion -> + if (suggestionMenuRows(room, suggestion, state.features).isNotEmpty()) { + openModal { menuSuggestionId = suggestion.id } + } + }, + ), + ) + } + } + + val purpose = pickerPurpose + if (purpose != null && room != null) { + // Host-pick hosts stage their pick; everyone else suggests. + val stages = purpose == PICK_SELECT && state.features?.stagedSelection == true + TvWatchPartyPicker( + stages = stages, + members = room.members, + memberState = if (state.features?.memberState == true) viewModel::memberState else null, + onPick = { item -> + pickerPurpose = null + TvWatchPartyPreviews.put(item) + if (stages) viewModel.stage(item) else viewModel.suggest(item) + }, + onOpenSeries = { contentId -> + pickerPurpose = null + onOpenDetail(contentId) + }, + onDismiss = { pickerPurpose = null }, + ) + } + + if (optionsOpen && room != null) { + TvWatchPartyOptionsOverlay( + room = room, + features = state.features, + eligibility = eligibility, + onSetMode = viewModel::setMode, + onSetPolicy = viewModel::setGuestPolicy, + // The lobby is never playing; the player's panel owns this row. + onStopPlayback = { optionsOpen = false }, + onEnd = { + optionsOpen = false + confirmEnd = true + }, + onLeave = { + optionsOpen = false + if (isHost) { + confirmLeave = true + } else { + viewModel.leave() + onLeft() + } + }, + onDismiss = { optionsOpen = false }, + ) + } + + if (inviteOpen && room != null) { + TvWatchPartyInvitePage( + code = room.code, + inviteUrl = inviteUrl, + backdropUrl = staged?.backdropUrl, + backdropThumbhash = staged?.backdropThumbhash, + onDismiss = { inviteOpen = false }, + ) + } + + val menuSuggestion = suggestions.firstOrNull { it.id == menuSuggestionId } + LaunchedEffect(menuSuggestionId, menuSuggestion == null) { + // The suggestion went away (removed, promoted) while its menu was open. + if (menuSuggestionId != null && menuSuggestion == null) menuSuggestionId = null + } + if (menuSuggestion != null && room != null) { + TvPartyMenuOverlay( + title = menuSuggestion.title, + onDismiss = { menuSuggestionId = null }, + width = 380.dp, + ) { + suggestionMenuRows(room, menuSuggestion, state.features).forEach { row -> + TvPartyOptionRow( + title = row.title, + destructive = row == SuggestionMenuRow.Remove, + state = TvControlState.transient(!eligibility.busy), + onClick = { + menuSuggestionId = null + when (row) { + SuggestionMenuRow.StartThisOne -> viewModel.promote(menuSuggestion) + SuggestionMenuRow.Queue -> queue(viewModel, menuSuggestion) + SuggestionMenuRow.Remove -> viewModel.remove(menuSuggestion) + } + }, + ) + } + } + } + + if (confirmEnd) { + TvWatchPartyConfirmDialog( + title = "End this party for everyone?", + message = "Everyone leaves the party.", + confirmLabel = "End party", + destructive = true, + onConfirm = { + confirmEnd = false + viewModel.endForEveryone() + onLeft() + }, + onDismiss = { confirmEnd = false }, + ) + } + if (confirmLeave) { + TvWatchPartyConfirmDialog( + title = "Leave the party?", + message = TV_WATCH_PARTY_HOST_LEAVE_NOTE, + confirmLabel = "Leave party", + destructive = true, + onConfirm = { + confirmLeave = false + viewModel.leave() + onLeft() + }, + onDismiss = { confirmLeave = false }, + ) + } +} + +private const val PICK_SELECT = "select" +private const val PICK_SUGGEST = "suggest" +internal const val PARTY_KEY_BACK = "back" + +private fun queue(viewModel: WatchPartyLobbyViewModel, suggestion: Suggestion) { + TvWatchPartyPreviews.put( + WatchPartyItem( + contentId = suggestion.contentId, + contentType = suggestion.contentType, + title = suggestion.title, + subtitle = suggestion.subtitle.ifBlank { null }, + posterUrl = suggestion.posterUrl.ifBlank { null }, + ), + ) + viewModel.queue(suggestion) +} + +private enum class SuggestionMenuRow(val title: String) { + StartThisOne("Start this one"), + Queue("Queue"), + Remove("Remove suggestion"), +} + +/** A suggestion card's context actions for this member. */ +private fun suggestionMenuRows(room: RoomSnapshot, suggestion: Suggestion, features: WatchPartyFeatures?): List { + val manages = room.selfRole == MemberRole.Host && room.selfCanManageRoom + return buildList { + if (manages && room.selectionMode == RoomSelectionMode.Vote && features?.voteHostOverride == true) { + add(SuggestionMenuRow.StartThisOne) + } + // Queueing stages the pick, which only servers with staged selection accept. + if (manages && room.selectionMode == RoomSelectionMode.HostPick && features?.stagedSelection == true && + suggestion.contentId != room.selectedContentId + ) { + add(SuggestionMenuRow.Queue) + } + if (canRemoveSuggestion(room, suggestion)) add(SuggestionMenuRow.Remove) + } +} + +/** The actionable suggestion nearest [index] in the current list, preferring the one now in its place. */ +private fun nearestActionable(ids: List, actionable: List, index: Int): String? { + if (actionable.isEmpty()) return null + val set = actionable.toSet() + for (offset in 0..ids.size) { + ids.getOrNull(index + offset)?.takeIf { it in set }?.let { return it } + ids.getOrNull(index - offset)?.takeIf { it in set }?.let { return it } + } + return null +} + +/** "Seen by Ana · Part way: Ben" for the staged item, from member state (omitted members are unknown, not unplayed). */ +private fun memberStateLine(room: RoomSnapshot?, state: ItemMemberState?): String? { + if (room == null || state == null) return null + fun nameOf(userId: String, profileId: String): String? = room.members + .firstOrNull { it.userId == userId && it.profileId == profileId } + ?.let(::tvWatchPartyMemberName) + val seen = state.members.filter { it.state == "watched" }.mapNotNull { nameOf(it.userId, it.profileId) } + val partWay = state.members + .filter { it.state != "watched" && (it.positionSeconds ?: 0.0) > 0.0 } + .mapNotNull { nameOf(it.userId, it.profileId) } + val parts = buildList { + if (seen.isNotEmpty()) add("Seen by ${tvWatchPartyNameList(seen)}") + if (partWay.isNotEmpty()) add("Part way: ${tvWatchPartyNameList(partWay)}") + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") +} + +/** Before the first snapshot: say so, and keep a focus owner so Back and Select work. */ +@Composable +private fun ConnectingRoom(focus: TvPartyFocus, onBack: () -> Unit) { + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop(url = null) + Column( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(horizontal = TvPartyMetrics.pageInsetX), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TvPartyEyebrow("Watch Party") + Text(text = "Connecting to the party…", fontSize = 30.sp, fontWeight = FontWeight.Bold, color = SiloOnSurface) + TvPartyButton( + label = "Back", + kind = TvPartyButtonKind.Secondary, + onClick = onBack, + modifier = Modifier.partyFocus(focus, PARTY_KEY_BACK), + ) + } + } +} + +@Composable +private fun SuggestionRetryRow(title: String, onRetry: () -> Unit, onDismiss: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Couldn't confirm your suggestion of $title.", + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText, + modifier = Modifier.weight(1f, fill = false), + ) + TvPartyButton(label = "Try again", kind = TvPartyButtonKind.Secondary, onClick = onRetry, height = 32.dp, fontSize = 14.sp) + TvPartyButton(label = "Dismiss", kind = TvPartyButtonKind.Secondary, onClick = onDismiss, height = 32.dp, fontSize = 14.sp) + } +} + +private const val LOBBY_FOCUS_RESCUE_DELAY_MS = 150L +private const val HOST_STOPPED_NOTICE_MS = 6_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt new file mode 100644 index 0000000000..dfc9ae7322 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt @@ -0,0 +1,587 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.watchtogether.ItemMemberState +import org.siloserver.silo.model.watchtogether.MemberWatchState +import org.siloserver.silo.model.watchtogether.PickerEntry +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.tv.ui.components.TvMediaRow +import org.siloserver.silo.tv.ui.components.TvPoster +import org.siloserver.silo.tv.ui.components.TvTextInputDialog +import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus +import org.siloserver.silo.tv.ui.focus.TvRestoreFocusOnModalDismiss +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.focus.tvModalFocusBoundary +import org.siloserver.silo.tv.ui.theme.DarkBackground +import org.siloserver.silo.tv.ui.theme.FocusedContainer +import org.siloserver.silo.tv.ui.theme.FocusedContent +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.theme.SiloSecondaryText +import org.siloserver.silo.tv.ui.theme.TvSkyline +import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.viewmodel.WatchPartyPickerViewModel + +/** One Home-style shelf of the picker. */ +private data class TvPickerShelf( + val key: String, + val title: String, + val entries: List>, + val showProgress: Boolean = false, +) { + val items: List = entries.map { (item, entry) -> item.toSectionItem(entry) } +} + +/** What the confirmation page shows, and the item it stages or suggests. */ +private data class TvPickerChoice( + val item: WatchPartyItem, + val title: String, + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String?, + val backdropUrl: String?, + val backdropThumbhash: String?, + val overview: String?, + val facts: List, +) + +/** + * Choose or suggest a title, as the tvOS picker does it: a full-screen page of + * Home-style shelves (Continue Together, On Your Watchlists, or search + * results) under a slim strip with the page name, Search, and Close. Picking a + * movie or episode opens a confirmation page (artwork, overview, "Who's seen + * it") whose primary action stages or suggests it. A series with a party + * next-up episode confirms that episode; any other series opens its normal + * page, where the party action applies. + * + * Focus: the first card of the first shelf, else Search. If the focused card + * disappears (a newer search), focus returns to Search instead of dropping. + */ +@Composable +internal fun TvWatchPartyPicker( + /** Host Picks host: the pick is staged. Otherwise it is suggested. */ + stages: Boolean, + members: List, + /** Who in the room has watched a title; null when the server can't say. */ + memberState: (suspend (String) -> ItemMemberState?)?, + onPick: (WatchPartyItem) -> Unit, + onOpenSeries: (contentId: String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchPartyPickerViewModel = koinViewModel(key = "tv-watch-party-picker"), +) { + val state by viewModel.state.collectAsState() + var searchOpen by remember { mutableStateOf(false) } + var choice by remember { mutableStateOf(null) } + var choiceShelf by remember { mutableStateOf(null) } + var searchFocused by remember { mutableStateOf(false) } + var pickerHasFocus by remember { mutableStateOf(false) } + val searchFocus = remember { FocusRequester() } + val firstCardFocus = remember { FocusRequester() } + val shelfFocus = remember { mutableStateMapOf() } + val shelfRequesters = remember { mutableMapOf() } + + LaunchedEffect(viewModel) { viewModel.loadRows() } + + val searching = state.query.isNotBlank() + val shelves: List = if (searching) { + listOf(TvPickerShelf("results", "Results for “${state.query.trim()}”", state.results.map { it to null })) + } else { + listOf( + TvPickerShelf( + "together", + "Continue Together", + state.rows?.continueTogether.orEmpty().map { it.item to it }, + showProgress = true, + ), + TvPickerShelf("watchlist", "On Your Watchlists", state.rows?.watchlistUnion.orEmpty().map { it.item to it }), + ) + }.filter { it.entries.isNotEmpty() } + val shelfKeys = shelves.joinToString { it.key } + + val open: (TvPickerShelf, String) -> Unit = { shelf, contentId -> + val (item, entry) = shelf.entries.first { it.first.contentId == contentId } + val nextUp = entry?.nextUp + when { + item.type == "series" && nextUp != null -> { + choiceShelf = shelf.key + choice = TvPickerChoice( + item = WatchPartyItem( + contentId = nextUp.contentId, + contentType = "episode", + title = nextUp.title.ifBlank { "Episode ${nextUp.episodeNumber}" }, + subtitle = "${item.title} · S${nextUp.seasonNumber}E${nextUp.episodeNumber}", + posterUrl = item.posterUrl, + ), + title = item.title, + subtitle = "S${nextUp.seasonNumber} · E${nextUp.episodeNumber}" + + nextUp.title.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty(), + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = null, + facts = emptyList(), + ) + } + item.type == "series" -> onOpenSeries(item.contentId) + else -> { + choiceShelf = shelf.key + choice = TvPickerChoice( + item = WatchPartyItem( + contentId = item.contentId, + contentType = item.type, + title = item.title, + subtitle = item.year.takeIf { it > 0 }?.toString(), + posterUrl = item.posterUrl, + ), + title = item.title, + subtitle = null, + posterUrl = item.posterUrl, + posterThumbhash = item.posterThumbhash, + backdropUrl = item.backdropUrl, + backdropThumbhash = item.backdropThumbhash, + overview = item.overview?.takeIf { it.isNotBlank() }, + facts = listOfNotNull( + item.year.takeIf { it > 0 }?.toString(), + item.runtime?.takeIf { it > 0 }?.let(::tvWatchPartyRuntime), + item.contentRating?.takeIf { it.isNotBlank() }, + item.genres.firstOrNull(), + ), + ) + } + } + } + + // A card that vanished under focus (a newer search, reloaded rows) would + // otherwise leave the picker with no focus owner. + LaunchedEffect(pickerHasFocus, searchOpen, choice != null) { + if (pickerHasFocus || searchOpen || choice != null) return@LaunchedEffect + delay(PICKER_FOCUS_RESCUE_DELAY_MS) + requestFocusUntilObserved( + maxAttempts = 20, + awaitAttempt = { delay(60) }, + requestFocus = searchFocus::requestFocus, + isFocused = { pickerHasFocus }, + ) + } + // Shelves arrive after the page opens: the first card claims focus from + // Search, as Home's first row does, unless the viewer already moved on. + LaunchedEffect(shelfKeys) { + if (shelves.isEmpty() || !searchFocused || searching) return@LaunchedEffect + requestFocusUntilObserved( + maxAttempts = 10, + awaitAttempt = { delay(60) }, + requestFocus = firstCardFocus::requestFocus, + isFocused = { shelfFocus[shelves.first().key] == true }, + ) + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(DarkBackground) + .onFocusChanged { pickerHasFocus = it.hasFocus } + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(if (shelves.isEmpty()) searchFocus else firstCardFocus)), + ) { + // ---- Strip: page name, search, close ---------------------------- + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = TvSkyline.safeAreaX, end = TvSkyline.safeAreaX, top = TvSkyline.barTopInset) + .height(TvSkyline.barHeight), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TvSkyline.barTrailingSpacing), + ) { + Text( + text = if (stages) "Choose a title" else "Suggest a title", + fontSize = TvSkyline.tabLabelSize, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + Spacer(modifier = Modifier.weight(1f)) + if (searching) { + TvPartyButton( + label = "Clear search", + kind = TvPartyButtonKind.Secondary, + onClick = { viewModel.onQuery("") }, + height = TvSkyline.barIconSize, + fontSize = 14.sp, + ) + } + ChromeIcon( + icon = Icons.Filled.Search, + label = "Search", + onClick = { searchOpen = true }, + modifier = Modifier + .focusRequester(searchFocus) + .onFocusChanged { searchFocused = it.isFocused }, + ) + ChromeIcon(icon = Icons.Filled.Close, label = "Close", onClick = onDismiss) + } + + // ---- Shelves ----------------------------------------------------- + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp), + ) { + val status = when { + searching && state.error != null && shelves.isEmpty() -> state.error + searching && state.searching && shelves.isEmpty() -> "Searching…" + searching && shelves.isEmpty() -> "No titles match “${state.query.trim()}”." + !searching && state.rowsLoading && shelves.isEmpty() -> "Loading…" + else -> null + } + if (status != null) { + PickerStatus(status) + } else if (!searching && shelves.isEmpty()) { + Column( + modifier = Modifier.padding(horizontal = TvSkyline.safeAreaX, vertical = 40.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(text = "Nothing to pick from yet", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + Text( + text = "Search for a title, or add something to a watchlist.", + fontSize = TvPartyMetrics.body, + color = SiloSecondaryText, + ) + state.error?.let { message -> + TvPartyBanner(message = message, warning = true) + TvPartyButton( + label = "Try again", + kind = TvPartyButtonKind.Secondary, + onClick = viewModel::loadRows, + ) + } + } + } + shelves.forEachIndexed { index, shelf -> + TvMediaRow( + title = shelf.title, + items = shelf.items, + onItemClick = { contentId -> open(shelf, contentId) }, + showProgress = shelf.showProgress, + startPadding = TvSkyline.safeAreaX, + endPadding = TvSkyline.safeAreaX, + rowTopPadding = 10.dp, + rowBottomPadding = 16.dp, + firstItemFocusRequester = if (index == 0) firstCardFocus else null, + rowContainerFocusRequester = shelfRequesters.getOrPut(shelf.key) { FocusRequester() }, + onRowFocusChanged = { shelfFocus[shelf.key] = it }, + ) + } + } + } + } + + if (searchOpen) { + TvTextInputDialog( + title = "Search titles", + label = "Title", + confirmLabel = "Search", + initialValue = state.query, + allowBlank = true, + onConfirm = { text -> + viewModel.onQuery(text) + searchOpen = false + }, + onDismiss = { searchOpen = false }, + ) + } + TvRestoreFocusOnModalDismiss( + visible = searchOpen, + opener = searchFocus, + isOpenerFocused = { searchFocused }, + ) + + choice?.let { current -> + TvPickerConfirmation( + choice = current, + stages = stages, + members = members, + memberState = memberState, + onConfirm = { + choice = null + onPick(current.item) + }, + onDismiss = { choice = null }, + ) + } + // Back from the confirmation returns to the shelf it came from; the row's + // focus restorer puts focus back on the card. + TvRestoreFocusOnModalDismiss( + visible = choice != null, + opener = choiceShelf?.let { shelfRequesters[it] }, + isOpenerFocused = { choiceShelf?.let { shelfFocus[it] } == true }, + ) +} + +/** + * The confirmation page: the title's artwork and facts, what everyone in the + * room has seen, and one action. Back returns to the shelves. + */ +@Composable +private fun TvPickerConfirmation( + choice: TvPickerChoice, + stages: Boolean, + members: List, + memberState: (suspend (String) -> ItemMemberState?)?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val confirmFocus = remember { FocusRequester() } + val contentId = choice.item.contentId + val seen by produceState(initialValue = MemberStateLoad.Loading, contentId) { + value = MemberStateLoad.Loading + value = memberState?.invoke(contentId)?.let(MemberStateLoad::Loaded) ?: MemberStateLoad.Unavailable + } + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop( + url = choice.backdropUrl ?: choice.posterUrl, + thumbhash = if (choice.backdropUrl != null) choice.backdropThumbhash else choice.posterThumbhash, + isPoster = choice.backdropUrl == null, + ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = TvPartyMetrics.pageInsetX, vertical = 44.dp) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(confirmFocus)), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + TvPoster( + imageUrl = choice.posterUrl, + contentDescription = null, + modifier = Modifier.size(width = 110.dp, height = 165.dp), + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + TvPartyEyebrow(if (stages) "Watch together" else "Suggest to the party") + Text( + text = choice.title, + fontSize = 44.sp, + lineHeight = 48.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp, + color = SiloOnSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val facts = listOfNotNull(choice.subtitle) + choice.facts + if (facts.isNotEmpty()) { + Text(text = facts.joinToString(" · "), fontSize = TvPartyMetrics.body, color = SiloSecondaryText) + } + } + } + choice.overview?.let { overview -> + Text( + text = overview, + fontSize = TvPartyMetrics.body, + lineHeight = 21.sp, + color = SiloSecondaryText, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + TvPartyButton( + label = if (stages) "Choose for the party" else "Add suggestion", + icon = if (stages) Icons.Filled.Check else Icons.Filled.Add, + onClick = onConfirm, + modifier = Modifier.focusRequester(confirmFocus), + ) + when (val load = seen) { + MemberStateLoad.Unavailable -> Unit + MemberStateLoad.Loading -> if (memberState != null) { + WhoSeenSection { + Text(text = "Checking viewing history…", fontSize = TvPartyMetrics.caption, color = SiloSecondaryText) + } + } + is MemberStateLoad.Loaded -> WhoSeenSection { + members.forEach { member -> + val watch = load.state.members.firstOrNull { + it.userId == member.userId && it.profileId == member.profileId + } + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = tvWatchPartyMemberName(member), + fontSize = TvPartyMetrics.body, + color = SiloOnSurface, + modifier = Modifier.weight(1f), + ) + Text(text = tvWatchPartyHistoryLabel(watch), fontSize = TvPartyMetrics.body, color = SiloSecondaryText) + } + } + } + } + } + } + } +} + +private sealed interface MemberStateLoad { + data object Loading : MemberStateLoad + data object Unavailable : MemberStateLoad + data class Loaded(val state: ItemMemberState) : MemberStateLoad +} + +@Composable +private fun WhoSeenSection(content: @Composable () -> Unit) { + Column(modifier = Modifier.width(420.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + TvPartyEyebrow("Who's seen it") + content() + } +} + +/** "Watched", "In progress · Watchlist", "No viewing history". */ +internal fun tvWatchPartyHistoryLabel(state: MemberWatchState?): String { + state ?: return "No viewing history" + val label = when { + state.state in setOf("watched", "completed", "played") -> "Watched" + state.state in setOf("in_progress", "inprogress") || (state.positionSeconds ?: 0.0) > 0.0 -> "In progress" + else -> "Not watched" + } + return if (state.onWatchlist) "$label · Watchlist" else label +} + +/** Capsule icon in the strip, the same geometry as the app's top bar. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ChromeIcon(icon: ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = FocusedContainer, + pressedContainerColor = FocusedContainer, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + modifier = modifier.size(TvSkyline.barIconSize), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (isFocused) FocusedContent else Color.White.copy(alpha = 0.62f), + modifier = Modifier.size(15.dp), + ) + } + } +} + +@Composable +private fun PickerStatus(text: String) { + Text( + text = text, + fontSize = TvPartyMetrics.body, + color = SiloSecondaryText, + modifier = Modifier.padding(horizontal = TvSkyline.safeAreaX, vertical = 24.dp), + ) +} + +private fun BrowseItem.toSectionItem(entry: PickerEntry?): SectionItem = SectionItem( + contentId = contentId, + type = type, + title = title, + year = year, + genres = genres, + contentRating = contentRating, + runtime = runtime, + overview = overview, + posterUrl = posterUrl, + posterThumbhash = posterThumbhash, + backdropUrl = backdropUrl, + backdropThumbhash = backdropThumbhash, + userState = userState, + overlaySummary = overlaySummary, + positionSeconds = entry?.members?.mapNotNull { it.positionSeconds }?.maxOrNull(), + durationSeconds = entry?.members?.mapNotNull { it.durationSeconds }?.maxOrNull(), +) + +private const val PICKER_FOCUS_RESCUE_DELAY_MS = 150L diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt new file mode 100644 index 0000000000..c1e2f52d2a --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt @@ -0,0 +1,961 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +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.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Icon +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import org.siloserver.silo.model.watchtogether.GuestControlPolicy +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.repository.WatchTogetherConnectionState +import org.siloserver.silo.tv.ui.components.TvPoster +import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus +import org.siloserver.silo.tv.ui.focus.TvControlState +import org.siloserver.silo.tv.ui.focus.tvControlSemantics +import org.siloserver.silo.tv.ui.focus.tvModalFocusBoundary +import org.siloserver.silo.tv.ui.screens.auth.QrCodePanel +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.theme.SiloSecondaryText +import org.siloserver.silo.watchtogether.WatchPartyEligibility +import org.siloserver.silo.watchtogether.WatchPartyFeatures +import org.siloserver.silo.watchtogether.WatchPartyLobbyHero +import org.siloserver.silo.watchtogether.WatchPartyPrimaryAction +import org.siloserver.silo.watchtogether.WatchPartySecondaryAction +import org.siloserver.silo.watchtogether.canRemoveSuggestion +import org.siloserver.silo.watchtogether.roomVoteWinner +import org.siloserver.silo.watchtogether.watchPartyConnectionLabel +import org.siloserver.silo.watchtogether.watchPartyLobbyBanner +import org.siloserver.silo.watchtogether.watchPartyLobbyHero +import org.siloserver.silo.watchtogether.watchPartyLobbyHint +import org.siloserver.silo.watchtogether.watchPartyPrimaryAction +import org.siloserver.silo.watchtogether.watchPartySecondaryAction + +/** What a room screen can do. The lobby and the in-player panel each supply their own. */ +internal class TvPartyRoomActions( + val onInvite: () -> Unit, + val onOptions: () -> Unit, + val onChooseTitle: () -> Unit = {}, + val onSuggest: () -> Unit = {}, + /** Host-pick: start the staged title. Voting: start the leader. */ + val onStart: () -> Unit = {}, + val onReady: (Boolean) -> Unit = {}, + val onReturnToPlayback: () -> Unit = {}, + val onVote: (Suggestion) -> Unit = {}, + val onQueue: (Suggestion) -> Unit = {}, + /** "Start this one", "Remove suggestion": long-press, Menu, or Select on a card with no other action. */ + val onSuggestionMenu: (Suggestion) -> Unit = {}, +) + +internal const val PARTY_KEY_PRIMARY = "primary" +internal const val PARTY_KEY_SECONDARY = "secondary" +internal const val PARTY_KEY_OPTIONS = "options" +internal const val PARTY_KEY_CODE = "code" +internal const val PARTY_KEY_INVITE_SEAT = "invite-seat" +internal const val PARTY_KEY_SUGGEST_CARD = "suggest-card" + +/** The focus key of a suggestion card. */ +internal fun tvPartySuggestionKey(id: String) = "s:$id" + +/** The control that owns focus on entry and when focus is lost: the primary action, else the next best. */ +internal fun tvPartyAnchorKey(room: RoomSnapshot, features: WatchPartyFeatures?, suggestions: List): String { + val primary = watchPartyPrimaryAction(room, features, suggestions) + return when { + primary != WatchPartyPrimaryAction.None -> PARTY_KEY_PRIMARY + watchPartySecondaryAction(room, primary) != null -> PARTY_KEY_SECONDARY + else -> PARTY_KEY_OPTIONS + } +} + +/** Whether a suggestion card is shown and has something to do for this member. */ +internal fun tvPartySuggestionFocusable(room: RoomSnapshot, suggestion: Suggestion): Boolean = when { + room.phase != RoomPhase.Lobby -> false + room.selectionMode == RoomSelectionMode.Vote -> true + room.selectionMode == RoomSelectionMode.HostPick -> + room.isManagedBySelf || canRemoveSuggestion(room, suggestion) + else -> false +} + +private val RoomSnapshot.isManagedBySelf: Boolean + get() = selfRole == MemberRole.Host && selfCanManageRoom + +/** + * The room, laid out as the tvOS lobby: the staged film fills the screen, the + * hero names it, members sit as seats along the bottom, and the one action + * that matters now is the white pill. Voting rooms put the ballot where the + * overview would be. Play/pause on the remote starts when Start is showing. + * + * Which buttons exist comes from the shared lobby policy, never from a + * request in flight: busy gating dims a control but keeps it (and focus) put. + */ +@Composable +internal fun TvWatchPartyRoomView( + room: RoomSnapshot, + suggestions: List, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + personalVotesKnown: Boolean, + connection: WatchTogetherConnectionState, + staged: TvStagedPreview?, + focus: TvPartyFocus, + actions: TvPartyRoomActions, + modifier: Modifier = Modifier, + /** A neutral notice (reconnecting, the host stopped playback) when no warning outranks it. */ + notice: String? = null, + /** "Seen by Ana" for the staged title. */ + memberStateLine: String? = null, + /** Extra content under the banner (the suggestion retry row). */ + footer: (@Composable () -> Unit)? = null, +) { + val connected = connection.writable + val reconnecting = !connected && connection.epoch != 0L + val (tone, connectionText) = watchPartyConnectionLabel(room, connected, reconnecting) + val primary = watchPartyPrimaryAction(room, features, suggestions) + val secondary = watchPartySecondaryAction(room, primary) + val hero = watchPartyLobbyHero(room, suggestions) + val hint = watchPartyLobbyHint(room, suggestions) + val voting = room.selectionMode == RoomSelectionMode.Vote && room.phase == RoomPhase.Lobby + val winner = if (room.selectionMode == RoomSelectionMode.Vote) roomVoteWinner(suggestions) else null + val warning = watchPartyLobbyBanner(room, selectionUnavailable = false, reconnecting = false) + val startState = TvControlState.transient( + connected && if (room.selectionMode == RoomSelectionMode.Vote) eligibility.canPromote else eligibility.canStart, + ) + + // The staged film's backdrop, else its poster blurred, else (voting) the leader's poster. + val leaderPoster = if (voting) (winner ?: suggestions.firstOrNull())?.posterUrl?.ifBlank { null } else null + val backdropUrl = staged?.backdropUrl ?: staged?.posterUrl ?: leaderPoster + val backdropIsPoster = staged?.backdropUrl == null + + Box( + modifier = modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + // Same predicate as the Start button, so the remote cannot start what the button refuses. + val playKey = event.key == Key.MediaPlayPause || event.key == Key.MediaPlay + if (playKey && event.type == KeyEventType.KeyDown && primary is WatchPartyPrimaryAction.Start) { + startState.perform(actions.onStart) + true + } else { + false + } + }, + ) { + TvPartyBackdrop( + url = backdropUrl, + thumbhash = if (backdropIsPoster) staged?.posterThumbhash else staged?.backdropThumbhash, + isPoster = backdropIsPoster, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = TvPartyMetrics.pageInsetX), + ) { + // ---- Header ------------------------------------------------------ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = if (voting) 30.dp else TvPartyMetrics.headerTop), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Watch Party", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = SiloOnSurface) + Spacer(modifier = Modifier.width(10.dp)) + TvPartyConnectionDot(tone = tone, text = connectionText) + Spacer(modifier = Modifier.weight(1f)) + TvPartyCodePill( + code = room.code, + onClick = actions.onInvite, + modifier = Modifier.partyFocus(focus, PARTY_KEY_CODE), + ) + Spacer(modifier = Modifier.width(11.dp)) + TvPartyOptionsButton( + onClick = actions.onOptions, + modifier = Modifier.partyFocus(focus, PARTY_KEY_OPTIONS), + ) + } + + // ---- Hero -------------------------------------------------------- + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clipToBounds(), + ) { + if (voting) { + Column( + modifier = Modifier.padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HeroText(room = room, hero = hero, staged = staged, titleSize = 34.sp, memberStateLine = null) + TvPartyBallot( + room = room, + suggestions = suggestions, + leaderId = winner?.id, + personalVotesKnown = personalVotesKnown, + voteState = TvControlState.transient(eligibility.canVote && connected), + suggestState = TvControlState.transient(eligibility.canSuggest), + focus = focus, + actions = actions, + ) + RoomBanner(warning = warning, notice = notice, footer = footer) + } + } else { + // Suggestions take the empty side of the screen so the hero + // keeps its height and the seats stay put. + Row( + modifier = Modifier.padding(top = 30.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column( + modifier = Modifier.width(380.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HeroText( + room = room, + hero = hero, + staged = staged, + titleSize = TvPartyMetrics.heroTitle, + memberStateLine = memberStateLine, + ) + staged?.overview?.takeIf { !room.selectedContentId.isNullOrBlank() }?.let { overview -> + Text( + text = overview, + fontSize = TvPartyMetrics.overview, + lineHeight = 20.sp, + color = SiloSecondaryText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + RoomBanner(warning = warning, notice = notice, footer = footer) + } + if (room.phase == RoomPhase.Lobby && room.selectionMode == RoomSelectionMode.HostPick && + suggestions.isNotEmpty() + ) { + Spacer(modifier = Modifier.weight(1f)) + TvPartyHostPickSuggestions( + room = room, + suggestions = suggestions, + queueState = TvControlState.transient(eligibility.canQueue && connected), + focus = focus, + actions = actions, + modifier = Modifier.widthIn(max = 410.dp), + ) + } + } + } + } + + // ---- Seats and actions ----------------------------------------- + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = if (voting) 26.dp else 36.dp), + verticalAlignment = Alignment.Bottom, + ) { + TvPartySeatsRow( + members = room.members, + phase = room.phase, + onInvite = actions.onInvite, + inviteModifier = Modifier.partyFocus(focus, PARTY_KEY_INVITE_SEAT), + seatSize = if (voting) 40.dp else TvPartyMetrics.seat, + ) + Spacer(modifier = Modifier.weight(1f).width(20.dp)) + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(10.dp)) { + TvPartyButtonRow { + SecondaryButton( + secondary = secondary, + primary = primary, + eligibility = eligibility, + actions = actions, + modifier = Modifier.partyFocus(focus, PARTY_KEY_SECONDARY), + ) + PrimaryButton( + primary = primary, + hasSelection = !room.selectedContentId.isNullOrBlank(), + startState = startState, + eligibility = eligibility, + connected = connected, + actions = actions, + modifier = Modifier.partyFocus(focus, PARTY_KEY_PRIMARY), + ) + } + if (hint != null) { + Text( + text = hint, + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText.copy(alpha = 0.7f), + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = 360.dp), + ) + } + } + } + } + } +} + +/** Staged titles longer than this step the hero type down a size. */ +private const val LONG_TITLE_CHARS = 16 + +@Composable +private fun RoomBanner(warning: String?, notice: String?, footer: (@Composable () -> Unit)?) { + when { + warning != null -> TvPartyBanner(message = warning, warning = true) + notice != null -> TvPartyBanner(message = notice) + } + footer?.invoke() +} + +/** Eyebrow, title, facts. In a voting lobby the question is the title. */ +@Composable +private fun HeroText( + room: RoomSnapshot, + hero: WatchPartyLobbyHero, + staged: TvStagedPreview?, + titleSize: TextUnit, + memberStateLine: String?, +) { + val selected = !room.selectedContentId.isNullOrBlank() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TvPartyEyebrow(hero.eyebrow) + val title = hero.title ?: staged?.title ?: "Loading title…" + val size = if (!hero.isVoteQuestion && hero.title == null && title.length > LONG_TITLE_CHARS) titleSize * 0.8f else titleSize + Text( + text = title, + fontSize = size, + lineHeight = size * 1.06f, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp, + color = if (staged == null && selected && hero.title == null) SiloSecondaryText else SiloOnSurface, + maxLines = if (hero.isVoteQuestion) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + if (selected && !hero.isVoteQuestion) { + if (staged != null) FactsLine(staged) + val chose = listOfNotNull(hero.supporting, memberStateLine).joinToString(" · ") + if (chose.isNotBlank()) { + Text( + text = chose, + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText.copy(alpha = 0.7f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + hero.supporting?.let { + Text(text = it, fontSize = TvPartyMetrics.body, lineHeight = 21.sp, color = SiloSecondaryText) + } + } + } +} + +/** "S1:E2 · 2021 · 2h 35m [4K] [HDR]". */ +@Composable +private fun FactsLine(staged: TvStagedPreview) { + val parts = listOfNotNull(staged.subtitle) + staged.facts + if (parts.isEmpty() && staged.chips.isEmpty()) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + parts.forEachIndexed { index, part -> + if (index > 0) Text(text = "·", fontSize = 14.sp, color = SiloSecondaryText.copy(alpha = 0.5f)) + Text(text = part, fontSize = 14.sp, color = SiloSecondaryText, maxLines = 1) + } + staged.chips.forEach { chip -> + Text( + text = chip, + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.SemiBold, + color = SiloSecondaryText, + modifier = Modifier + .border(1.dp, SiloSecondaryText.copy(alpha = 0.5f), RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + } +} + +@Composable +private fun PrimaryButton( + primary: WatchPartyPrimaryAction, + hasSelection: Boolean, + startState: TvControlState, + eligibility: WatchPartyEligibility, + connected: Boolean, + actions: TvPartyRoomActions, + modifier: Modifier, +) { + when (primary) { + WatchPartyPrimaryAction.ChooseTitle -> TvPartyButton( + label = if (hasSelection) "Change title" else "Choose a title", + onClick = actions.onChooseTitle, + state = TvControlState.transient(eligibility.canStage), + modifier = modifier, + ) + is WatchPartyPrimaryAction.Start -> TvPartyButton( + label = primary.title?.let { "Start $it" } ?: "Start for everyone", + icon = Icons.Filled.PlayArrow, + onClick = actions.onStart, + state = startState, + modifier = modifier.widthIn(max = 380.dp), + ) + WatchPartyPrimaryAction.WaitingForVotes -> TvPartyButton( + label = "Suggest a title", + icon = Icons.Filled.Add, + onClick = actions.onSuggest, + state = TvControlState.transient(eligibility.canSuggest), + modifier = modifier, + ) + is WatchPartyPrimaryAction.Ready -> TvPartyButton( + label = if (primary.isReady) "You're ready" else "I'm ready", + icon = if (primary.isReady) Icons.Filled.Check else Icons.Filled.CheckCircle, + kind = if (primary.isReady) TvPartyButtonKind.Outlined else TvPartyButtonKind.Primary, + trailing = if (primary.isReady) "select to undo" else null, + onClick = { actions.onReady(!primary.isReady) }, + state = TvControlState.transient(eligibility.canLobbyReady && connected), + modifier = modifier, + ) + WatchPartyPrimaryAction.ReturnToPlayback -> TvPartyButton( + label = "Return to playback", + icon = Icons.Filled.PlayArrow, + onClick = actions.onReturnToPlayback, + modifier = modifier, + ) + WatchPartyPrimaryAction.None -> Unit + } +} + +@Composable +private fun SecondaryButton( + secondary: WatchPartySecondaryAction?, + primary: WatchPartyPrimaryAction, + eligibility: WatchPartyEligibility, + actions: TvPartyRoomActions, + modifier: Modifier, +) { + when (secondary) { + WatchPartySecondaryAction.ChangeTitle -> TvPartyButton( + label = "Change title", + kind = TvPartyButtonKind.Secondary, + onClick = actions.onChooseTitle, + state = TvControlState.transient(eligibility.canStage), + modifier = modifier, + ) + WatchPartySecondaryAction.Suggest -> TvPartyButton( + label = if (primary is WatchPartyPrimaryAction.Start) "Suggest" else "Suggest a title", + icon = Icons.Filled.Add, + kind = TvPartyButtonKind.Secondary, + onClick = actions.onSuggest, + state = TvControlState.transient(eligibility.canSuggest), + modifier = modifier, + ) + null -> Unit + } +} + +// ---- Ballot (voting) --------------------------------------------------------- + +/** Candidates with their vote count as a badge; the leader's badge inverts. Suggest is the dashed card at the end. */ +@Composable +private fun TvPartyBallot( + room: RoomSnapshot, + suggestions: List, + leaderId: String?, + personalVotesKnown: Boolean, + voteState: TvControlState, + suggestState: TvControlState, + focus: TvPartyFocus, + actions: TvPartyRoomActions, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(vertical = 8.dp), + modifier = Modifier.padding(top = 2.dp), + ) { + items(suggestions, key = { it.id }) { suggestion -> + TvPartySuggestionCard( + suggestion = suggestion, + posterWidth = TvPartyMetrics.ballotPosterWidth, + titleSize = 15.sp, + // Personal votes are unknown until an HTTP read confirms them. + voted = personalVotesKnown && suggestion.votedByMe, + badge = { VoteBadge(count = suggestion.voteCount, isLeader = suggestion.id == leaderId) }, + state = voteState, + focusable = tvPartySuggestionFocusable(room, suggestion), + onClick = { actions.onVote(suggestion) }, + onMenu = { actions.onSuggestionMenu(suggestion) }, + modifier = Modifier.partyFocus(focus, tvPartySuggestionKey(suggestion.id)), + ) + } + item(key = PARTY_KEY_SUGGEST_CARD) { + TvPartySuggestCard( + posterWidth = TvPartyMetrics.ballotPosterWidth, + state = suggestState, + onClick = actions.onSuggest, + modifier = Modifier.partyFocus(focus, PARTY_KEY_SUGGEST_CARD), + ) + } + } +} + +@Composable +private fun VoteBadge(count: Int, isLeader: Boolean) { + val shape = RoundedCornerShape(100.dp) + Row( + modifier = Modifier + .background(if (isLeader) SiloOnSurface else Color.Black.copy(alpha = 0.7f), shape) + .border(1.dp, if (isLeader) Color.Transparent else PartyChromeBorder, shape) + .padding(horizontal = 7.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + val color = if (isLeader) Color.Black else SiloOnSurface + Text(text = "$count", fontSize = 16.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = color) + Text( + text = if (count == 1) "VOTE" else "VOTES", + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.8.sp, + color = color.copy(alpha = 0.6f), + ) + } +} + +// ---- Suggestions (host-pick) ----------------------------------------------- + +/** + * Guests' suggestions in a host-pick lobby. The host still decides, so Select + * queues a suggestion as the pick rather than starting it; a guest's own card + * stays focusable so they can remove it. Other guests' cards only display. + */ +@Composable +private fun TvPartyHostPickSuggestions( + room: RoomSnapshot, + suggestions: List, + queueState: TvControlState, + focus: TvPartyFocus, + actions: TvPartyRoomActions, + modifier: Modifier = Modifier, +) { + val manages = room.isManagedBySelf + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TvPartyEyebrow("Suggestions · ${suggestions.size}") + Spacer(modifier = Modifier.weight(1f).width(12.dp)) + Text( + text = if (manages) "Queue one to put it up next." else "The host decides what plays.", + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText, + maxLines = 1, + ) + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(suggestions, key = { it.id }) { suggestion -> + val queued = suggestion.contentId == room.selectedContentId + TvPartySuggestionCard( + suggestion = suggestion, + posterWidth = 76.dp, + titleSize = 14.sp, + voted = false, + badge = if (queued) { + { UpNextBadge() } + } else { + null + }, + state = if (manages && !queued) queueState else TvControlState.structural(true), + focusable = tvPartySuggestionFocusable(room, suggestion), + onClick = { + if (manages && !queued) actions.onQueue(suggestion) else actions.onSuggestionMenu(suggestion) + }, + onMenu = { actions.onSuggestionMenu(suggestion) }, + showSubtitle = false, + modifier = Modifier.partyFocus(focus, tvPartySuggestionKey(suggestion.id)), + ) + } + } + } +} + +@Composable +private fun UpNextBadge() { + Text( + text = "UP NEXT", + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = Color.Black, + modifier = Modifier + .background(SiloOnSurface, RoundedCornerShape(100.dp)) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) +} + +/** + * A poster card for a suggestion. Select runs [onClick]; long-press or the + * remote's Menu key runs [onMenu]. Focus lifts and outlines the poster. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvPartySuggestionCard( + suggestion: Suggestion, + posterWidth: Dp, + titleSize: TextUnit, + voted: Boolean, + badge: (@Composable () -> Unit)?, + state: TvControlState, + focusable: Boolean, + onClick: () -> Unit, + onMenu: () -> Unit, + modifier: Modifier = Modifier, + showSubtitle: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(6.dp) + val controlState = if (focusable) state else TvControlState.structural(false) + Column(modifier = Modifier.width(posterWidth), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + onClick = { controlState.perform(onClick) }, + onLongClick = if (focusable) onMenu else null, + enabled = controlState.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + pressedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, PartyChromeBorder), shape = shape), + focusedBorder = Border(BorderStroke(3.dp, SiloOnSurface), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier + .onPreviewKeyEvent { event -> + if (focusable && event.key == Key.Menu && event.type == KeyEventType.KeyUp) { + onMenu() + true + } else { + event.key == Key.Menu && focusable + } + } + .tvControlSemantics(controlState), + ) { + Box(modifier = Modifier.size(width = posterWidth, height = posterWidth * 1.5f)) { + TvPoster( + imageUrl = suggestion.posterUrl.ifBlank { null }, + contentDescription = suggestion.title, + cornerRadius = 6.dp, + modifier = Modifier.fillMaxSize(), + ) + if (badge != null) { + Box(modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) { badge() } + } + if (voted) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(22.dp) + .background(SiloOnSurface, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Check, contentDescription = "Your vote", tint = Color.Black, modifier = Modifier.size(14.dp)) + } + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = suggestion.title, + fontSize = titleSize, + fontWeight = FontWeight.SemiBold, + color = if (isFocused) SiloOnSurface else SiloOnSurface.copy(alpha = 0.92f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (showSubtitle) { + val line = suggestion.note.takeIf { it.isNotBlank() }?.let { "“$it”" } ?: suggestion.subtitle.ifBlank { " " } + Text(text = line, fontSize = TvPartyMetrics.caption, color = SiloSecondaryText, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +/** The dashed "+" card at the end of the ballot. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvPartySuggestCard( + posterWidth: Dp, + state: TvControlState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(6.dp) + Column(modifier = Modifier.width(posterWidth), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + onClick = { state.perform(onClick) }, + enabled = state.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.White.copy(alpha = 0.14f), + pressedContainerColor = Color.White.copy(alpha = 0.14f), + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + focusedBorder = Border(BorderStroke(2.dp, SiloOnSurface), shape = shape), + ), + modifier = modifier.tvControlSemantics(state), + ) { + Box( + modifier = Modifier.size(width = posterWidth, height = posterWidth * 1.5f), + contentAlignment = Alignment.Center, + ) { + if (!isFocused) { + Canvas(modifier = Modifier.fillMaxSize()) { + val stroke = 1.dp.toPx() + drawRoundRect( + color = SiloSecondaryText.copy(alpha = 0.5f), + topLeft = Offset(stroke / 2, stroke / 2), + size = Size(size.width - stroke, size.height - stroke), + cornerRadius = CornerRadius(6.dp.toPx()), + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(5.dp.toPx(), 4.dp.toPx()))), + ) + } + } + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Suggest a title", + tint = if (isFocused) SiloOnSurface else SiloSecondaryText, + modifier = Modifier.size(34.dp), + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(text = "Suggest a title", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = SiloSecondaryText, maxLines = 1) + Text(text = "Anyone can add one", fontSize = TvPartyMetrics.caption, color = SiloSecondaryText.copy(alpha = 0.7f), maxLines = 1) + } + } +} + +// ---- Options ----------------------------------------------------------------- + +/** + * Party options behind the "···" button, so settings never share a row with + * Start. Hosts get the mode, the playback policy, Return everyone to lobby + * while playing, End, and Leave; guests get Leave. Close has focus on open, + * and Back closes. Rows stay focusable while a change is in flight. + */ +@Composable +internal fun TvWatchPartyOptionsOverlay( + room: RoomSnapshot, + features: WatchPartyFeatures?, + eligibility: WatchPartyEligibility, + onSetMode: (RoomSelectionMode) -> Unit, + onSetPolicy: (GuestControlPolicy) -> Unit, + onStopPlayback: () -> Unit, + onEnd: () -> Unit, + onLeave: () -> Unit, + onDismiss: () -> Unit, +) { + TvPartyMenuOverlay(title = "Party options", onDismiss = onDismiss) { + if (room.isManagedBySelf) { + if (room.phase == RoomPhase.Lobby && features?.selectionModeSwitch == true) { + val vote = room.selectionMode == RoomSelectionMode.Vote + TvPartyOptionRow( + title = "Who chooses", + value = if (vote) "Everyone votes" else "Host chooses", + state = TvControlState.transient(eligibility.canSwitchMode), + onClick = { onSetMode(if (vote) RoomSelectionMode.HostPick else RoomSelectionMode.Vote) }, + ) + } + val guestsPlay = room.guestControlPolicy == GuestControlPolicy.GuestPlayPause + TvPartyOptionRow( + title = "Playback controls", + value = if (guestsPlay) "Guests can play and pause" else "Host only", + state = TvControlState.transient(eligibility.canSetPolicy), + onClick = { onSetPolicy(if (guestsPlay) GuestControlPolicy.HostOnly else GuestControlPolicy.GuestPlayPause) }, + ) + if (room.phase == RoomPhase.Playing && features?.stopPlayback == true) { + TvPartyOptionRow( + title = "Return everyone to lobby", + state = TvControlState.transient(eligibility.canStop), + onClick = onStopPlayback, + ) + } + TvPartyOptionRow( + title = "End party for everyone", + destructive = true, + state = TvControlState.transient(eligibility.canEnd), + onClick = onEnd, + ) + } + TvPartyOptionRow(title = "Leave party", destructive = true, onClick = onLeave) + } +} + +// ---- Invitation ---------------------------------------------------------------- + +/** + * The invitation: a large code, and for the host a QR of the invitation link + * for a phone. Nothing scrolls and Done is the only control, so the page + * always has a focus owner. Back dismisses. + */ +@Composable +internal fun TvWatchPartyInvitePage( + code: String, + inviteUrl: String?, + backdropUrl: String?, + backdropThumbhash: String?, + onDismiss: () -> Unit, +) { + val doneFocus = remember { FocusRequester() } + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box(modifier = Modifier.fillMaxSize()) { + TvPartyBackdrop(url = backdropUrl, thumbhash = backdropThumbhash) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 60.dp) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(doneFocus)), + horizontalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.widthIn(max = 450.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TvPartyEyebrow("Invite friends") + Text( + text = "Join with this code", + fontSize = 40.sp, + lineHeight = 46.sp, + fontWeight = FontWeight.Bold, + color = SiloOnSurface, + ) + Text( + text = "Anyone with a profile on this Silo server can enter it from Watch Party.", + fontSize = TvPartyMetrics.body, + lineHeight = 21.sp, + color = SiloSecondaryText, + ) + } + Text( + text = code, + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 8.sp, + color = SiloOnSurface, + maxLines = 1, + ) + if (inviteUrl != null) { + Text( + text = "Or scan to open the invitation on a phone.", + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText, + ) + } + TvPartyButton( + label = "Done", + kind = TvPartyButtonKind.Secondary, + onClick = onDismiss, + modifier = Modifier.focusRequester(doneFocus), + ) + } + if (inviteUrl != null) { + Box( + modifier = Modifier + .background(Color.White, RoundedCornerShape(8.dp)) + .padding(15.dp), + ) { + QrCodePanel(content = inviteUrl, size = 250.dp) + } + } + } + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt new file mode 100644 index 0000000000..5597752ae9 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt @@ -0,0 +1,136 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import org.koin.compose.koinInject +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.viewmodel.WatchPartyItem +import java.util.concurrent.ConcurrentHashMap + +/** + * Title previews handed from the detail page or the picker to the lobby, so + * the staged card can lay out once instead of waiting for a detail fetch. + * Process memory only; nothing here is a secret or survives the process. + */ +internal object TvWatchPartyPreviews { + private const val CAPACITY = 32 + private val items = ConcurrentHashMap() + + fun put(item: WatchPartyItem) { + if (items.size >= CAPACITY) items.clear() + items[item.contentId] = item + } + + fun get(contentId: String): WatchPartyItem? = items[contentId] +} + +/** What the room shows for the staged item. */ +internal data class TvStagedPreview( + val title: String, + /** An episode's series and number; null for a movie. */ + val subtitle: String?, + val posterUrl: String?, + val posterThumbhash: String? = null, + val backdropUrl: String? = null, + val backdropThumbhash: String? = null, + val overview: String? = null, + /** "2021", "2h 35m". */ + val facts: List = emptyList(), + /** The chosen version's quality: "4K", "HDR". */ + val chips: List = emptyList(), +) + +/** + * The staged item's hero content: a handed-over preview or a suggestion first, + * then the catalog detail (cached, else fetched) for the full title, artwork, + * overview, and the selected version's quality. + */ +@Composable +internal fun rememberTvStagedPreview( + contentId: String?, + fileId: Int?, + libraryId: Int?, + suggestions: List, + catalog: CatalogRepository = koinInject(), +): TvStagedPreview? { + val detail by produceState(initialValue = null, contentId, libraryId) { + value = null + val id = contentId?.takeIf { it.isNotBlank() } ?: return@produceState + value = catalog.getCachedItemDetail(id, libraryId) + ?: (catalog.getItemDetail(id, libraryId) as? ApiResult.Success)?.data + } + val id = contentId?.takeIf { it.isNotBlank() } ?: return null + val loaded = detail?.takeIf { it.contentId == id } + if (loaded != null) { + val version = loaded.versions.firstOrNull { it.fileId == fileId } ?: loaded.versions.singleOrNull() + return TvStagedPreview( + title = loaded.title, + subtitle = tvDetailSubtitle(loaded), + posterUrl = loaded.posterUrl, + posterThumbhash = loaded.posterThumbhash, + backdropUrl = loaded.backdropUrl, + backdropThumbhash = loaded.backdropThumbhash, + overview = loaded.overview?.takeIf { it.isNotBlank() }, + facts = listOfNotNull( + loaded.year.takeIf { it > 0 && loaded.type != "episode" }?.toString(), + loaded.runtime.takeIf { it > 0 }?.let(::tvWatchPartyRuntime), + ), + chips = listOfNotNull( + version?.resolution?.let(::tvWatchPartyResolutionChip), + "HDR".takeIf { version?.hdr == true }, + ), + ) + } + TvWatchPartyPreviews.get(id)?.let { item -> + return TvStagedPreview(item.title, item.subtitle, item.posterUrl) + } + suggestions.firstOrNull { it.contentId == id }?.let { suggestion -> + return TvStagedPreview( + title = suggestion.title, + subtitle = suggestion.subtitle.ifBlank { null }, + posterUrl = suggestion.posterUrl.ifBlank { null }, + ) + } + return TvStagedPreview(title = "Loading title…", subtitle = null, posterUrl = null) +} + +private fun tvDetailSubtitle(detail: ItemDetail): String? = when (detail.type) { + "episode" -> listOfNotNull( + detail.seriesTitle?.takeIf { it.isNotBlank() }, + detail.seasonNumber?.let { season -> detail.episodeNumber?.let { "S$season:E$it" } }, + ).joinToString(" · ").ifBlank { null } + else -> null +} + +/** "2h 35m", "48m". */ +internal fun tvWatchPartyRuntime(minutes: Int): String = + if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" + +private fun tvWatchPartyResolutionChip(resolution: String): String? = when (resolution.trim().lowercase()) { + "" -> null + "2160p", "4k", "uhd" -> "4K" + else -> resolution.trim().uppercase() +} + +/** A detail page's item as the party sees it. */ +internal fun tvWatchPartyItem( + contentId: String, + contentType: String, + title: String, + subtitle: String?, + posterUrl: String?, + fileId: Int?, + libraryId: Int?, +): WatchPartyItem = WatchPartyItem( + contentId = contentId, + contentType = contentType, + title = title, + subtitle = subtitle?.takeIf { it.isNotBlank() }, + posterUrl = posterUrl?.takeIf { it.isNotBlank() }, + fileId = fileId, + libraryId = libraryId, +) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt new file mode 100644 index 0000000000..52e3563f05 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt @@ -0,0 +1,837 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +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.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Icon +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus +import org.siloserver.silo.tv.ui.focus.TvControlState +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.focus.tvControlSemantics +import org.siloserver.silo.tv.ui.focus.tvModalFocusBoundary +import org.siloserver.silo.tv.ui.theme.DarkBackground +import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated +import org.siloserver.silo.tv.ui.theme.FocusedContainer +import org.siloserver.silo.tv.ui.theme.FocusedContent +import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.theme.SiloSecondaryText +import org.siloserver.silo.watchtogether.WatchPartyConnectionTone +import org.siloserver.silo.watchtogether.watchPartyAvatarIndex +import org.siloserver.silo.watchtogether.WatchPartySeatState +import org.siloserver.silo.watchtogether.watchPartyPresenceSummary +import org.siloserver.silo.watchtogether.watchPartySeatOrder +import org.siloserver.silo.watchtogether.watchPartySeatState +import org.siloserver.silo.watchtogether.watchPartySeatStatus + +/* + * The Watch Party look, ported from the tvOS clients (`WatchPartyComponents` + * in silo-apple). tvOS metrics are pixels at 1920×1080; this app lays out a + * 960×540dp canvas and scales text by 0.86, so lengths are px / 2 and text is + * px / 2 / 0.86, never below the TV app's 14sp reading floor (eyebrows, + * captions, and badges land on the floor rather than at tvOS's smaller size). + */ + +internal object TvPartyMetrics { + /** tvOS page inset (88) plus the system's horizontal safe area (80). */ + val pageInsetX = 84.dp + val headerTop = 58.dp + val seat = 64.dp + val seatGap = 18.dp + val heroTitle = 50.sp + val body = 15.sp + val overview = 15.sp + val caption = 14.sp + val eyebrow = 14.sp + val code = 17.sp + val ballotPosterWidth = 104.dp +} + +/** The TV app's smallest readable text size. */ +private const val MIN_SP = 14f + +internal val PartyAmber = Color(0xFFF5A524) +internal val PartyAmberText = Color(0xFFF5C563) +internal val PartyEmerald = Color(0xFF34D399) +internal val PartyRose = Color(0xFFFB7185) +internal val PartyDestructive = Color(0xFFF2667D) +internal val PartyDestructiveFocused = Color(0xFFC8243B) +internal val PartyChromeFill = Color.White.copy(alpha = 0.10f) +internal val PartyChromeBorder = Color.White.copy(alpha = 0.14f) + +/** + * Focus bookkeeping for a room screen: one requester per named control, and + * which one holds focus now. Modals record [focusedKey] when they open and + * hand focus back to it when they close. + */ +@Stable +internal class TvPartyFocus { + private val requesters = mutableMapOf() + var focusedKey by mutableStateOf(null) + private set + + fun requester(key: String): FocusRequester = requesters.getOrPut(key) { FocusRequester() } + + fun isFocused(key: String?): Boolean = key != null && focusedKey == key + + fun onFocus(key: String, focused: Boolean) { + if (focused) focusedKey = key else if (focusedKey == key) focusedKey = null + } +} + +@Composable +internal fun rememberTvPartyFocus(): TvPartyFocus = remember { TvPartyFocus() } + +/** Attach [key]'s requester and report its focus to [focus]. */ +internal fun Modifier.partyFocus(focus: TvPartyFocus, key: String): Modifier = this + .focusRequester(focus.requester(key)) + .onFocusChanged { focus.onFocus(key, it.isFocused) } + +// ---- Type ------------------------------------------------------------------ + +@Composable +internal fun TvPartyEyebrow(text: String, modifier: Modifier = Modifier, color: Color = SiloSecondaryText) { + Text( + text = text.uppercase(), + fontSize = TvPartyMetrics.eyebrow, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.4.sp, + color = color, + maxLines = 1, + modifier = modifier, + ) +} + +// ---- Backdrop -------------------------------------------------------------- + +/** + * Full-bleed artwork under a room screen: the film is the room. A poster + * (all some titles have) is blurred and dimmed so it tints rather than shows. + */ +@Composable +internal fun TvPartyBackdrop( + url: String?, + thumbhash: String? = null, + isPoster: Boolean = false, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize().background(DarkBackground)) { + if (!url.isNullOrBlank() || !thumbhash.isNullOrBlank()) { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .then(if (isPoster) Modifier.blur(14.dp).alpha(0.7f) else Modifier), + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.92f), + 0.34f to Color.Black.copy(alpha = 0.78f), + 0.62f to Color.Black.copy(alpha = 0.25f), + 1f to Color.Black.copy(alpha = 0.05f), + ), + ), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.35f), + 0.3f to Color.Transparent, + 0.7f to Color.Black.copy(alpha = 0.4f), + 1f to Color.Black.copy(alpha = 0.95f), + ), + ), + ) + } +} + +// ---- Buttons --------------------------------------------------------------- + +internal enum class TvPartyButtonKind { Primary, Secondary, Outlined } + +/** + * The room's pill button: white primary, chrome secondary, outlined. Focus + * always inverts to the white capsule, as the rest of the TV app does. A + * [state] gated by work in flight stays focusable and dims. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: TvPartyButtonKind = TvPartyButtonKind.Primary, + icon: ImageVector? = null, + state: TvControlState = TvControlState.structural(true), + trailing: String? = null, + fontSize: TextUnit = if (kind == TvPartyButtonKind.Primary) 17.sp else 15.sp, + height: Dp = 40.dp, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + val (restFill, restContent, restBorder) = when (kind) { + TvPartyButtonKind.Primary -> Triple(SiloOnSurface, DarkBackground, Color.Transparent) + TvPartyButtonKind.Secondary -> Triple(PartyChromeFill, SiloOnSurface, PartyChromeBorder) + TvPartyButtonKind.Outlined -> Triple(Color.Transparent, SiloOnSurface, SiloOnSurface.copy(alpha = 0.8f)) + } + val content = if (isFocused) FocusedContent else restContent + Surface( + onClick = { state.perform(onClick) }, + enabled = state.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = restFill, + contentColor = restContent, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + disabledContainerColor = restFill, + disabledContentColor = restContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, restBorder), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier + .alpha(if (state.actionable) 1f else 0.45f) + .tvControlSemantics(state), + ) { + Row( + modifier = Modifier + .height(height) + .padding(horizontal = 22.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (icon != null) { + Icon(imageVector = icon, contentDescription = null, tint = content, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(10.dp)) + } + Text( + text = label, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + color = content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (trailing != null) { + Spacer(modifier = Modifier.width(8.dp)) + Text(text = trailing, fontSize = TvPartyMetrics.caption, color = content.copy(alpha = 0.6f), maxLines = 1) + } + } + } +} + +/** The room code is the invitation: "JOIN WITH [qr]". Opens the invitation page. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyCodePill(code: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = PartyChromeFill, + contentColor = SiloOnSurface, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, PartyChromeBorder), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier, + ) { + Row( + modifier = Modifier + .height(34.dp) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Text( + text = "JOIN WITH", + fontSize = TvPartyMetrics.caption, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.4.sp, + color = if (isFocused) Color.Black.copy(alpha = 0.5f) else SiloSecondaryText, + ) + Text( + text = code, + fontSize = TvPartyMetrics.code, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 3.sp, + color = if (isFocused) FocusedContent else SiloOnSurface, + ) + Icon( + imageVector = Icons.Filled.QrCode2, + contentDescription = "Invite friends", + tint = if (isFocused) Color.Black.copy(alpha = 0.6f) else SiloSecondaryText, + modifier = Modifier.size(16.dp), + ) + } + } +} + +/** The "···" Options button beside the code pill. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyOptionsButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(100.dp) + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = PartyChromeFill, + contentColor = SiloOnSurface, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, PartyChromeBorder), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier, + ) { + Box(modifier = Modifier.size(width = 68.dp, height = 38.dp), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.MoreHoriz, + contentDescription = "Party options", + tint = if (isFocused) FocusedContent else SiloOnSurface, + modifier = Modifier.size(22.dp), + ) + } + } +} + +// ---- Status ---------------------------------------------------------------- + +@Composable +internal fun TvPartyConnectionDot(tone: WatchPartyConnectionTone, text: String, modifier: Modifier = Modifier) { + val color = when (tone) { + WatchPartyConnectionTone.Good -> PartyEmerald + WatchPartyConnectionTone.Warning -> PartyAmber + WatchPartyConnectionTone.Neutral -> SiloSecondaryText + WatchPartyConnectionTone.Bad -> PartyRose + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box(modifier = Modifier.size(6.dp).background(color, CircleShape)) + Text(text = text, fontSize = TvPartyMetrics.caption, color = SiloSecondaryText, maxLines = 1) + } +} + +/** One banner for whatever the member should know. Warnings are amber. */ +@Composable +internal fun TvPartyBanner(message: String, modifier: Modifier = Modifier, warning: Boolean = false) { + val shape = RoundedCornerShape(8.dp) + Row( + modifier = modifier + .background(if (warning) PartyAmber.copy(alpha = 0.12f) else PartyChromeFill, shape) + .border(1.dp, if (warning) PartyAmber.copy(alpha = 0.3f) else PartyChromeBorder, shape) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(7.dp) + .background(if (warning) PartyAmber else SiloSecondaryText, CircleShape), + ) + Text( + text = message, + fontSize = TvPartyMetrics.caption, + lineHeight = 17.sp, + color = if (warning) PartyAmberText else SiloOnSurface, + ) + } +} + +// ---- Seats ----------------------------------------------------------------- + +private val AvatarPalette = listOf( + Color(0xFF5A4A8A) to Color(0xFF2B2450), + Color(0xFF8A5A3A) to Color(0xFF4A2C1C), + Color(0xFF3A7A6A) to Color(0xFF1C3F36), + Color(0xFF7A3A5A) to Color(0xFF3F1C30), + Color(0xFF4A6A8A) to Color(0xFF213546), + Color(0xFF7A6A2A) to Color(0xFF3F3616), +) + +/** The member's avatar colours, chosen as Apple chooses them so a member keeps one colour everywhere. */ +internal fun tvPartyAvatarBrush(member: RoomMember): Brush { + val (start, end) = AvatarPalette[watchPartyAvatarIndex(member, AvatarPalette.size)] + return Brush.linearGradient(listOf(start, end)) +} + +/** + * One member as a seat: an avatar disc whose ring says where they are. Dashed + * grey is here but not ready, solid white with a tick is ready or watching, + * dotted and dimmed is reconnecting. + */ +@Composable +internal fun TvPartySeat(member: RoomMember, state: WatchPartySeatState, size: Dp = TvPartyMetrics.seat) { + val ring = size * 1.14f + val ringColor = when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> SiloOnSurface + WatchPartySeatState.Away -> SiloOnSurface.copy(alpha = 0.25f) + else -> SiloOnSurface.copy(alpha = 0.38f) + } + val done = state == WatchPartySeatState.Ready || state == WatchPartySeatState.Watching + val nameSize = (size.value * 0.19f / 0.86f).coerceAtLeast(MIN_SP).sp + val detailSize = (size.value * 0.16f / 0.86f).coerceAtLeast(MIN_SP).sp + Column( + modifier = Modifier.width(seatColumnWidth(size)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.08f), + ) { + Box( + modifier = Modifier + .padding(top = size * 0.12f) + .size(ring), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(size) + .alpha(if (state == WatchPartySeatState.Away) 0.45f else 1f) + .background(tvPartyAvatarBrush(member), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = tvWatchPartyMemberName(member).take(1).uppercase(), + fontSize = (size.value * 0.36f / 0.86f).sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + ) + } + Canvas(modifier = Modifier.fillMaxSize()) { + val width = size.toPx() * 0.035f + val effect = when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> null + WatchPartySeatState.Away -> PathEffect.dashPathEffect(floatArrayOf(0.1f, width * 3f)) + else -> PathEffect.dashPathEffect(floatArrayOf(width * 3f, width * 2.2f)) + } + drawCircle( + color = ringColor, + radius = this.size.minDimension / 2f - width / 2f, + style = Stroke( + width = width, + pathEffect = effect, + cap = if (state == WatchPartySeatState.Away) StrokeCap.Round else StrokeCap.Butt, + ), + ) + } + if (member.isHost) { + Text( + text = "HOST", + fontSize = MIN_SP.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = SiloOnSurface, + modifier = Modifier + .align(Alignment.TopCenter) + // Text keeps a 14sp floor, so on a small seat the tag is wider than the disc. + .wrapContentSize(unbounded = true) + .offset(y = -size * 0.16f) + .background(Color.Black.copy(alpha = 0.72f), RoundedCornerShape(100.dp)) + .border(1.dp, PartyChromeBorder, RoundedCornerShape(100.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + } + if (done) { + // Sits on the ring at 4:30, cutting into it. + val badge = size * 0.34f + val offset = ring / 2 * 0.7071f + Box( + modifier = Modifier + .offset(x = offset, y = offset) + .size(badge) + .background(Color.Black, CircleShape) + .padding(size * 0.03f) + .background(SiloOnSurface, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(size * 0.2f), + ) + } + } + } + Text( + text = tvWatchPartyMemberName(member), + fontSize = nameSize, + fontWeight = FontWeight.Medium, + color = SiloOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + // "you · not ready": the role rides with the state so long names keep their width. + listOfNotNull("you".takeIf { member.isSelf }, watchPartySeatStatus(state)) + .takeIf { it.isNotEmpty() } + ?.let { detail -> + Text(text = detail.joinToString(" · "), fontSize = detailSize, color = SiloSecondaryText, maxLines = 1) + } + } +} + +/** Seat columns never narrow past what a 14sp name needs, even when the discs shrink. */ +private fun seatColumnWidth(size: Dp): Dp = maxOf(size * 1.5f, 88.dp) + +/** The dashed "+" seat that opens the invitation. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyInviteSeat(onClick: () -> Unit, modifier: Modifier = Modifier, size: Dp = TvPartyMetrics.seat) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + Column( + modifier = Modifier.width(seatColumnWidth(size)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(size * 0.08f), + ) { + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = CircleShape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + contentColor = SiloSecondaryText, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(size * 0.035f, Color.White.copy(alpha = 0.22f)), shape = CircleShape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = CircleShape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.22f), elevation = 16.dp), + ), + modifier = modifier + .padding(top = size * 0.12f) + .size(size * 1.14f), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Invite friends", + tint = if (isFocused) FocusedContent else SiloSecondaryText, + modifier = Modifier.size(size * 0.42f), + ) + } + } + Text( + text = "Invite", + fontSize = (size.value * 0.19f / 0.86f).coerceAtLeast(MIN_SP).sp, + fontWeight = FontWeight.Medium, + color = SiloSecondaryText, + ) + } +} + +/** "HERE NOW · 2 of 3 ready", then the seats and the invite seat. */ +@Composable +internal fun TvPartySeatsRow( + members: List, + phase: RoomPhase, + onInvite: () -> Unit, + inviteModifier: Modifier = Modifier, + seatSize: Dp = TvPartyMetrics.seat, +) { + Column(verticalArrangement = Arrangement.spacedBy(seatSize * 0.2f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TvPartyEyebrow("Here now") + Text( + text = watchPartyPresenceSummary(members, phase), + fontSize = TvPartyMetrics.caption, + color = SiloSecondaryText.copy(alpha = 0.7f), + maxLines = 1, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(TvPartyMetrics.seatGap), verticalAlignment = Alignment.Top) { + watchPartySeatOrder(members).forEach { member -> + TvPartySeat(member = member, state = watchPartySeatState(member, phase), size = seatSize) + } + TvPartyInviteSeat(onClick = onInvite, modifier = inviteModifier, size = seatSize) + } + } +} + +// ---- Menus ----------------------------------------------------------------- + +/** + * A settings-pane row: title left, current value right. Destructive rows + * read red. Rows gated by work in flight stay focusable. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvPartyOptionRow( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + value: String? = null, + destructive: Boolean = false, + state: TvControlState = TvControlState.structural(true), +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val shape = RoundedCornerShape(10.dp) + val titleColor = when { + isFocused && destructive -> PartyDestructiveFocused + isFocused -> FocusedContent + destructive -> PartyDestructive + else -> SiloOnSurface + } + Surface( + onClick = { state.perform(onClick) }, + enabled = state.focusable, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(shape = shape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.04f), + contentColor = SiloOnSurface, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + disabledContainerColor = Color.White.copy(alpha = 0.04f), + disabledContentColor = SiloOnSurface, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), shape = shape), + focusedBorder = Border(BorderStroke(0.dp, Color.Transparent), shape = shape), + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.18f), elevation = 12.dp), + ), + modifier = modifier + .fillMaxWidth() + .heightIn(min = 34.dp) + .alpha(if (state.actionable) 1f else 0.5f) + .tvControlSemantics(state), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = titleColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Text( + text = value, + fontSize = 14.sp, + color = (if (isFocused) FocusedContent else SiloOnSurface).copy(alpha = 0.7f), + maxLines = 1, + ) + } + } + } +} + +/** + * A dimmed-backdrop card for a short list of rows ("Party options", a + * suggestion's actions), ending in Close. Close has focus on open, so a stray + * Select never runs a destructive row, and Back dismisses. + */ +@Composable +internal fun TvPartyMenuOverlay( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = 450.dp, + content: @Composable () -> Unit, +) { + val closeFocus = remember { FocusRequester() } + var closeFocused by remember { mutableStateOf(false) } + // The focusable popup hands focus to its first row on its own, and the + // shared initial-focus helper then stands down, so move focus to Close + // once the rows exist. + LaunchedEffect(Unit) { + requestFocusUntilObserved( + maxAttempts = MENU_FOCUS_ATTEMPTS, + awaitAttempt = { withFrameNanos { } }, + requestFocus = closeFocus::requestFocus, + isFocused = { closeFocused }, + ) + } + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismiss, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.62f)), + contentAlignment = Alignment.Center, + ) { + val shape = RoundedCornerShape(15.dp) + Column( + modifier = modifier + .width(width) + .background(DarkSurfaceElevated, shape) + .border(1.dp, PartyChromeBorder, shape) + .padding(24.dp) + .tvModalFocusBoundary() + .then(rememberTvDialogInitialFocus(closeFocus)), + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + Text( + text = title, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = SiloOnSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 2.dp), + ) + content() + TvPartyOptionRow( + title = "Close", + onClick = onDismiss, + modifier = Modifier + .focusRequester(closeFocus) + .onFocusChanged { closeFocused = it.isFocused }, + ) + } + } + } +} + +/** Frames to keep asking for Close's initial focus. */ +private const val MENU_FOCUS_ATTEMPTS = 6 + +/** A row of the room's buttons laid out right to left from the trailing edge. */ +@Composable +internal fun TvPartyButtonRow(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyText.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyText.kt new file mode 100644 index 0000000000..33a87895e0 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyText.kt @@ -0,0 +1,75 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.repository.WatchPartyEndReason + +/** Plain-language reason this device's Watch Party ended. */ +internal fun tvWatchPartyEndedMessage(reason: String): String = when (reason) { + WatchPartyEndReason.Replaced -> "This profile joined the Watch Party on another device." + WatchPartyEndReason.ConnectionLost -> "Lost connection to the party." + else -> "The Watch Party has ended." +} + +/** A member's name for display; the server may omit it. */ +internal fun tvWatchPartyMemberName(member: RoomMember): String = + member.displayName.trim().ifBlank { "Someone" } + +/** "Ana", "Ana and Ben", "Ana, Ben, and Cy", "Ana, Ben, and 3 others". */ +internal fun tvWatchPartyNameList(names: List): String = when (names.size) { + 0 -> "" + 1 -> names[0] + 2 -> "${names[0]} and ${names[1]}" + 3 -> "${names[0]}, ${names[1]}, and ${names[2]}" + else -> "${names[0]}, ${names[1]}, and ${names.size - 2} others" +} + +/** + * The other members the room is waiting for, or null when the room is not + * waiting. An empty list means the room waits only for this viewer. + */ +internal fun tvWatchPartyWaitingFor(room: RoomSnapshot?): List? { + if (room == null || room.phase != RoomPhase.Playing || room.playbackState != RoomPlaybackState.Waiting) { + return null + } + return room.members.filter { it.isSyncing && !it.isSelf }.map(::tvWatchPartyMemberName) +} + +/** + * Members the room left behind between two snapshots of the same selection: + * the room was waiting while they synced, and it now plays again while they + * are still not ready. This viewer is never listed; it gets the catching-up + * notice instead. Members who left the room are not listed either. + */ +internal fun tvWatchPartyLeftBehind(previous: RoomSnapshot?, next: RoomSnapshot?): List { + if (previous == null || next == null) return emptyList() + if (previous.roomId != next.roomId || previous.selectionRevision != next.selectionRevision) return emptyList() + if (previous.phase != RoomPhase.Playing || previous.playbackState != RoomPlaybackState.Waiting) return emptyList() + if (next.phase != RoomPhase.Playing || next.playbackState != RoomPlaybackState.Playing) return emptyList() + return previous.members + .filter { it.isSyncing && !it.isSelf } + .mapNotNull { waitedFor -> + next.members.firstOrNull { it.isSameMember(waitedFor) } + ?.takeIf { !it.isReady } + ?.let(::tvWatchPartyMemberName) + } +} + +private fun RoomMember.isSameMember(other: RoomMember): Boolean = + if (userId.isNotBlank() || other.userId.isNotBlank()) { + userId == other.userId && profileId == other.profileId + } else { + displayName == other.displayName + } + +/** A room code split in two for reading aloud and typing: "ABCD 2345". */ +internal fun tvWatchPartyDisplayCode(code: String): String = + if (code.length == 8) "${code.substring(0, 4)} ${code.substring(4)}" else code + +internal const val TV_WATCH_PARTY_HOST_LEAVE_NOTE = + "The party ends two minutes after you leave unless you rejoin." + +internal const val TV_WATCH_PARTY_HOST_AWAY_NOTE = + "The party ends in two minutes unless the host returns." diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt deleted file mode 100644 index 130860757e..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt +++ /dev/null @@ -1,276 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -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 -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -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.Border -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus -import org.siloserver.silo.tv.ui.focus.TvControlState -import org.siloserver.silo.tv.ui.focus.tvControlSemantics -import org.siloserver.silo.tv.ui.screens.player.TvDialogActionRow -import org.siloserver.silo.tv.ui.theme.DarkBackground -import org.siloserver.silo.tv.ui.theme.FocusedContainer -import org.siloserver.silo.tv.ui.theme.FocusedContent -import org.siloserver.silo.watchtogether.canDismissRoomEntry - -/** - * Pure 8-char D-pad join-code accumulator. Appends only A–Z / 0–9 (uppercasing - * letters), caps at [LENGTH], and supports backspace/clear. Kept free of any - * Android types so it is unit-testable without Robolectric. - */ -data class JoinCodeState(val code: String = "") { - val isComplete: Boolean get() = code.length == LENGTH - fun append(c: Char): JoinCodeState { - if (code.length >= LENGTH) return this - val up = c.uppercaseChar() - return if (up in 'A'..'Z' || up in '0'..'9') copy(code = code + up) else this - } - fun backspace() = if (code.isEmpty()) this else copy(code = code.dropLast(1)) - fun clear() = JoinCodeState() - companion object { const val LENGTH = 8 } -} - -/** Keys laid out in the focusable A–Z / 0–9 grid, six per row. */ -private val JoinCodeKeys: List = ('A'..'Z') + ('0'..'9') -private const val KEYS_PER_ROW = 6 - -/** - * D-pad join-by-code dialog. Panel + row idiom mirrors - * [org.siloserver.silo.tv.ui.screens.player.TvSubtitleSearchDialog]. Shows a large - * mono display of the running code (placeholder dashes for unfilled slots), a - * focusable A–Z/0–9 key grid (each key appends into a [JoinCodeState]), a Delete - * row (backspace), and a "Join" [TvDialogActionRow] enabled when the code is - * complete and not busy. - */ -@Composable -fun TvJoinCodeDialog( - isBusy: Boolean, - error: String?, - onJoin: (String) -> Unit, - onDismiss: () -> Unit, -) { - var state by remember { mutableStateOf(JoinCodeState()) } - val firstKeyFocus = remember { FocusRequester() } - - 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 = 40.dp, end = 36.dp, bottom = 30.dp), - contentAlignment = Alignment.Center, - ) { - val panelShape = RoundedCornerShape(14.dp) - Column( - modifier = Modifier - .width(280.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(firstKeyFocus)), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = "JOIN BY CODE", - 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), - ) - - // Large mono display: filled chars + dashes for empty slots. - val display = (0 until JoinCodeState.LENGTH).joinToString(" ") { i -> - state.code.getOrNull(i)?.toString() ?: "–" - } - Text( - text = display, - style = MaterialTheme.typography.headlineMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = 16.sp, - letterSpacing = 2.sp, - fontWeight = FontWeight.Bold, - ), - color = Color.White, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - ) - - // Focusable A–Z / 0–9 grid. - JoinCodeKeys.chunked(KEYS_PER_ROW).forEachIndexed { rowIndex, rowKeys -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - rowKeys.forEachIndexed { colIndex, ch -> - JoinCodeKey( - char = ch, - // Joining is in flight, not a dead end — the - // grid keeps its focus so the ring survives a - // failed join. - controlState = TvControlState.transient(!isBusy), - onClick = { state = state.append(ch) }, - modifier = if (rowIndex == 0 && colIndex == 0) { - Modifier - .weight(1f) - .focusRequester(firstKeyFocus) - } else { - Modifier.weight(1f) - }, - ) - } - // Pad the final partial row so keys keep a consistent width. - repeat(KEYS_PER_ROW - rowKeys.size) { - Box(modifier = Modifier.weight(1f)) - } - } - } - - TvDialogActionRow( - title = "Delete", - enabled = !isBusy && state.code.isNotEmpty(), - onClick = { state = state.backspace() }, - ) - - TvDialogActionRow( - title = if (isBusy) "Joining…" else "Join", - enabled = state.isComplete && !isBusy, - onClick = { onJoin(state.code) }, - ) - - error?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFFEF4444), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - } - } - } -} - -/** Single focusable key tile in the join-code grid. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun JoinCodeKey( - char: Char, - controlState: TvControlState, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(12.dp) - val enabled = controlState.actionable - - Surface( - onClick = { controlState.perform(onClick) }, - enabled = controlState.focusable, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - // Dimmed from `actionable`, not from the Surface's disabled slots: the - // key stays focusable while a join is in flight, so it never enters the - // disabled colour path. - colors = ClickableSurfaceDefaults.colors( - containerColor = if (enabled) { - Color.White.copy(alpha = 0.06f) - } else { - Color.White.copy(alpha = 0.03f) - }, - contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.42f), - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - disabledContainerColor = Color.White.copy(alpha = 0.03f), - disabledContentColor = Color.White.copy(alpha = 0.38f), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(2.dp, DarkBackground.copy(alpha = 0.82f)), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.White.copy(alpha = 0.18f), - elevation = 14.dp, - ), - ), - modifier = modifier - .height(24.dp) - .then( - if (isFocused) { - Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) - } else { - Modifier - }, - ) - .tvControlSemantics(controlState), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = char.toString(), - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 18.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - ), - color = if (isFocused) FocusedContent else Color.White, - ) - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt deleted file mode 100644 index c3be09ec17..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvSuggestToRoomViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -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.siloserver.silo.model.watchtogether.AddSuggestionRequest -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.errorMessage -import org.siloserver.silo.repository.WatchTogetherRepository - -class TvSuggestToRoomViewModel( - private val repository: WatchTogetherRepository, -) : ViewModel() { - data class UiState( - val isBusy: Boolean = false, - val notice: String? = null, - val error: String? = null, - ) - - private val _uiState = MutableStateFlow(UiState()) - val uiState: StateFlow = _uiState.asStateFlow() - val room: StateFlow = repository.roomSnapshot - - fun suggest( - contentId: String, - contentType: String, - title: String, - subtitle: String?, - posterUrl: String?, - ) { - if (_uiState.value.isBusy) return - _uiState.update { it.copy(isBusy = true, error = null, notice = null) } - viewModelScope.launch { - val result = repository.addSuggestion( - AddSuggestionRequest( - contentId = contentId, - contentType = contentType, - title = title, - subtitle = subtitle, - posterUrl = posterUrl, - ), - ) - when (result) { - is ApiResult.Success -> - _uiState.update { it.copy(isBusy = false, notice = "Suggested to the room") } - is ApiResult.Error, is ApiResult.NetworkError -> - _uiState.update { - it.copy(isBusy = false, error = result.errorMessage("Could not suggest that")) - } - } - } - } - - fun consumeNotice() = _uiState.update { it.copy(notice = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt deleted file mode 100644 index 956753fe95..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.tv.ui.navigation.TvRoute -import org.siloserver.silo.watchtogether.WatchTogetherEntryTarget -import org.siloserver.silo.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/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt deleted file mode 100644 index af888cac08..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherEntryDialog.kt +++ /dev/null @@ -1,117 +0,0 @@ -package org.siloserver.silo.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.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus -import org.siloserver.silo.tv.ui.screens.player.TvDialogActionRow -import org.siloserver.silo.tv.ui.theme.DarkBackground -import org.siloserver.silo.watchtogether.canDismissRoomEntry - -/** - * D-pad Watch Together entry dialog. Panel + row idiom mirrors - * [org.siloserver.silo.tv.ui.screens.player.TvSubtitleSearchDialog] (Popup, dark - * panel, [TvDialogActionRow] rows). "Host a room" (auto-focused) creates a room - * with the current title pre-selected; "Join by code" opens the [TvJoinCodeDialog]. - */ -@Composable -fun TvWatchTogetherEntryDialog( - isBusy: Boolean, - error: String?, - onHost: () -> Unit, - onHostVote: () -> Unit, - onJoin: () -> Unit, - onDismiss: () -> Unit, -) { - val hostFocus = remember { FocusRequester() } - - 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(hostFocus)), - 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), - ) - - TvDialogActionRow( - title = if (isBusy) "Working…" else "Host a room", - enabled = !isBusy, - onClick = onHost, - modifier = Modifier.focusRequester(hostFocus), - ) - - TvDialogActionRow( - title = if (isBusy) "Working…" else "Host a vote room", - enabled = !isBusy, - onClick = onHostVote, - ) - - 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/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt deleted file mode 100644 index 3530d0cb42..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt +++ /dev/null @@ -1,567 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.outlined.StarBorder -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -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 -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import org.siloserver.silo.model.watchtogether.GuestControlPolicy -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.Suggestion -import org.siloserver.silo.tv.ui.navigation.TvRoute -import org.siloserver.silo.tv.ui.screens.auth.QrCodePanel -import org.siloserver.silo.tv.ui.screens.player.TvDialogActionRow -import org.siloserver.silo.tv.ui.screens.player.TvDialogCyclerRow -import org.siloserver.silo.tv.ui.theme.DarkBackground -import org.siloserver.silo.tv.ui.theme.FocusedContainer -import org.siloserver.silo.tv.ui.theme.FocusedContent -import org.siloserver.silo.tv.ui.theme.SiloBlue -import org.siloserver.silo.watchtogether.isVoteRoom -import org.siloserver.silo.watchtogether.roomVoteWinner -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf - -/** - * Pure: should the lobby hand off to the synced player? Only once the room is - * actually [RoomPhase.Playing] AND a (non-blank) selection has landed — mirrors - * the phone lobby's `lobbyPlayerDestinationOrNull` semantics (enum compare + - * isNullOrBlank, NOT a wire-string compare). - */ -fun shouldEnterSyncedPlayer(s: RoomSnapshot?): Boolean { - if (s == null || s.phase != RoomPhase.Playing || s.selectedContentId.isNullOrBlank()) return false - if (s.selfRole == MemberRole.Host && s.memberCount <= 1) return false - return true -} - -/** - * Watch Together lobby (TV). Shows member count / role / selection mode, the - * host block (invite code shown LARGE + selection-mode/policy cyclers + per-row - * promote/remove, gated on the server's per-recipient management capability), - * and the suggestion list (focusable rows; Select toggles vote/unvote). Auto- - * navigates everyone into the synced player once a selection lands and the room - * starts playing. - * - * Host pre-selection comes from item-detail (the entry dialog) and there is no - * reusable TV title-picker, so this screen is vote/promote/pick-only: there is - * no "add a suggestion" affordance on TV (it would require building a picker). - * - * [onNavigateToPlayer] is called with a fully-built player route (computed from - * the snapshot, mirroring the phone lobby); [onBack] backs out of the lobby. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvWatchTogetherLobbyScreen( - roomId: String, - onNavigateToPlayer: (route: String) -> Unit, - onBack: () -> Unit, - viewModel: TvWatchTogetherLobbyViewModel = koinViewModel( - key = "wt-lobby-$roomId", - parameters = { parametersOf(roomId) }, - ), -) { - 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 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 - val isVoteRoom = snapshot.isVoteRoom() - - // Auto-hand-off into the synced player once the room is playing + selected. - LaunchedEffect( - room?.phase, - room?.selectedContentId, - room?.selectedFileId, - room?.memberCount, - room?.selfRole, - ) { - val snapshot = room ?: return@LaunchedEffect - if (shouldEnterSyncedPlayer(snapshot)) { - onNavigateToPlayer( - TvRoute.Player( - contentId = snapshot.selectedContentId!!, - fileId = snapshot.selectedFileId, - roomId = snapshot.roomId, - ).route, - ) - } - } - - // Server already closed the room (host left / explicit close) — just tear - // down locally and back out; do NOT re-issue closeRoom. - LaunchedEffect(closedReason) { - if (closedReason != null) { - viewModel.leave() - onBack() - } - } - - 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) { - onBack() - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(start = 56.dp, end = 56.dp, top = 40.dp, bottom = 48.dp), - horizontalArrangement = Arrangement.spacedBy(48.dp), - ) { - Column( - modifier = Modifier.weight(0.42f), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - // --- Header -------------------------------------------------------- - Text( - text = "WATCH TOGETHER", - style = MaterialTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 4.sp, - ), - color = Color.White, - ) - if (snapshot != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "${snapshot.memberCount} in room · ${if (isHostLabel) "Host" else "Guest"}", - style = MaterialTheme.typography.titleMedium, - color = Color.White.copy(alpha = 0.86f), - ) - Text( - text = "Mode: ${selectionModeLabel(snapshot.selectionMode)}", - style = MaterialTheme.typography.titleMedium, - color = Color.White.copy(alpha = 0.66f), - ) - if (!snapshot.hostConnected) { - Text( - text = "Host disconnected", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - color = Color(0xFFFFB4A9), - ) - } - } - } - - if (snapshot == null) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = "Connecting…", - style = MaterialTheme.typography.titleLarge, - color = Color.White.copy(alpha = 0.7f), - ) - } - } else { - // --- Host block (gated on manage capability) ------------------- - if (canManage) { - Text( - text = "JOIN CODE", - style = MaterialTheme.typography.labelLarge.copy(letterSpacing = 2.sp), - color = Color.White.copy(alpha = 0.56f), - ) - Text( - text = snapshot.code, - style = MaterialTheme.typography.displaySmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - letterSpacing = 12.sp, - ), - color = Color.White, - ) - // Scannable invite: a guest can point their phone camera at - // the QR to read the join code instead of typing it. (TVs - // have no share targets, so a QR is the parity equivalent of - // the phone's share-code action.) Nested Column so the parent - // 20dp spacing doesn't balloon around the QR + caption. - if (snapshot.code.isNotBlank()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - QrCodePanel(content = snapshot.code, size = 140.dp) - Text( - text = "Scan to get the code", - style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.5f), - ) - } - } - } - } - } - - Column( - modifier = Modifier - .weight(0.58f) - .verticalScroll(rememberScrollState()) - .padding(bottom = 48.dp), - 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( - title = "Selection mode", - value = selectionModeLabel(snapshot.selectionMode), - onPrevious = {}, - onNext = {}, - enabled = false, - ) - TvDialogCyclerRow( - title = "Guest controls", - value = policyLabel(snapshot.guestControlPolicy), - onPrevious = { viewModel.updatePolicy(nextPolicy(snapshot.guestControlPolicy).wire) }, - onNext = { viewModel.updatePolicy(nextPolicy(snapshot.guestControlPolicy).wire) }, - ) - // Explicit host close — mirrors mobile's "Close" action. - // Stays on screen; the room_closed broadcast then drives the - // back-out via the closedReason effect above. - CloseRoomButton(onClick = viewModel::closeRoom) - } - - val winner = roomVoteWinner(suggestions) - if (isVoteRoom && canManage) { - TvDialogActionRow( - title = winner?.let { "Start winner: ${it.title}" } - ?: "Start winner — no votes yet", - enabled = winner != null, - onClick = { winner?.let { viewModel.promote(it.id) } }, - ) - } - - Text( - text = "SUGGESTIONS", - style = MaterialTheme.typography.labelLarge.copy(letterSpacing = 2.sp), - color = Color.White.copy(alpha = 0.56f), - ) - if (suggestions.isEmpty()) { - Text( - text = "No suggestions yet.", - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.5f), - ) - } else { - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth(), - ) { - suggestions.forEach { s -> - key(s.id) { - SuggestionRow( - suggestion = s, - canManage = canManage, - isWinning = isVoteRoom && winner?.id == s.id, - onVote = { - if (s.votedByMe) viewModel.unvote(s.id) else viewModel.vote(s.id) - }, - onPromote = { viewModel.promote(s.id) }, - onRemove = { viewModel.removeSuggestion(s.id) }, - ) - } - } - } - } - } - } - } - } -} - -private fun selectionModeLabel(mode: RoomSelectionMode): String = when (mode) { - RoomSelectionMode.HostPick -> "Host pick" - RoomSelectionMode.Vote -> "Vote" - RoomSelectionMode.Unknown -> "—" -} - -private fun policyLabel(policy: GuestControlPolicy): String = when (policy) { - GuestControlPolicy.HostOnly -> "Host only" - GuestControlPolicy.GuestPlayPause -> "Guests play/pause" - GuestControlPolicy.Unknown -> "—" -} - -/** Cycle between the two real policies; Unknown maps forward to HostOnly. */ -private fun nextPolicy(policy: GuestControlPolicy): GuestControlPolicy = when (policy) { - GuestControlPolicy.HostOnly -> GuestControlPolicy.GuestPlayPause - GuestControlPolicy.GuestPlayPause -> GuestControlPolicy.HostOnly - GuestControlPolicy.Unknown -> GuestControlPolicy.HostOnly -} - -/** - * Focusable suggestion row — reuses the `Surface` + [MutableInteractionSource] - * focus idiom from `TvSubtitleResultRow`. Select toggles vote/unvote; a host - * (manage-capable) row exposes Promote + Remove sub-actions. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SuggestionRow( - suggestion: Suggestion, - canManage: Boolean, - isWinning: Boolean, - onVote: () -> Unit, - onPromote: () -> Unit, - onRemove: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(16.dp) - - Surface( - onClick = onVote, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.04f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), - border = ClickableSurfaceDefaults.border( - focusedBorder = Border( - border = BorderStroke(2.dp, DarkBackground.copy(alpha = 0.82f)), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.White.copy(alpha = 0.18f), - elevation = 16.dp, - ), - ), - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 64.dp) - .then( - if (isFocused) { - Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) - } else { - Modifier - }, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (suggestion.posterUrl.isNotBlank()) { - AsyncImage( - model = suggestion.posterUrl, - contentDescription = null, - modifier = Modifier - .size(width = 36.dp, height = 54.dp), - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Text( - text = suggestion.title, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isFocused) FocusedContent else Color.White, - ) - if (suggestion.subtitle.isNotBlank()) { - Text( - text = suggestion.subtitle, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isFocused) { - FocusedContent.copy(alpha = 0.7f) - } else { - Color.White.copy(alpha = 0.56f) - }, - ) - } - } - // Vote glyph: filled when votedByMe, outline otherwise. - Icon( - imageVector = if (suggestion.votedByMe) Icons.Filled.Star else Icons.Outlined.StarBorder, - contentDescription = if (suggestion.votedByMe) "Voted" else "Vote", - tint = if (isFocused) FocusedContent else Color.White, - ) - Spacer(Modifier.width(4.dp)) - Text( - text = suggestion.voteCount.toString(), - style = MaterialTheme.typography.titleMedium, - color = if (isFocused) FocusedContent else Color.White, - ) - if (isWinning) { - Spacer(Modifier.width(8.dp)) - Text( - text = "WINNING", - style = MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 1.sp, - ), - color = if (isFocused) FocusedContent else SiloBlue, - ) - } - if (canManage) { - Spacer(Modifier.width(8.dp)) - RowAction(text = "Pick", onClick = onPromote) - RowAction(text = "Remove", onClick = onRemove) - } - } - } -} - -/** Compact focusable sub-action button used for host Promote/Remove. */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun RowAction(text: String, onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(10.dp) - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - modifier = Modifier.then( - if (isFocused) Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) else Modifier, - ), - ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = if (isFocused) FocusedContent else Color.White, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - } -} - -/** - * Host-only "Close room for everyone" action — the TV equivalent of mobile's - * "Close" button. Clicking closes the room server-side while staying on the - * lobby; the resulting room_closed broadcast drives the back-out reactively. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun CloseRoomButton(onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(12.dp) - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF7A2620).copy(alpha = 0.42f), - contentColor = Color(0xFFFFB4A9), - focusedContainerColor = Color(0xFFB3261E), - focusedContentColor = Color.White, - pressedContainerColor = Color(0xFFB3261E), - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), - modifier = Modifier.then( - if (isFocused) Modifier.border(2.dp, Color.White.copy(alpha = 0.98f), shape) else Modifier, - ), - ) { - Text( - text = "Close room for everyone", - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), - ) - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt deleted file mode 100644 index a55b28c056..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.Suggestion -import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.errorMessage -import org.siloserver.silo.repository.WatchTogetherRepository -import org.siloserver.silo.watchtogether.RoomSession -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -/** - * Backs [TvWatchTogetherLobbyScreen]. Binds the per-room websocket on enter so - * snapshots/suggestions flow into the state flows, and exposes vote / unvote / - * host-promote / host-pick / policy / close-room ops. Mirrors the phone - * [org.siloserver.silo.android.ui.screens.watchtogether.WatchTogetherLobbyViewModel]. - * - * The repository owns the room JWT and reads the active roomId from its own - * snapshot, so the room-scoped ops take only request/id params (not a roomId). - * The application-scoped [RoomSession] owns reconnect and survives navigation - * from this lobby into the synced player. - */ -class TvWatchTogetherLobbyViewModel( - private val roomId: String, - private val repository: WatchTogetherRepository, - private val roomSession: RoomSession, -) : ViewModel() { - - init { - roomSession.adopt(roomId) - } - - val room: StateFlow = repository.roomSnapshot - .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomSnapshot.value) - val suggestions: StateFlow> = repository.suggestions - .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 removeSuggestion(suggestionId: String) = - 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) = - launchOperation("Could not start suggestion") { - repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) - } - - /** Host: change the guest-control policy. */ - fun updatePolicy(guestControlPolicyWire: String) = - launchOperation("Could not update room policy") { - repository.updatePolicy(UpdatePolicyRequest(guestControlPolicy = guestControlPolicyWire)) - } - - /** Host: close the room for everyone. */ - 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 - * mobile — leaving does NOT close the room for everyone. A host closes the - * room explicitly via [closeRoom] (and stays on the lobby until the server's - * `room_closed` broadcast reactively backs them out); otherwise the server's - * host-left timeout reaps it. Closing on leave used to race the closeRoom - * network call against viewModelScope cancellation during screen dispose. - */ - fun leave() { - roomSession.depart() - } - - override fun onCleared() { - super.onCleared() - // Do NOT reset here — the player binding reuses the same repo connection - // when we auto-navigate into the synced player. reset() is called - // explicitly via leave() when the user backs out without playing. - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt deleted file mode 100644 index f35e169a34..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt +++ /dev/null @@ -1,140 +0,0 @@ -package org.siloserver.silo.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.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus -import org.siloserver.silo.tv.ui.screens.player.TvDialogActionRow -import org.siloserver.silo.tv.ui.theme.DarkBackground -import org.siloserver.silo.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/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt deleted file mode 100644 index 4bf153c314..0000000000 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt +++ /dev/null @@ -1,155 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.siloserver.silo.model.watchtogether.CreateRoomRequest -import org.siloserver.silo.model.watchtogether.JoinRoomRequest -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.SetSelectionRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.errorMessage -import org.siloserver.silo.watchtogether.WatchTogetherEntryGateway -import org.siloserver.silo.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 - -/** - * Backs the TV Watch Together entry + join-code dialogs. Hosts a room (create + - * set this title as the room selection) or joins an existing room by invite code, - * then surfaces a one-shot [UiState.result] [RoomSnapshot] the detail screen routes - * on (host-with-selection → synced player, no selection → lobby). - * - * 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 - * returning. Create/join/selection all return a `{room, room_access_token}` - * wrapper; the snapshot lives at `.data.room`. - * - * Mirrors the mobile `WatchTogetherEntryViewModel` host()/joinByCode() shape. - */ -class TvWatchTogetherViewModel( - private val gateway: WatchTogetherEntryGateway, -) : ViewModel() { - - data class UiState( - val isBusy: Boolean = false, - val error: String? = null, - /** Set once a create/join resolves — the screen observes this and navigates. */ - val result: RoomSnapshot? = null, - ) - - 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( - contentId: String, - 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 = gateway.createRoom( - CreateRoomRequest(selectionMode = selectionMode.wire), - ) - ) { - is ApiResult.Success -> { - // createRoom does NOT auto-select; set this title as the room - // 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 = gateway.setSelection( - SetSelectionRequest(contentId = contentId, fileId = fileId), - ) - ) { - is ApiResult.Success -> finish(sel.data.room) - is ApiResult.Error, is ApiResult.NetworkError -> - fail(sel.errorMessage("Failed to set selection")) - } - } else { - finish(created.data.room) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> - fail(created.errorMessage("Failed to create room")) - } - } - } - - /** 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 = 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")) - } - } - } - - private fun finish(room: RoomSnapshot) { - _uiState.update { it.copy(isBusy = false, error = null, result = room) } - } - - private fun fail(message: String) { - _uiState.update { it.copy(isBusy = false, error = message) } - } - - fun consumeResult() = _uiState.update { it.copy(result = null) } - fun clearError() = _uiState.update { it.copy(error = null) } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt index 50b43e3f49..7ecfd93327 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt @@ -115,10 +115,9 @@ import org.siloserver.silo.common.ui.components.avatarRef import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage import org.siloserver.silo.common.ui.components.rememberProfileServerUrl import org.siloserver.silo.model.catalog.BrowseItem -import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED +import org.siloserver.silo.model.feature.WatchPartyExposure import org.siloserver.silo.model.feature.RequestsFeatureStore import org.siloserver.silo.model.personal.UserLibrary -import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.repository.AuthRepository @@ -151,9 +150,6 @@ import org.siloserver.silo.tv.ui.screens.requests.TvRequestDetailScreen import org.siloserver.silo.tv.ui.screens.requests.TvRequestsPage import org.siloserver.silo.tv.ui.screens.search.TvSearchScreen import org.siloserver.silo.tv.ui.screens.settings.TvSettingsScreen -import org.siloserver.silo.tv.ui.screens.watchtogether.TvJoinCodeDialog -import org.siloserver.silo.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntryDialog -import org.siloserver.silo.tv.ui.screens.watchtogether.TvWatchTogetherViewModel import org.siloserver.silo.tv.ui.theme.TvSkyline import org.siloserver.silo.tv.ui.util.visibleOnTv import org.koin.compose.koinInject @@ -207,7 +203,8 @@ fun TvMainShell( onSwitchServer: () -> Unit, onPairDevice: () -> Unit, onPlayItem: (contentId: String, type: String?, resumePositionSeconds: Double?) -> Unit, - onOpenWatchTogether: (RoomSnapshot) -> Unit, + /** Open the Watch Party hub. */ + onOpenWatchParty: () -> Unit, onOpenPersonDetail: (personId: Long) -> Unit, ) { val nestedNav = rememberNavController() @@ -231,19 +228,9 @@ fun TvMainShell( // while the already-visible Home instance held the populated rows. val homeViewModel: HomeViewModel = koinViewModel(key = "tv-main-home") 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) - } + // Settings → Experimental → Watch Party decides whether the entry shows. + val watchPartyExposure: WatchPartyExposure = koinInject() + val watchPartyEnabled by watchPartyExposure.enabled.collectAsState() // 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). @@ -1672,12 +1659,8 @@ fun TvMainShell( navigateToSecondary(TvMainRoute.History.route) moveFocusToContent(TvMainRoute.History.route) }, - showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED, - onWatchTogether = { - focusState.closeProfileMenuForContent() - watchTogetherViewModel.clearError() - watchTogetherEntryOpen = true - }, + showWatchParty = watchPartyEnabled, + onWatchParty = closeMenuAnd(onOpenWatchParty), onSettings = { // Keep focus on the dropdown row through the route fade. // TvSettingsScreen closes the menu only after its General @@ -1699,38 +1682,6 @@ 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() - }, - ) - } - } } } @@ -1916,7 +1867,7 @@ private fun cascadePanelOffset( * returns focus to the avatar via [onDismiss]. * * Row set + order mirrors tvOS: Switch Profile · Watchlist · Favorites · - * History · Watch Together (client-policy-gated) · Settings · Switch Server · + * History · Watch Party (Settings → Experimental) · Settings · Switch Server · * Sign Out. Calendar and Requests are top-level tabs. */ @OptIn(ExperimentalComposeUiApi::class) @@ -1929,8 +1880,8 @@ private fun TvProfileDropdown( onWatchlist: () -> Unit, onFavorites: () -> Unit, onHistory: () -> Unit, - showWatchTogether: Boolean, - onWatchTogether: () -> Unit, + showWatchParty: Boolean, + onWatchParty: () -> Unit, onSettings: () -> Unit, onSwitchServer: () -> Unit, onSignOut: () -> Unit, @@ -1976,11 +1927,11 @@ private fun TvProfileDropdown( ProfileDropdownRow(label = "Watchlist", icon = Icons.Filled.Bookmark, onClick = onWatchlist) ProfileDropdownRow(label = "Favorites", icon = Icons.Filled.Favorite, onClick = onFavorites) ProfileDropdownRow(label = "History", icon = Icons.Filled.History, onClick = onHistory) - if (showWatchTogether) { + if (showWatchParty) { ProfileDropdownRow( - label = "Watch Together", + label = "Watch Party", icon = Icons.Filled.People, - onClick = onWatchTogether, + onClick = onWatchParty, ) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt index bc98e160be..d9f5a78bce 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvControlWiringCallSiteTest.kt @@ -91,7 +91,7 @@ class TvControlWiringCallSiteTest { listOf( Triple("ui/components/TvPinEntryDialog.kt", "PinKeypad", "PinKey"), Triple( - "ui/screens/watchtogether/TvJoinCodeDialog.kt", + "ui/screens/watchparty/TvJoinCodeDialog.kt", "TvJoinCodeDialog", "JoinCodeKey", ), @@ -171,7 +171,7 @@ class TvControlWiringCallSiteTest { "ui/components/TvOptionDialog.kt", "ui/components/marquee/TvMarquee.kt", "ui/components/TvPinEntryDialog.kt", - "ui/screens/watchtogether/TvJoinCodeDialog.kt", + "ui/screens/watchparty/TvJoinCodeDialog.kt", ).forEach { path -> assertFalse( source(path).containsLoosely("onClick = { if ("), diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt deleted file mode 100644 index e350c30bb0..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.siloserver.silo.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/siloserver/silo/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/siloserver/silo/tv/ui/screens/player/TvRoomTransportGateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomTransportGateTest.kt deleted file mode 100644 index 4a43fc4a1a..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvRoomTransportGateTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.player - -import org.siloserver.silo.model.watchtogether.GuestControlPolicy -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * Pure transport-authority gate tests. The gate decides whether a local - * transport intent (play/pause vs seek) is allowed to be broadcast as a - * `transport_request`. It now delegates to the shared `roomTransportAuthorized` - * (the single source of truth mirroring silo-server): seek is host-only, - * play/pause follows `self_can_control_transport`, nothing outside Playing. - * - * Fixtures mirror server truth: `self_can_control_transport` = isHost OR - * (Playing && guest_play_pause), so a guest_play_pause guest in the Playing - * phase has canControl=true (and is still blocked from seek by role). - */ -class TvRoomTransportGateTest { - - private fun fixture( - policy: GuestControlPolicy, - role: MemberRole, - canControl: Boolean, - phase: RoomPhase = RoomPhase.Playing, - ): RoomSnapshot = RoomSnapshot( - roomId = "room-1", - phase = phase, - guestControlPolicy = policy, - selfRole = role, - selfCanControlTransport = canControl, - ) - - @Test - fun hostMayPlayPauseAndSeek() { - val s = fixture(GuestControlPolicy.HostOnly, MemberRole.Host, canControl = true) - assertEquals(TransportGate.Send, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - assertEquals(TransportGate.Send, tvRoomTransportGate(s, TvTransportIntent.Seek)) - } - - @Test - fun guestHostOnlyBlocked() { - val s = fixture(GuestControlPolicy.HostOnly, MemberRole.Guest, canControl = false) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.Seek)) - } - - @Test - fun guestPlayPausePolicy() { - val s = fixture(GuestControlPolicy.GuestPlayPause, MemberRole.Guest, canControl = true) - assertEquals(TransportGate.Send, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.Seek)) - } - - @Test - fun noTransportOutsidePlaying() { - val s = fixture(GuestControlPolicy.GuestPlayPause, MemberRole.Host, canControl = true, phase = RoomPhase.Lobby) - assertEquals(TransportGate.Blocked, tvRoomTransportGate(s, TvTransportIntent.PlayPause)) - } - - @Test - fun nullBlocked() { - assertEquals(TransportGate.Blocked, tvRoomTransportGate(null, TvTransportIntent.Seek)) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt new file mode 100644 index 0000000000..f4b1e9da27 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt @@ -0,0 +1,231 @@ +package org.siloserver.silo.tv.ui.screens.player + +import androidx.media3.common.Player +import org.siloserver.silo.watchtogether.RoomPlayerState +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvWatchPartyPlayerTest { + private val room = WatchPartyPlaybackContext( + roomId = "room-1", + selectionRevision = 7L, + contentId = "movie-1", + fileId = 42, + libraryId = 3, + positionSeconds = 120.0, + paused = true, + ) + + private val viewModelSource = File( + "src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt", + ).readText() + + // ---- Room start and restarts ---------------------------------------------- + + @Test + fun `a first room start uses the room position and file`() { + val start = tvRoomStartContext( + context = room, + startPositionOverride = room.positionSeconds, + currentSourcePositionSeconds = 0.0, + preparedRevision = -1L, + ) + + assertEquals(room, start) + } + + @Test + fun `a restart of the prepared revision resumes where the player was, on the same file`() { + val start = tvRoomStartContext( + context = room, + startPositionOverride = null, + currentSourcePositionSeconds = 845.5, + preparedRevision = room.selectionRevision, + ) + + assertEquals(845.5, start?.positionSeconds) + assertEquals(room.fileId, start?.fileId) + assertEquals(room.selectionRevision, start?.selectionRevision) + } + + @Test + fun `a restart before the revision was prepared keeps the room position`() { + val start = tvRoomStartContext( + context = room, + startPositionOverride = null, + currentSourcePositionSeconds = 845.5, + preparedRevision = room.selectionRevision - 1, + ) + + assertEquals(room.positionSeconds, start?.positionSeconds) + } + + @Test + fun `a retry position from the previous title never carries into a new revision`() { + // Retry passes the player's last position, which still belongs to the + // previous revision until the new session publishes. + val start = tvRoomStartContext( + context = room, + startPositionOverride = 1_800.0, + currentSourcePositionSeconds = 1_800.0, + preparedRevision = room.selectionRevision - 1, + ) + + assertEquals(room, start) + } + + @Test + fun `an explicit restart position wins and an invalid one falls back`() { + assertEquals( + 300.0, + tvRoomStartContext(room, 300.0, 845.5, room.selectionRevision)?.positionSeconds, + ) + assertEquals( + 845.5, + tvRoomStartContext(room, Double.NaN, 845.5, room.selectionRevision)?.positionSeconds, + ) + } + + @Test + fun `without a room context there is nothing to start`() { + assertNull(tvRoomStartContext(null, 10.0, 0.0, -1L)) + } + + @Test + fun `a room session is published paused and solo playback is not`() { + assertTrue(tvPausedWhenReady(inWatchParty = true)) + assertFalse(tvPausedWhenReady(inWatchParty = false)) + } + + @Test + fun `every room load reuses the room context and publishes paused`() { + val load = viewModelSource + .substringAfter("private fun loadContent(") + .substringBefore("private fun startIntroAutoSkipObserver") + + assertTrue("tvRoomStartContext(" in load, "loadContent must build its start from the room context") + assertTrue( + "if (roomId != null && requestRoom == null) return" in load, + "a room player must never start without the room's context", + ) + assertTrue("room = requestRoom," in load, "the start request must carry the room context") + assertTrue("isPaused = tvPausedWhenReady(inWatchParty = requestRoom != null)" in load) + } + + @Test + fun `a room player waits for the room instead of starting the route solo`() { + val init = viewModelSource + .substringAfter("sleepTimer.configure {") + .substringBefore("fun onBackendCapabilities(") + val roomBranch = init.indexOf("if (roomId != null) {") + val soloLoad = init.indexOf("loadContent(startPositionOverride = resumePositionOverride)") + + assertTrue(roomBranch >= 0 && soloLoad > roomBranch, "the solo auto-load must sit behind the room check") + } + + @Test + fun `version and dolby vision restarts are refused in a room`() { + val version = viewModelSource + .substringAfter("fun onSelectFileVersion(") + .substringBefore("private fun restartSessionInPlace(") + val restart = viewModelSource + .substringAfter("private fun restartSessionInPlace(") + .substringBefore("fun retryServerReachability(") + val dolbyVision = viewModelSource + .substringAfter("fun onSetDolbyVisionEnabled(") + .substringBefore("restartSessionInPlace(fileId)") + + assertTrue("if (roomId != null) return" in version) + assertTrue("if (roomId != null) return" in restart) + assertTrue("if (roomId != null) return@launch" in dolbyVision) + } + + // ---- Speed --------------------------------------------------------------- + + @Test + fun `a SiloCast speed change is refused in a room`() { + assertNull(tvSiloCastPlaybackSpeed(inWatchParty = true, requested = 1.5)) + assertEquals(1.5, tvSiloCastPlaybackSpeed(inWatchParty = false, requested = 1.5)) + assertNull(tvSiloCastPlaybackSpeed(inWatchParty = false, requested = 0.0)) + assertNull(tvSiloCastPlaybackSpeed(inWatchParty = false, requested = Double.NaN)) + } + + @Test + fun `the saved speed preference is never written from a room`() { + val setter = viewModelSource + .substringAfter("fun onSetPlaybackSpeed(value: Double) {") + .substringBefore("fun onSetIntroSkipMode(") + val guard = setter.indexOf("if (roomId != null) return") + val write = setter.indexOf("setPlaybackSpeed(value)") + + assertTrue(guard >= 0 && write > guard, "the room guard must precede the preference write") + } + + // ---- Buffered positions ------------------------------------------------------ + + @Test + fun `a target inside the buffered window of the mounted item is buffered`() { + assertTrue(tvRoomPositionBuffered(targetPlayerSeconds = 105.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertTrue(tvRoomPositionBuffered(targetPlayerSeconds = 99.2, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertTrue(tvRoomPositionBuffered(targetPlayerSeconds = 130.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + } + + @Test + fun `targets behind the playhead or past the buffer need new media`() { + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 98.5, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 131.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + } + + @Test + fun `inconsistent or unknown positions are never buffered`() { + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 100.0, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 90.0)) + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = Double.NaN, currentPlayerSeconds = 100.0, bufferedPlayerSeconds = 130.0)) + assertFalse(tvRoomPositionBuffered(targetPlayerSeconds = 100.0, currentPlayerSeconds = Double.NaN, bufferedPlayerSeconds = 130.0)) + } + + // ---- Player state, notices, quality --------------------------------------- + + @Test + fun `media3 states map to room player states`() { + assertEquals(RoomPlayerState.Buffering, tvRoomPlayerState(Player.STATE_BUFFERING, hasMedia = true)) + assertEquals(RoomPlayerState.Ready, tvRoomPlayerState(Player.STATE_READY, hasMedia = true)) + assertEquals(RoomPlayerState.Ended, tvRoomPlayerState(Player.STATE_ENDED, hasMedia = true)) + assertEquals(RoomPlayerState.Idle, tvRoomPlayerState(Player.STATE_IDLE, hasMedia = true)) + assertEquals(RoomPlayerState.Idle, tvRoomPlayerState(Player.STATE_READY, hasMedia = false)) + assertEquals(RoomPlayerState.Idle, tvRoomPlayerState(null, hasMedia = true)) + } + + @Test + fun `the lower quality offer is one rung below the current height`() { + val ladder = listOf( + VideoQualityOption(id = "auto", label = "Auto", isSelected = false), + VideoQualityOption(id = "original", label = "Original", isSelected = true, resolution = "1080p"), + VideoQualityOption(id = "1080p", label = "1080p", isSelected = false, resolution = "1080p"), + VideoQualityOption(id = "720p", label = "720p", isSelected = false, resolution = "720p"), + VideoQualityOption(id = "480p", label = "480p", isSelected = false, resolution = "480p"), + ) + + assertEquals("720p", tvLowerQualityOption(ladder, fallbackCurrentHeight = null)?.id) + val onSeven = ladder.map { it.copy(isSelected = it.id == "720p") } + assertEquals("480p", tvLowerQualityOption(onSeven, fallbackCurrentHeight = null)?.id) + val onBottom = ladder.map { it.copy(isSelected = it.id == "480p") } + assertNull(tvLowerQualityOption(onBottom, fallbackCurrentHeight = null), "no rung below: no offer") + } + + @Test + fun `auto uses the playing resolution to find the next rung`() { + val ladder = listOf( + VideoQualityOption(id = "auto", label = "Auto", isSelected = true), + VideoQualityOption(id = "1080p", label = "1080p", isSelected = false, resolution = "1080p"), + VideoQualityOption(id = "720p", label = "720p", isSelected = false, resolution = "720p"), + ) + + assertEquals("720p", tvLowerQualityOption(ladder, fallbackCurrentHeight = tvQualityHeight("1080p"))?.id) + assertEquals("1080p", tvLowerQualityOption(ladder, fallbackCurrentHeight = tvQualityHeight("4K"))?.id) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvJoinCodeStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvJoinCodeStateTest.kt similarity index 59% rename from androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvJoinCodeStateTest.kt rename to androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvJoinCodeStateTest.kt index 0cf14789f4..dbaa1aeba6 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvJoinCodeStateTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvJoinCodeStateTest.kt @@ -1,17 +1,19 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether +package org.siloserver.silo.tv.ui.screens.watchparty import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class TvJoinCodeStateTest { @Test fun appendsUppercaseAndCapsAtEight() { - var s = JoinCodeState(); "abcd1234ef".forEach { s = s.append(it) } - assertEquals("ABCD1234", s.code); assertTrue(s.isComplete) + var s = JoinCodeState(); "abcd2345ef".forEach { s = s.append(it) } + assertEquals("ABCD2345", s.code); assertTrue(s.isComplete) + assertEquals("ABCD2345", s.normalized) } - @Test fun rejectsNonAlphanumeric() { - val s = JoinCodeState().append('-').append(' ').append('A') + @Test fun rejectsCharactersOutsideTheCodeAlphabet() { + val s = JoinCodeState().append('-').append(' ').append('0').append('1').append('I').append('O').append('A') assertEquals("A", s.code); assertFalse(s.isComplete) } @Test fun backspaceRemovesLast() { @@ -22,6 +24,7 @@ class TvJoinCodeStateTest { } @Test fun incompleteUntilEight() { assertFalse(JoinCodeState().append('A').isComplete) - var t = JoinCodeState(); "ABCD1234".forEach { t = t.append(it) }; assertTrue(t.isComplete) + assertNull(JoinCodeState().append('A').normalized) + var t = JoinCodeState(); "ABCD2345".forEach { t = t.append(it) }; assertTrue(t.isComplete) } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyTextTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyTextTest.kt new file mode 100644 index 0000000000..f249cab271 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyTextTest.kt @@ -0,0 +1,89 @@ +package org.siloserver.silo.tv.ui.screens.watchparty + +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvWatchPartyTextTest { + private val self = RoomMember(userId = "1", profileId = "a", displayName = "Me", isSelf = true, connected = true) + private val ana = RoomMember(userId = "2", profileId = "b", displayName = "Ana", connected = true) + private val ben = RoomMember(userId = "3", profileId = "c", displayName = "Ben", connected = true) + + private fun room( + state: RoomPlaybackState, + members: List, + revision: Long = 4L, + phase: RoomPhase = RoomPhase.Playing, + ) = RoomSnapshot( + roomId = "room", + phase = phase, + playbackState = state, + selectionRevision = revision, + members = members, + ) + + @Test + fun `members still not ready when the room plays again were left behind`() { + val waiting = room(RoomPlaybackState.Waiting, listOf(self, ana.copy(isSyncing = true), ben.copy(isSyncing = true))) + val playing = room(RoomPlaybackState.Playing, listOf(self, ana, ben.copy(isReady = true))) + assertEquals(listOf("Ana"), tvWatchPartyLeftBehind(waiting, playing)) + } + + @Test + fun `this viewer is never listed as left behind`() { + val waiting = room(RoomPlaybackState.Waiting, listOf(self.copy(isSyncing = true), ana)) + val playing = room(RoomPlaybackState.Playing, listOf(self, ana)) + assertTrue(tvWatchPartyLeftBehind(waiting, playing).isEmpty()) + } + + @Test + fun `a new selection, a room that is still waiting, or a member who left is not left behind`() { + val waiting = room(RoomPlaybackState.Waiting, listOf(self, ana.copy(isSyncing = true))) + assertTrue(tvWatchPartyLeftBehind(waiting, room(RoomPlaybackState.Playing, listOf(self, ana), revision = 5L)).isEmpty()) + assertTrue(tvWatchPartyLeftBehind(waiting, room(RoomPlaybackState.Waiting, listOf(self, ana))).isEmpty()) + assertTrue(tvWatchPartyLeftBehind(waiting, room(RoomPlaybackState.Playing, listOf(self))).isEmpty()) + assertTrue(tvWatchPartyLeftBehind(null, room(RoomPlaybackState.Playing, listOf(self, ana))).isEmpty()) + assertTrue( + tvWatchPartyLeftBehind( + waiting, + room(RoomPlaybackState.Idle, listOf(self, ana), phase = RoomPhase.Lobby), + ).isEmpty(), + ) + } + + @Test + fun `the waiting overlay names other syncing members only while the room waits`() { + assertEquals( + listOf("Ana"), + tvWatchPartyWaitingFor(room(RoomPlaybackState.Waiting, listOf(self.copy(isSyncing = true), ana.copy(isSyncing = true), ben))), + ) + assertEquals(emptyList(), tvWatchPartyWaitingFor(room(RoomPlaybackState.Waiting, listOf(self.copy(isSyncing = true), ana)))) + assertNull(tvWatchPartyWaitingFor(room(RoomPlaybackState.Playing, listOf(self, ana.copy(isSyncing = true))))) + } + + @Test + fun `name lists read naturally`() { + assertEquals("", tvWatchPartyNameList(emptyList())) + assertEquals("Ana", tvWatchPartyNameList(listOf("Ana"))) + assertEquals("Ana and Ben", tvWatchPartyNameList(listOf("Ana", "Ben"))) + assertEquals("Ana, Ben, and Cy", tvWatchPartyNameList(listOf("Ana", "Ben", "Cy"))) + assertEquals("Ana, Ben, and 3 others", tvWatchPartyNameList(listOf("Ana", "Ben", "Cy", "Di", "Ed"))) + } + + @Test + fun `an ended party explains itself`() { + assertEquals("The Watch Party has ended.", tvWatchPartyEndedMessage("host_left")) + assertEquals("The Watch Party has ended.", tvWatchPartyEndedMessage("not_found")) + assertEquals("The Watch Party has ended.", tvWatchPartyEndedMessage("ended")) + assertEquals( + "This profile joined the Watch Party on another device.", + tvWatchPartyEndedMessage("connection_replaced"), + ) + assertEquals("Lost connection to the party.", tvWatchPartyEndedMessage("connection_lost")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt deleted file mode 100644 index 89d1037e54..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/LobbyNavigationDecisionTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Pure decision test for the lobby → synced-player hand-off. Mirrors the mobile - * lobby's `lobbyPlayerDestinationOrNull` semantics: enter only when the room is - * actually [RoomPhase.Playing] AND a (non-blank) selection has landed. - * - * Route-independent — [shouldEnterSyncedPlayer] returns a Boolean over the - * landed [RoomSnapshot] (no Android types), so this stays a plain JVM unit test. - */ -class LobbyNavigationDecisionTest { - - private fun fixture(phase: RoomPhase, selectedContentId: String?): RoomSnapshot = - RoomSnapshot( - roomId = "room-1", - phase = phase, - selectedContentId = selectedContentId, - ) - - @Test - fun entersWhenSelectionSetAndPlaying() { - assertTrue( - shouldEnterSyncedPlayer( - fixture(phase = RoomPhase.Playing, selectedContentId = "m1"), - ), - ) - } - - @Test - fun staysInLobbyWhenNoSelection() { - assertFalse( - shouldEnterSyncedPlayer( - fixture(phase = RoomPhase.Lobby, selectedContentId = null), - ), - ) - } - - @Test - fun staysInLobbyWhenSelectionButNotPlaying() { - assertFalse( - shouldEnterSyncedPlayer( - fixture(phase = RoomPhase.Lobby, selectedContentId = "m1"), - ), - ) - } - - @Test - fun nullSnapshotDoesNotEnter() { - assertFalse(shouldEnterSyncedPlayer(null)) - } - - @Test - fun hostAloneHoldsOnTheInviteCodeUntilSomeoneJoins() { - val alone = fixture(RoomPhase.Playing, "m1").copy( - selfRole = MemberRole.Host, - memberCount = 1, - ) - assertFalse(shouldEnterSyncedPlayer(alone)) - assertTrue(shouldEnterSyncedPlayer(alone.copy(memberCount = 2))) - } - - @Test - fun guestEntersAnAlreadyPlayingRoomImmediately() { - assertTrue( - shouldEnterSyncedPlayer( - fixture(RoomPhase.Playing, "m1").copy( - selfRole = MemberRole.Guest, - memberCount = 1, - ), - ), - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt deleted file mode 100644 index 8619da0ad2..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.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/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt deleted file mode 100644 index fe2c96acde..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -package org.siloserver.silo.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.siloserver.silo.model.watchtogether.AddSuggestionRequest -import org.siloserver.silo.model.watchtogether.CreateRoomRequest -import org.siloserver.silo.model.watchtogether.JoinRoomRequest -import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest -import org.siloserver.silo.model.watchtogether.RoomResponse -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.SetSelectionRequest -import org.siloserver.silo.model.watchtogether.SuggestionsResponse -import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.AuthScopeSnapshot -import org.siloserver.silo.network.DefaultIdentityTransitionBarrier -import org.siloserver.silo.network.api.WatchTogetherApi -import org.siloserver.silo.repository.WatchTogetherRepository -import org.siloserver.silo.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/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuInitialActionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuInitialActionTest.kt deleted file mode 100644 index 1705a11964..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherMenuInitialActionTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import kotlin.test.Test -import kotlin.test.assertEquals - -class TvWatchTogetherMenuInitialActionTest { - @Test - fun initialActionPrefersResumeOnlyWhenAvailable() { - assertEquals( - TvWatchTogetherMenuInitialAction.Resume, - tvWatchTogetherMenuInitialAction(canResume = true), - ) - assertEquals( - TvWatchTogetherMenuInitialAction.Host, - tvWatchTogetherMenuInitialAction(canResume = false), - ) - } -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt deleted file mode 100644 index f6fdb04cc4..0000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.siloserver.silo.tv.ui.screens.watchtogether - -import org.siloserver.silo.model.watchtogether.CreateRoomRequest -import org.siloserver.silo.model.watchtogether.JoinRoomRequest -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.model.watchtogether.RoomResponse -import org.siloserver.silo.model.watchtogether.RoomSelectionMode -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.SetSelectionRequest -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.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/docs/README.md b/docs/README.md index 9b20327312..73bfccf901 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,9 +12,12 @@ Start with the root [README](../README.md) for architecture/build instructions a - Android TV navigation is Home, media-type tabs from server libraries, For You, and Calendar, plus search and profile actions. - Requests is live on phone and TV, server-gated by `requests_enabled`. -- Admin surfaces, session management, and Watch Together are not exposed in - either Android app. Device pairing stays. Treat their old routes, repositories, - tests, and design plans as inactive until a product decision exposes them. +- Admin surfaces and session management are not exposed in either Android app. + Device pairing stays. Treat their old routes, repositories, tests, and design + plans as inactive until a product decision exposes them. +- Watch Party is experimental on phone and TV, behind Settings → Experimental → + Watch Party (on in debug builds, off in release builds) and the server's room + capabilities. See [`watch-party-implementation-plan.md`](watch-party-implementation-plan.md). - All server calls use API v2. See [`api-v2/android-migration-status.md`](api-v2/android-migration-status.md). - Phone downloads preserve original filenames/formats in public storage so other apps can open downloaded videos, audiobooks, and ebooks. diff --git a/docs/playback/README.md b/docs/playback/README.md index 39fc04ec24..e0aaca02d8 100644 --- a/docs/playback/README.md +++ b/docs/playback/README.md @@ -36,6 +36,7 @@ and [migration guide](https://developer.android.com/media/media3/exoplayer/migra | [Reference review](03-reference-implementation-review.md) | Source-pinned Wholphin/Plezy observations. It is evidence, not another implementation plan. | | [Implementation status](04-implementation-status-and-dv-handoff.md) | Code, automated proof, dev-server v3 status, and the 4K Dolby Vision handoff checklist. | | [Intro skip](intro-skip.md) | Where the never/ask/always prompt lives, and the rules the server spec pins. | +| [Watch Party implementation plan](../watch-party-implementation-plan.md) | Room contract, player binding and catch-up, phone/TV experience, milestones and qualification. Server documents own wire semantics. | | [Shield 1080p capability audit](05-shield-1080p-playback-capability-audit.md) | Live protocol-v3 route matrix, catalog coverage, current direct-play gaps, and prioritized causes. | | [Device-correction evidence and design](06-device-quirk-evidence-and-design.md) | Current Jellyfin Android TV, Jellyfin Android, Wholphin, Plezy, Android platform, and issue evidence for the server/client quirk layer. | diff --git a/docs/watch-party-implementation-plan.md b/docs/watch-party-implementation-plan.md new file mode 100644 index 0000000000..8ccf427d8f --- /dev/null +++ b/docs/watch-party-implementation-plan.md @@ -0,0 +1,1032 @@ +# Android Watch Party implementation plan + +Build Watch Party for Android phone and Android TV on one shared room owner and +one shared player binding over the existing Media3 players, with native Compose +screens. Follow the current server contract, including its September 23 +buffering and catch-up policy. Use the merged Apple client as the reference +native implementation. Ship behind a device-local Experimental toggle, off by +default in release builds. + +Status: planning only. No Android implementation or playback qualification is +claimed here. This plan replaces the September 21 draft, which predates the +server's buffering policy and Apple's merged client. The June and July Watch +Together plans under `docs/superpowers/` remain history. +[Android playback architecture](playback/README.md) continues to own Media3, +playback-v3, timeline mapping, and output-route behavior. Tracking issue: +[Android #355](https://github.com/Silo-Server/silo-android/issues/355). + +## What changed from the September 21 draft + +- The server now has a buffering and catch-up policy: a 2-second buffering + grace, `self_ignore_wait`, `ready` while the room plays or is paused, no + position reports while stalled, rate convergence, and paced correction + reloads. The draft's 500 ms stall rule and "no second drift controller" rule + contradicted it. +- The snapshot anchor is already projected to send time. Projecting it again + counts elapsed playback twice (Apple fixed this in #347). +- A host who leaves ends the room two minutes later unless they rejoin. There + is no leave route; the room closes two minutes after the host's socket drops. +- Socket rotation needs a token refresh path for the ticket mint. The draft's + `singleAttempt()` rule would have ended the party at an ordinary rotation. +- Exposure follows Apple: an Experimental toggle, not a debug-only build flag. +- The player section lists every Android input and session-replacement path, + including code that landed after the draft (seek intervals, extras, named + editions, Force HDR passthrough). +- UX adds in-playback status, host-pick suggestions, end-of-file handling, + a lower-quality offer, other-playback rules, and a background table. + +## Sources and baselines + +| Source | Revision | Use | +| --- | --- | --- | +| Android | `main` at `03ac5b71` (2026-09-24) | Code baseline. Watch Together sources are unchanged since `6e26ae46`; the player gained seek intervals (#365), extras (#369), Force HDR passthrough (#360), and named editions | +| Server | `main` at `2812ac39` (2026-09-24) | Contract owner: [room phases](https://github.com/Silo-Server/silo-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/docs/architecture/watch-together-rooms.md), [realtime API](https://github.com/Silo-Server/silo-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/docs/realtime-api.md), [synchronization and buffering policy](https://github.com/Silo-Server/silo-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/docs/architecture/watch-party-synchronization.md), [capabilities](https://github.com/Silo-Server/silo-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/internal/apiv2/watch_together_capabilities.go) | +| Web reference client | Same server revision | Client timing reference: [`useWatchTogetherPlaybackSync.ts`](https://github.com/Silo-Server/silo-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/web/src/player/hooks/useWatchTogetherPlaybackSync.ts), [`roomSyncCatchup.ts`](https://github.com/Silo-Server/silo-server/blob/2812ac390829bec2b64328dcee29f0eb15a9b808/web/src/player/utils/roomSyncCatchup.ts) | +| Apple | `main` at `5d9dc764`, including [#347](https://github.com/Silo-Server/silo-apple/pull/347) | Native reference: session, playback adapter, end of file, Experimental gate, host-pick suggestions | +| Shared-dev | Record at every run | Must run server code that includes `9482302b` and advertise every required capability | + +When sources disagree, server code wins, then server docs, then the web +client, then Apple. Apple still lags the server on buffering: it reports +buffering after 0.5 s and has only a small 0.95x/1.05x correction, with no +catch-up acknowledgement, reload budget, or lower-quality offer. Follow the +server there. + +On 2026-09-24, shared-dev's `/api/v2/admin/system/build` returned +`available: false`. Read the running image and revision through the runtime +and fleet tooling instead. The last recorded inspection (2026-09-22) showed +one API node with workers disabled. That runtime cannot qualify HLS, transcode, +remux, or cross-node cases. + +## Product decisions + +| ID | Decision | Basis | Status | +| --- | --- | --- | --- | +| D1 | Add a device-local **Settings → Experimental → Watch Party** toggle on phone and TV. Debug builds default on; release builds default off. The section is visible to everyone. Turning it off hides every entry point, ignores invitations, and leaves an active party (for a host, the room ends two minutes later unless they rejoin). A server reporting `allowed=false` or missing capabilities is the remote kill switch. | Apple #347 and `5d9dc764` | Agreed 2026-09-24. Record it in `AGENTS.md` "Current Product Exposure" in the change that adds the toggle | +| D2 | Everyone gets **Leave party**. The host also gets **Return everyone to lobby** (Stop) and **End party for everyone**. A host's Leave explains that the party ends two minutes after they leave unless they rejoin. | Apple's room menu; no leave route; 2-minute host grace | Adopted | +| D3 | No new foreground service keeps the room socket alive. Background behavior follows the table under "Background and process death". Background hosting is not promised. | 2-minute host grace | Adopted | +| D4 | Invitations: room code everywhere; phone share and copy of the server's `https` invitation link (custom-scheme links aren't clickable in Messages or email); a paste parser; a TV QR code of the link. The app link is `silo://watch-party?server=&token=`, and it resolves to the same invitation as the web link. Tapping a shared `https` link opens the app only after the server's join page offers that app link. | Server `invite_path`; [Apple #409](https://github.com/Silo-Server/silo-apple/issues/409); format proposed in [Apple #412](https://github.com/Silo-Server/silo-apple/pull/412) | Not final. Build against #412's format; freeze it before the M5 link handling merges | +| D5 | While engaged, solo playback (detail Play, extras, launcher and deep links, SiloCast launch) asks the user to leave the party first. An invitation that arrives while engaged explains that the user must leave first. | Invitation message from Apple #347; the solo-playback guard is an Android decision (one shared service player) | Adopted | +| D6 | Catch-up follows the server policy and the web client: 2-second buffering grace, rate convergence between 0.9x and 1.25x kept separate from user speed, paced correction reloads, and a lower-quality offer. User playback speed is a session-only 1x. | Server sync doc; web client | Adopted | +| D7 | Party PiP, Google Cast, and external players stay disabled in rooms until each route is qualified. Starting party playback ends a solo PiP session. The party player blocks Cast connection. | Apple disables party PiP and AirPlay and ends solo PiP; Cast and external players are the Android equivalents | Adopted | +| D8 | In a party, intro and credits never skip automatically. Members who may seek get the Skip Intro offer (sent as a room seek) even when their setting is Always. Members who may not seek get no intro or credits pill. | Apple #347 | Adopted | +| D9 | Suggestions work in both modes. In a host-pick lobby, the host's **Queue** stages a suggestion and labels it "Up next"; it never promotes. While engaged, a guest's detail page offers **Suggest to Party**. The host's detail action stages the item in the lobby and selects it while playing. Android adds a confirmation before a selection that replaces what everyone is watching. | Web client; Apple #347 (Apple selects without a confirmation) | Adopted | + +## Scope + +| Area | Required behavior | +| --- | --- | +| Entry | Experimental toggle (D1); profile-menu hub; More action on movie and episode details and series (resolves to the next-up episode); Return to Party while engaged. Navigation tabs are unchanged | +| Host | Host Picks by default; create an empty room, or create and stage the chosen item; the host may start alone | +| Join | Room code, pasted invitation, validated invitation link, explicit recent-party rejoin; deferred sign-in and profile selection are preserved | +| Lobby | Empty or staged item, members and seats, advisory lobby Ready, host Start, mode switch, guest control policy, options menu with End as the only destructive action | +| Suggestions | Add, remove (own, or any as host), vote and unvote, winner display, host override when advertised, host-pick Queue (D9) | +| Picker | Server picker rows, search, series, season and episode navigation, selected-candidate member state | +| Playback | Server-owned play, pause, seek, and source; per-viewer delivery, quality ladder, audio, subtitles, and volume | +| Catch-up | Server buffering policy with client rate convergence, reload budget, and lower-quality offer (D6) | +| Party controls | Phone sheet and TV overlay over retained playback; Back opens them; Stop, End, and Leave per D2 | +| Status | Waiting and catching-up overlays, left-behind and host-disconnected notices, delayed reconnect notice, denied-action notices | +| Invitations and recents | D4; one recent party per identity for 24 hours | +| Recovery | Socket rotation, proof renewal, replacement and rejoin, identity cleanup, session reattachment, coordinated source fallback | +| Next episode | Optional, last: an explicit host Next Episode action in host-pick rooms through the selection operation | + +Out of scope: downloads, live TV, music, audiobooks, ebooks, chat, reactions, +kicking members (no server endpoint), host transfer, automatic episode queues, +activity feeds, and a room-aware SiloCast launch handoff. Admin surfaces stay +hidden. Suggestions are not a queue. + +## Server contract + +All paths are under `/api/v2`. Wire names keep `watch-together`; user-facing +text says Watch Party. + +### Capabilities + +Two documents gate the feature. Cache each within the captured authority, and +refresh on a server, account, or profile change. + +- `GET /watch-together/capabilities` (account authentication, ETag + revalidation): `state`, `allowed`, `socket_protocol`, `connection_replaced`, + `lobby_ready`, `staged_selection`, `stop_playback`, `selection_mode_switch`, + `picker`, `member_state`, `max_member_state_ids`, `vote_host_override`. +- `GET /playback/capabilities` (profile authority; PIN profiles also need their + profile token): `features[]` contains `watch_party_coordinator_v1`, + `fixed_media_file_v1`, and `watch_party_source_fallback_v1`. + +Entry requires room `state=available`, `allowed=true`, +`socket_protocol == "silo.room.v2"` (an empty string means no v2 socket) and +`connection_replaced=true`, plus a playback document that is available, allowed, +and lists the coordinator and fixed-file features. Unknown phases, roles, or +policies grant no actions. + +| Flag false or absent | Behavior | +| --- | --- | +| Required baseline | No create, join, or room playback. Show "not supported" or "not allowed"; a failed probe shows Retry and keeps any known state | +| `staged_selection` | Disable hosting and staging; joining an existing room still works. Never present direct selection as staging | +| `stop_playback` | Hide Stop; keep End and Leave | +| `selection_mode_switch` | Hide the mode switch | +| `lobby_ready` | Hide lobby Ready; never substitute playback readiness | +| `picker` | Use ordinary authorized catalog browsing | +| `member_state` (`max_member_state_ids` = 0) | Omit member watch state; never infer it from local history | +| `vote_host_override` | Hide promotion entirely. Without it, promote returns 503 | +| `watch_party_source_fallback_v1` | Keep the playback refusal visible with Retry and Leave; never pick another file locally | + +With the coordinator feature, omitted member status booleans mean false. +Without it, absence is not evidence of readiness. + +### Operations + +"Proof" means the `X-Room-Token` room access token. Retry classes come from +each operation's server `RetrySafety`. + +| Operation | Route | Needs | Retry class | +| --- | --- | --- | --- | +| Create room | `POST /watch-together/rooms` with client UUID `room_id`, `selection_mode` (`host_pick` or `vote`); returns 201 | Account and profile | Replay the same ID and body | +| Join | `POST /watch-together/join` with `code` or `join_token` | Account and profile | Idempotent; each call can return a different proof | +| Read and renew | `GET /watch-together/rooms/{id}` | Proof | Idempotent; returns a renewed `room_access_token` | +| End | `DELETE /watch-together/rooms/{id}` | Host account and profile | Converges on ended | +| Policy | `PATCH .../policy` (`host_only` or `guest_play_pause`) | Host | Idempotent; use the returned policy | +| Stage | `PUT .../staged-selection` | Host | Idempotent | +| Start | `POST .../playback/start` | Host | **Non-retryable** | +| Stop | `POST .../playback/stop` | Host | Idempotent | +| Mode | `PATCH .../selection-mode` | Host | Idempotent | +| Select | `PUT .../selection` (`content_id`, optional `file_id`, `library_id`) | Host; not in vote rooms | **Non-retryable** | +| Source fallback | `POST .../source-fallback` (`selection_revision`, `failed_file_id`, `reason`) | Proof and a connected member | Fenced; a stale call returns the current snapshot | +| List suggestions | `GET .../suggestions?limit=&cursor=` (limit 1–200, default 50) | Proof | Idempotent | +| Add suggestion | `POST .../suggestions` with client UUID `suggestion_id` | Proof | Replay the same ID and body | +| Remove suggestion | `DELETE .../suggestions/{sid}` | Proof; host or suggester (account and profile) | A repeat returns 404 | +| Vote and unvote | `POST` and `DELETE .../suggestions/{sid}/vote` | Proof | Returns 204, including when already in the requested state | +| Promote | `POST .../suggestions/promote` | Proof and host; hidden without `vote_host_override` | **Non-retryable** | +| Member state | `POST .../member-state` with 1–`max_member_state_ids` IDs | Proof | Idempotent read | +| Picker | `GET .../picker` | Proof | Idempotent read | +| Socket ticket | `POST .../ws-ticket` | Proof and an expiring access session | Safe to repeat | +| Socket | `GET .../ws` upgrade with the ticket as a subprotocol | Ticket | Not applicable | + +End, policy, stage, start, stop, mode, and select need host identity, not +proof. Promote needs both. Every successful room response carries a renewed +`room_access_token`: create, join, read, policy, selection, stage, start, stop, +mode, promote, and source fallback. + +The server gives only the host an `invite_path`: a relative web path, +`/rooms/join?token=<24-character token>`. Room codes are 8 characters from +`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`. The server trims input and matches codes +without regard to case, but it does not strip separators. + +### Socket frames + +- Server to client: `snapshot`, `transport_command`, `suggestions_update`, + `pong`, `error` (code `bad_request`), `connection_replaced`, and + `room_closed`. At connect time, `room_closed` carries `not_found` or `ended`. + Every later close, including the host's explicit End, carries `host_left`, + so the reason cannot tell End apart from grace expiry. +- Client to server: `attach_session`, `transport_request`, `state_report` + (optional `command_id` and `is_ready`), `ready` (`session_id`, + `position_seconds`, `is_paused`, `command_id`), `buffering`, `lobby_ready` + (`ready`), `ping`. There is no `client_state` frame. +- Ignore unknown frame types. A malformed frame of a known type triggers a + logged room read, not the end of the party. Apple ends the party here and + lists that as an open issue. +- `suggestions_update` carries the common list in `vote_count` descending, then + `created_at` ascending order, with every `voted_by_me` false. That field is + not authoritative; personal votes come from HTTP reads. + +Model these snapshot fields: `room_id`, `phase` (`lobby`, `playing`, `ended`), +`playback_state` (including `waiting`), `selection_mode`, `selection_revision`, +selected content, file, and library IDs, `code`, `guest_control_policy`, +`is_paused`, `anchor_position_seconds`, `anchor_updated_at`, `generation`, +`member_count`, `host_connected`, `self_role`, `self_can_control_transport`, +`self_can_manage_room`, `self_ignore_wait`, `attached_session_id`, +`invite_path`, and `members[]` with `user_id`, `profile_id`, `display_name`, +`is_host`, `is_self`, `connected`, `lobby_ready`, `is_ready`, `is_buffering`, +and `is_syncing`. Take the complete shape from the fixtures. Android's `RoomSnapshot` has no `members` today. HTTP v2 IDs are +strings; socket frames send several of them as numbers. + +### Timing and limits + +| Owner | Value | +| --- | --- | +| Server | Ticket lifetime ≤ 30 s (`expires_in` is 0 when the access token or the room proof has under a second left). Socket closes at the earliest of 5 min, access-token expiry, or proof expiry. Room proof is an HS256 JWT with `exp` 24 h after issue. Authority recheck every 15 s | +| Server | Host-disconnect grace 2 min. Presence lease 45 s. Idle room expiry 24 h. Socket write queue 64 frames; a full queue closes the socket | +| Server | Waiting deadline 10 s, and only once at least one attached member is ready; past it, the first `ready` resumes the room. Seek tolerance: guest 1 s, host 15 s (a host more than 1 s away re-anchors the room). Maximum command lead 5 s | +| Server | Buffering pauses the room only if the viewer has not stalled in 5 min, the room has not paused for buffering in 1 min, and the viewer is not already catching up. A lone viewer's stall always pauses and waits past the deadline. A buffering member gets no corrections for up to 30 s. A `state_report` within 1 s of the room with the same pause state marks the member ready. A host drifting 2 s or less is corrected like a guest; a larger jump or a pause mismatch moves the room | +| Server | `max_member_state_ids` = 200. Picker rows are capped at 20 and 30. Suggestion pages default to 50 | +| Client (web reference; tune only with evidence) | State reports every 1.5 s. Readiness retry every 500 ms. Ping every 15 s. Command quiet period 250 ms. Buffering report after 2 s of unplayable media. Catch-up band 2 s, deadband 0.35 s, rate 0.9–1.25x at `1 + drift / 8 s`. Reload budget: one at a time, 10 s doubling to 60 s, 30 s stale limit, lead ≤ 10 s, capped at the media duration. Reconnect notice after 2 s disconnected. Lower-quality offer after 2 sustained stalls within 5 min | + +### Errors + +| Response | Handling | +| --- | --- | +| 401 | Retry-safe operations, including the ticket mint: refresh under the same logical authority once, then resend. This follows Apple; the web client treats a ticket 401 as terminal, and the server marks the mint naturally idempotent. Non-retryable operations: refresh before sending when the token is near expiry; a 401 leaves the outcome uncertain, so reconcile | +| 403 `permission_denied` or `profile_verification_required` | Proof or authority failure on membership operations ends the engagement. A guest calling a host operation gets an action-level denial | +| 404 | On a room operation, the room is gone: end the engagement. On Join, an unknown code or token: action-level. A missing suggestion: action-level | +| 409 | Ambiguous: it means both a closed room and an ordinary conflict (vote room, nothing staged, already playing, no fallback candidate, reused ID). Reconcile with `GET /rooms/{id}`; a 409 there means closed | +| 422 | Action-level invalid input, such as a missing code or an unplayable selection | +| 429 `rate_limited` | Action-level; honor `Retry-After` | +| 503 | Transient; retry reads, and reconcile mutations | +| Malformed 2xx | `invalid_response`. For a mutation, the outcome is uncertain: reconcile, and never report success or failure blindly | +| Ticket `expires_in: 0` | Recoverable: refresh the access token or renew the proof, whichever is expiring, then mint again | +| Ticket 403, 404, 409, 422; `room_closed` at connect | Terminal | + +Use `detail` text for display only, never for control flow. + +## Design and ownership + +| Boundary | Responsibility | Code to evolve | +| --- | --- | --- | +| Wire and API | Typed capabilities, operations, frames, response validation, retry classes, error mapping | `shared/.../model/watchtogether/WatchTogetherModels.kt`, `network/api/WatchTogetherApi.kt`, `network/WatchTogetherRealtimeClient.kt`, `network/apiv2/ApiV2Call.kt` (`ownedV2Call`) | +| Room engagement | One app-level engagement: authority, membership, proof, socket, reconnect, clock, snapshot ordering, actions, votes, recent party | `shared/.../watchtogether/RoomSession.kt`, `repository/WatchTogetherRepository.kt`, `RoomTransportAuthority.kt`, `RoomVote.kt`, `WatchTogetherEntryPolicy.kt` | +| Player binding | Room playback context, paused preparation, attach, command scheduling, readiness, reports, catch-up, reload budget | New `android-shared/.../player/watchtogether/`; absorbs `RoomSyncEngine.kt`, `RoomDeliveryLatch.kt`, `RoomSyncStateReportGate.kt`, and the duplicated mechanics in both controllers | +| Player adapters | Connect the binding to each player: observed position, completed load and seek, buffering, media mount identity, input gating | `RoomSyncController.kt`, `TvRoomSyncController.kt`, both player ViewModels, `MobileVideoPlaybackStarter.kt`, `TvVideoPlaybackStarter.kt` | +| Shared presentation | Entry, lobby, suggestion, and picker state and actions; eligibility; pending and error state; domain destinations | Merge the duplicated phone and TV ViewModels into `shared/` or `android-shared/` | +| Platform UI | Phone layout and sharing; TV layout, focus, and QR; route construction | Both `ui/screens/watchtogether/` directories, menus, detail actions, settings, navigation | + +The application owns one engagement. Its room socket survives ordinary +navigation. The player binding owns only the current playback epoch and its +player observers. The room socket and the playback-control socket keep +independent connection state and recovery. + +Keep these identities distinct: + +- **Authority:** verified server (base path included, matched with + `AndroidServerRegistry.serverIdsMatch`), `loginId`, profile, PIN scope, and + identity generation. Unlocking the same PIN profile again does not change the + owner. +- **Membership:** authority, room ID, and engagement generation. Joining the + same room again creates a new membership. +- **Proof:** the renewable room token and its `exp`. Renewing proof never + changes membership. +- **Socket attempt:** connection epoch. +- **Playback epoch:** membership plus `selection_revision`. +- **Attachment:** playback epoch plus the committed playback session ID. +- **Command:** command ID, with pending and applied state. + +Every asynchronous result checks the identities its effect depends on. +Replacing the account, profile, PIN authority, or server cancels old work +before the new identity can use it. + +### Room transitions + +| Accepted state or event | Client action | +| --- | --- | +| Lobby, no selection | Empty lobby; no player | +| Lobby, staged item | Staged lobby; no player | +| Stage, restage, or mode switch | Update the lobby and lobby Ready; no playback epoch | +| Playing with a new `selection_revision` (Start, Select, Promote, fallback) | Cancel the old epoch; prepare the exact selected file, paused | +| Playing with a membership-only or anchor-only update | Update room state; keep the player | +| `playback_state=waiting` | Apply the waiting command; acknowledge readiness (see Readiness) | +| Playing or paused with `self_ignore_wait` or own member `is_buffering` | Catching up (see Buffering) | +| `host_connected=false` while engaged | Tell guests the party ends two minutes after the host drops off unless the host returns | +| Host Stop | End the playback epoch; show the retained staged lobby; keep membership | +| Ended snapshot or `room_closed` (any reason) | End the engagement, player, reports, commands, and reconnects. Say the party ended; say who ended it only when this device did. Clear the recent party. No current server path sends an ended snapshot, but keep the check | +| `connection_replaced` | End this device's engagement; offer explicit Rejoin; the room continues | +| Leave or identity replacement | Cancel work, stop the room player, close the socket, clear identity-scoped state. For a host, the room ends two minutes later unless they rejoin | + +Snapshot acceptance rejects results from an older room, authority, revision, or +generation. An HTTP response with the same generation must not erase a newer +socket attachment or readiness update: track socket receipt order as well as +generation. A stale response cannot reopen a terminal engagement. Accept a +renewed proof from a response even when its snapshot body is stale. + +## Playback rules + +### Room playback context and start + +- Build the context from authority, room, `selection_revision`, content, file, + library, the snapshot's paused state, and `anchor_position_seconds` **as + sent**. The server has already projected it; the command sent after attach + corrects the remaining drift. Resolve series or season entry to a playable + episode before building the context. +- Pass the context through both starters. Room starts send + `allow_alternate_versions: false`, and only to servers that advertise + `fixed_media_file_v1`. The field is nullable so solo starts omit it. The + server stores it with the attempt and keeps it through every replan. Refuse + any plan for a different file. +- Room playback always streams: bypass downloaded copies. Never fall back to + personal resume or a preferred alternate version. If the chosen file is + inaccessible, fail with a clear message and follow the fallback rules below. +- Prepare paused. Nothing plays until an accepted room command says so. + +### Session replacement paths + +Each of these paths can create a new playback session. Each must carry the room +context, start paused at the room position, keep the fixed file, and hold +reports until the matching attach echo arrives: + +- 404 session renewal, the only path that uses `StartParams` today + (`PlaybackSessionLifecycle.kt`). +- `replanActiveVideoSession` (`PlaybackSessionManager.kt`), reached from a + phone output-route change, an embedded-subtitle failure, an audio failure, a + subtitle inventory change, a player error, and a queued re-drive. +- `stageActiveVideoSessionReplan` (`MobileSubtitleTransactionAdapter.kt`, + `TvSubtitleTransactionAdapter.kt`), reached from user subtitle changes and + the TV output-route change. +- `onUnsupportedPlayback` in both ViewModels. +- Seek recovery and seek re-anchoring in `PlaybackSessionManager.kt` and both + ViewModels. +- Phone full restarts: `startVersionPlayback`, subtitle-adoption recovery, and + `replayLoad` (Retry, Try Anyway). +- TV full restarts: `restartSessionInPlace`, reached from the version picker + and the Dolby Vision toggle; `retry` and the reachability retries call + `loadContent` directly. + +Both ViewModels currently publish `isPaused=false` when the player becomes +ready. Room playback must not. + +### Attach and report + +- Attach the committed session after every room-socket epoch and every session + ID change. Wait for the matching `attached_session_id` before reporting. A + playback-control socket reconnect for the same session needs neither. +- Send a `state_report` every 1.5 s built from observed player state: the + source-timeline position from the existing timeline mapper, and whether the + player is actually playing. Today's reports send the intended paused state and + an optimistic position; replace that. +- Send no report while stalled, while the media is unplayable and a readiness + acknowledgement is pending, during provisional preparation or replacement, + during a local suspension, or before re-syncing after one. + +### Commands and clock + +- Apply accepted commands through an internal path, separate from user + transport requests, so they are never echoed back as intent. +- Sample the clock with ping and pong from socket open, before a player + attaches. Stamp pong receipt at transport ingress and fence samples by + connection epoch. Reject invalid samples and detect clock discontinuities. + Pings run every 15 s. +- Schedule with the server-clock estimate and a monotonic delay (at most the + 5 s lead). A late Play advances its target by the elapsed server time. With + no valid sample, stay paused at the snapshot anchor, obtain one, and bound + that wait with a recovery offer instead of assuming zero offset. +- Track pending and applied command IDs. A superseding command cancels delayed + work. A cancelled command never executes later and is never acknowledged. + Recheck ownership immediately before execution. + +### Readiness while the room waits + +- Readiness is level-triggered and keyed to the current command, epoch, and + session. Evaluate it on every Media3 signal that can mean playable media + (`STATE_READY`, a seek discontinuity, `onIsPlayingChanged`, first frame) and + on a periodic tick. It requires: the latest command executed, no pending + seek, playable media, and, for an explicit seek, an actual position within + 1 s of the destination (15 s for the host). +- Send `ready` with `command_id`, the actual position, and the pause state. + Retry every 500 ms, and also send `state_report` with `command_id` and + `is_ready: true`, until a fresh snapshot on the current connection marks the + own member `is_ready`. Socket delivery alone does not settle it, and neither + does an `is_ready` that predates the command. Then return to ordinary + 1.5 s reports. +- A second seek on the same session re-arms readiness even if buffering never + changes and the room stays waiting. + +### Buffering and catching up + +- Stalls under 2 s stay local. After 2 s of unplayable media while the room + plays, send one `buffering`. Recovery, a new command or session, a pause, a + phase change, a disconnect, or disposal cancels a pending report. +- The room is catching up for this viewer when the phase is `playing`, the + playback state is `playing` or `paused`, and the snapshot shows + `self_ignore_wait` or the own member `is_buffering`. Once the latest command + has executed and the media is playable, send `ready` and retry every 500 ms + until the own member is `is_ready`. The server clears the status. While the + room plays, it then sends a command at the room position; a paused room + sends none. A viewer catching up with nobody else watching moves the room to + its own position instead. +- A late joiner or a replaced session does not pause the room. It syncs alone, + and its startup counts as that viewer's stall. + +### Corrections + +Follow `decideRoomCatchup` in the web client: + +1. Explicit seek commands always seek, immediately, outside any budget. +2. Drift within 0.35 s: do nothing. +3. A target the player can reach in the current stream: seek. +4. A pause correction that needs new media: do nothing. The next Play realigns. +5. Drift over 2 s: seek, including a re-anchor or replan. +6. Otherwise converge by rate: `1 + drift / 8 s`, clamped to 0.9–1.25x, + tracking the room position as it advances, until within 0.35 s. Then reset + to exactly 1x. + +A Play correction whose target isn't buffered, and so needs a range request or +a stream rebuild, goes through the reload budget. Only one runs at a time. The +next waits 10 s after the previous one started playing, doubling to 60 s until +the viewer converges. An unlanded reload stops blocking after 30 s. Each +reload aims ahead by the previous load time (at most 10 s) and never past the +media duration. A quality change restarts the budget. + +The correction rate is not a user speed. It is never saved or shown as a speed +change, and it always returns to 1x. Media3 may not apply speed changes with +audio passthrough, offload, or tunneling. Verify this on TV. Where speed cannot +change, use a seek inside the reload budget instead. + +After 2 sustained stalls within 5 minutes, offer one step down the quality +ladder of the same file, once per quality. Withdraw the offer if no lower rung +remains or the quality changes. + +### Host reports and local suspensions + +A host report can move the whole room: a jump of more than 2 s or a pause +mismatch re-anchors it. The host must never report a position that isn't a +decision. These events are local suspensions, not room intent: + +| Event | Where today | +| --- | --- | +| Audio focus loss or becoming noisy | Deliberately not reconciled (`PlayWhenReadyReconciliation.kt`) | +| Sleep timer | Sets `isPaused` directly (both ViewModels) | +| TV `ON_PAUSE` and `ON_STOP` | `TvPlayerScreen` pauses, then exits and leaves the room on stop | +| Phone background | Keeps playing; automatic PiP entry is not room-gated | +| Cast connect | Calls `remotePause` | +| Subtitle preparation that holds playback (Apple's subtitle AI case) | Not confirmed on Android: the subtitle remount keeps `playWhenReady`. Audit every subtitle path in M3 | +| Stall recovery (seek back, re-prepare) and the audio-sync no-op seek | Local recovery actions | +| Output-route change | Replans | + +During a suspension, send no reports and no transport requests. On resume, +re-sync before reporting. While the room plays, re-attach the current session: +the server answers with a command at the projected room position (or the +waiting command) and clears this member's catching-up state. While the room is +paused, the server sends nothing; apply the snapshot anchor locally. Guests +follow the same rule. Apple asks the room to resync after subtitle AI +preparation. + +### Input permissions + +Route every input through one decision. Guests never seek; play and pause +follow the room policy. A denied input changes nothing locally and shows a +notice. An input during a reconnect shows an immediate notice; it is neither +dropped silently nor queued ([Apple #410](https://github.com/Silo-Server/silo-apple/issues/410)). + +| Input | Today | Required | +| --- | --- | --- | +| Touch transport, gestures, scrubber, D-pad and media keys | Partly gated | Room request | +| Chapter selection (phone) | Bypasses the gate | Room seek request (host) | +| Intro and credits pill | Phone pill bypasses the gate; phone auto-skip is not pinned (TV pins it to Ask) | D8 | +| MediaSession play and pause, notification, headset, Assistant | Play and pause reconciled | Room request | +| MediaSession seek back and forward via `SeekIntervalForwardingPlayer` | Calls `seekTo` with no room check | Room seek request | +| PiP actions (`SiloPlaybackService`) and automatic PiP entry | Not room-gated | Disabled in rooms (D7) | +| SiloCast transport from a phone (`TvSiloCastReceiver`, `TvSiloCastPlayerAdapter`) | Play, pause, seek, and stop suppressed in rooms, hosts included (`TvPlayerScreen`) | Route through the TV membership's permission decision, so a host's phone can control the room | +| SiloCast `setPlaybackSpeed` | Ungated; writes the saved preference | Refuse in rooms; never write the preference | +| SiloCast launch request | Replaces the party player with solo playback | D5 prompt | +| Playback-control socket commands | Admin Stop is classified as transport and rejected | Transport follows the room; admin Stop and terminate end the local engagement (for a host, the 2-minute grace starts) | +| `PlaybackDebugReceiver` play, pause, seek | Call Media3 directly | Fault injection only; never evidence of room behavior | +| Version and edition picker (phone Quality selector, TV HUD) | Enabled; picks another file | Disabled in rooms. Quality means the ladder of the same file | +| Speed menu and hold-to-2x | Saved speed applied in rooms | Hidden in rooms; session-only 1x; restore the preference on exit | +| Autoplay, next-up, postroll | Autoplay already suppressed | Suppress; optional host Next Episode (Scope) | + +### Source fallback + +Request coordinated fallback only for `no_alternate_version`, +`hdr_transcode_unsupported`, `subtitle_conversion_unsupported`, and +`transcoding_disabled`, and only when the capability is advertised. Send the +failed file and selection revision, including for a startup refusal before +attach. A successful fallback changes the source and epoch for everyone. +Access and network failures never trigger fallback. If no candidate remains, +keep the original refusal. Report actual decode and output capabilities with +no blanket 1080p or SDR cap. Log the capability snapshot, including the Force +HDR Passthrough flag, with each fallback request: one under-reporting TV can +move the whole room to an SDR file. + +### End of file + +Match Apple (`WatchPartyEndOfFileTests.swift`): an ended player counts as ready +and paused for reports and commands. A room seek after the end remounts the +stream at the target, paused. Local seeks after the end go to the room. A +premature end remounts at the drop point without marking the item watched. Play +from the end is not sent to the room. Solo playback, next-up, and the postroll +are unchanged. Apple leaves repeated premature ends uncapped and lists that as +an open issue; Android caps the remounts and then shows the playback error. + +### Other playback and teardown + +- While engaged, the D5 prompt guards every solo start: detail Play, extras + (`ItemDetailScreen`, `TvItemDetailScreen`), launcher and deep links, and + SiloCast launch (`TvAppNavigation`). The service player is shared (`ActivePlayerHolder`), so + nothing may start without passing that guard. Picker cards never start solo + playback. +- Starting party playback ends a solo PiP session. Entering a party while + casting asks the user to stop casting first. +- Teardown is idempotent across Stop, End, Leave, replacement, identity switch, + navigation, and process recovery. Late player callbacks cannot affect the + next solo or room session. Disposing a screen binding does not leave the + room. + +## Experience + +### Entry and exposure + +- Add the Experimental section to phone Settings and TV Settings (D1). Store + the toggle locally on the device and exclude it from settings sync, like + `player.force_hdr_passthrough`. Inject the effective policy at the platform + boundary, the way both DI modules inject `SiloClientBuildIdentity`. Replace + the `CLIENT_WATCH_TOGETHER_SURFACE_ENABLED` constant at its eight production + call sites (`MainScreen`, `ItemDetailScreen`, `TvMainShell`, + `TvItemDetailScreen`). Gate + on build type, not the APK file name: the published `*-debug.apk` is a + release build. +- Show entry points for movies, episodes, and series (resolved to the next-up + episode). The TV detail screen currently offers entry for any non-audiobook + type; restrict it. Stage the file of the displayed edition, and show the + selected edition in the lobby. +- The hub offers Host, Join by code, Paste invitation, and Rejoin recent party, + plus availability states: unsupported, not allowed, and failed probe with + Retry. Hosting defaults to Host Picks; the menu creates Vote rooms today. +- Normalize codes the same way on both platforms: trim, remove whitespace and + dashes, uppercase, and check for 8 characters from the server alphabet. + +### Lobby, suggestions, and picker + +- Pass a title preview from the detail page or picker so the lobby lays out + once. Start stays disabled until the room confirms the selection. The host's + seat appears after the socket connects; show a placeholder until then. +- Lobby Ready is advisory and counts connected guests. It is separate from + playback readiness. The host may start alone and before everyone is ready. + Promotion starts playback directly with no extra ready step. +- Rank suggestions by the server rule (votes, then creation time), independent + of HTTP pagination order. Drain pages under one captured authority, merge + without truncation, and handle more than 50 items. Treat personal votes as + unknown until an HTTP read, and keep known flags across socket broadcasts. + Reconcile after entry, reconnect, a vote mutation, or an uncertain outcome. + A successful mutation followed by a failed list read is still a success with + stale display state. +- The picker uses server rows. Continue Together is empty unless two or more + connected members have progress; hide empty rows. The phone may add the + chooser's own Continue Watching row, as iOS does; TV follows tvOS and web and + leaves personal rows out. Search is debounced and cancels stale responses. + Series flow to seasons and episodes, and an episode offers "See all + episodes". Fetch member state only for the selected candidate; treat omitted + IDs as unavailable, not unplayed. Watchlist and progress rows do not prove + every member can play an item. + +### During playback + +- Back opens the party panel over the retained player: a phone sheet or a TV + overlay. Everyone sees Leave and Return to playback; the host also sees Stop + and End (D2). Closing the panel never tears down the player. +- Status, matching the web client: + - "Syncing playback · Waiting for …" names the members the room waits for. + - "Catching up to the party" and "The party kept playing" for the viewer who + is catching up. + - "Continuing without …" for everyone else when the room leaves someone + behind (`roomMembersLeftBehind`). + - "The host stopped playback" on Stop. + - A host-disconnected notice when `host_connected` is false. + - A reconnect notice after 2 s disconnected, timed from the first + non-connected state and cleared on reconnect. + - Immediate notices for denied actions and taps during a reconnect. Repeated + notices show again. + - The lower-quality offer. +- Keep focus stable on TV through live snapshots, vote reordering, and loading, + error, and empty states. Keep pending buttons focusable while disabled. Live + updates must not trigger a TalkBack announcement each time. + +### Invitations and recent parties + +- Build the host's invitation link by resolving `invite_path` against the + verified server URL, base path included. Guests share the code only. +- The paste parser and deep-link handler accept a bare code, a server + invitation link, and a `silo://watch-party` link (D4). Validate the app link + as strictly as Apple #412 does: an `http` or `https` server with no userinfo, + query, or fragment, and exactly one non-empty `server` and `token`. Scheme + matching ignores case. Reject `silo://invite`, which is the account + invitation link. Add a `watch-party` host to the phone manifest's `silo` + intent filter. TV queues deep links without identity scoping today; + port the phone's `ExternalRouteScope` model before TV accepts invitation + links. +- Match the link's server against the current one with `serverIdsMatch`. A + foreign server goes through the normal server, sign-in, and profile flow and + never reuses current credentials. A LAN URL and a public URL for the same + server count as different. +- The join token is a secret. Keep it out of route strings, saved state + (`consumedExternalRoute`), logs, and the recent-party store. +- The TV QR code encodes the invitation link, not the bare code. +- Store one recent party in `SecureSharedPrefs` for at most 24 hours, keyed by + server ID, `loginId`, and profile. Store the code and display metadata only, + never a room token. Rejoin joins again by code for fresh proof. Remove the + entry on a terminal state, on expiry, on sign-out, on server removal, and on + profile deletion. Never show it under another identity. A replaced + connection keeps the entry for explicit Rejoin. + +### Background and process death + +| Situation | Phone | TV | +| --- | --- | --- | +| Lobby, app backgrounded | Keep the engagement while the process lives. A host away longer than the grace, with its socket closed, ends the room | Same | +| Playing, app backgrounded | Local suspension: stop playback, send no reports. Resync on return | `ON_STOP` becomes a local suspension; it no longer leaves the room | +| Host socket lost for more than 2 min | Room ends for everyone (`host_left`); show the reason | Same | +| Activity recreation (font scale; rotation is handled in-activity) | Engagement survives; the screen rebinds | Same | +| Process death | A restored room route opens the hub with a Rejoin prompt. The room token is memory-only, so never re-adopt silently | Same | + +### Copy and documentation + +Use "Watch Party" in all user-facing text. Rename the 18 existing "Watch +Together" and "Watch together" strings, including shared repository errors, the +phone share text, and `ProfileMenu`. +The app has no localization setup; this plan adds none. + +## Milestones + +All milestones are pending. Write focused tests with each one. M0 fixtures can +run alongside M1. M4 can run alongside M3. Phone and TV layouts can proceed +independently once M4's actions are stable. Keep commits scoped to one +concern. Do not open a pull request unless asked. + +| Milestone | Scope | Depends on | Exit evidence | +| --- | --- | --- | --- | +| M0 | Decisions, environment, fixtures, harness | — | D1 signed off; pinned runtime and devices; a two-device observation run | +| M1 | Wire contract | M0 fixtures | Fixture and API tests; both apps compile | +| M2 | Room engagement owner | M1 | Lifecycle, ordering, renewal, and action tests | +| M3 | Player binding and catch-up | M1–M2 | Player-boundary tests; first live two-player checkpoint | +| M4 | Shared lobby, suggestions, picker | M2 | Action, ranking, and pagination tests | +| M5 | Native UI, gate, invitations | M3–M4 | Rendered phone and TV flows | +| M6 | Qualification matrix | M3–M5 | Matrix passes with native playback evidence | +| M7 | Cleanup and enablement | M6 | Checks green; exposure matches D1 and the evidence | + +### M0: Decisions, environment, fixtures, harness + +- [x] D1 agreed 2026-09-24. +- [ ] Record D2, D3, and D5–D9. Track D4 against Apple #412 and the server + join page. +- [ ] Pin Android, server, web, and Apple revisions for each run. Confirm + shared-dev includes `9482302b` and advertises every required capability. + Record the image through the runtime or fleet tooling. +- [ ] Build the fixture corpus in `shared/src/commonTest/resources/watchtogether/v2/` + with a `SOURCE` file naming the server revision and a sync script modeled on + `scripts/sync-apiv2-fixtures.sh`. Load fixtures through the + `ApiV2FixtureSupport` expect/actual pattern; plain `commonTest` resources are + only on the Android unit-test classpath. Label fixtures derived from Apple's + `WatchPartyAPITests`, `WatchPartyStateTests`, and + `WatchPartyEndOfFileTests`. +- [ ] Register room log attributes in the diagnostics attribute registry, with + its parity test. Debug builds throw on unregistered attributes. +- [ ] Extend the harness. `PlaybackDebugReceiver` status gains elapsed-realtime + and wall-clock timestamps, the server-clock estimate, the source-timeline + position, room phase, playback state and revision, connection epoch, + committed and attached session, pending and applied command, catching-up + state, stall count, reload-budget state, and current rate. Never expose + tokens or tickets. Add any new receiver actions to both manifests. Add a + two-device runner to `scripts/android-playback-test.sh` that parses JSON + instead of the current flat `sed`. +- [ ] Build deterministic seams: fake clock, socket, player, and HTTP, with + delayed responses, closures, bursts, reordering, failed attach and ready, + source refusals, and clock jumps. +- [ ] Allocate participants: a separate profile per simultaneous participant, + a reused profile only for replacement tests, and restricted profiles for + access tests. `silo-runtime dev api` authenticates only as admin; probe + participant capability and room behavior with participant credentials from + local configuration, including `X-Profile-Token` for PIN profiles. +- [ ] Devices: claim phone and TV emulators through the device hub and never + bypass another task's claim. Both apps use `org.siloserver.silo`, so each + participant needs its own device. Debug builds are signed with each build + host's debug keystore; install from one host or uninstall first. Don't clear + app data as routine setup. +- [ ] Peers: the web client on shared-dev through browser automation; Apple + simulators from Apple `main` with Experimental → Watch Party on, via the Mac + build workflow; a two-API stack with workers from the dev-builder for + cross-node and worker-dependent deliveries; network shaping through the + emulator console on owned emulators, or a proxy. +- [ ] Each run gets a unique room, an artifact directory, and a participant + set. Cleanup closes only rooms the test created and releases only its own + device claims, even when the test fails. + +Exit: two Android clients can be observed at once with correlated timestamps, +and every run records its runtime, fixtures, and participants. + +### M1: Wire contract + +- [ ] Type both capability documents and the entry baseline. Keep "unsupported", + "not allowed", and "probe failed" distinct. +- [ ] Complete the room, member, member-state, picker, and suggestion-page + models from fixtures, including v2 string IDs and numeric socket IDs. +- [ ] Implement every operation in the table through `ownedV2Call`, validating + the status, required fields, and IDs. A malformed 2xx is `invalid_response`. + `safeApiV2Call` currently turns it into a network error. +- [ ] Keep room and suggestion UUIDs and exact bodies in the logical action, not + in each network attempt. Today they are generated per attempt. +- [ ] Apply retry classes. `singleAttempt()` only skips the pinned-scope 401 + refresh; it doesn't stop OkHttp's connection retry, which is still on for the + API client. Prove with a request-count test that non-retryable operations + send at most once; if they don't, route them through a client with + `retryOnConnectionFailure(false)`, as the player's auxiliary call factory in + `PlayerOkHttpClient` does. Retry-safe + operations, including the ticket mint, get one refresh and resend on 401. +- [ ] Adopt `room_access_token` from every 200 room response without changing + membership. Read `exp` from the token. +- [ ] Decode `connection_replaced` as terminal. Add `lobby_ready`, + `ready.command_id`, `state_report.command_id` and `is_ready`, and + `self_ignore_wait`. Ignore unknown frame types; reconcile after malformed + known frames. +- [ ] Validate tickets: `expires_in` from 0 to 30, `max_connection_seconds` + from 1 to 300, and header-safe characters. Handle 0 as in the errors table. + The events client's `validTicket` rejects 0, so adapt it rather than reuse it + unchanged. Check the negotiated protocol. Mint a fresh ticket for each + attempt. +- [ ] Add nullable `allowAlternateVersions` to `PlaybackStartRequestV3`, its + wire body (`allow_alternate_versions`), start allocation, and `StartParams`. +- [ ] Add the error mapping table, including the 409 reconcile rule. +- [ ] Add fixture tests for re-attaching the same session in a playing room + and in a paused room (see Host reports and local suspensions). + +Exit: fixtures cover every request and response shape, authorization header, +negative capability case, retry class, and error mapping. Both apps compile. + +### M2: Room engagement owner + +- [ ] Consolidate transitions and eligibility. Remove the content-present and + member-count shortcuts (`WatchTogetherEntryPolicy`, + `WatchTogetherLobbyViewModel`). Treat an ended snapshot as terminal even + without `room_closed`. +- [ ] Separate membership from proof. Renew through `GET /rooms/{id}` before + `exp`, leaving more than 5 minutes plus the ticket lifetime. If renewal fails + terminally, end the engagement and offer Rejoin. +- [ ] Fix same-room rejoin: `RoomSession` returns early for a matching room ID + today. A new binding replaces the old connection job, and the old socket + closes before the new owner takes over shared state. +- [ ] Order snapshots by persisted generation and socket receipt order. Fence + suggestion reads separately from room mutations. +- [ ] Take suggestion hydration off the socket receive path. Replace the + unchecked `trySend` and drop-oldest flows with reliable delivery or an + explicit recovery read. Drain terminal frames before treating closure as a + transient failure. +- [ ] Reconnect at once after a socket that stayed up (the 5-minute rotation). + Back off from 0.5 s to 5 s, with jitter, only for sockets that fail quickly. + Keep trying for at least the host grace period, unless a terminal signal or + an authority change stops it. The current budget gives up after six failures + (about 13.5 s of backoff). Never reclaim after `connection_replaced`. +- [ ] Move clock sampling to the room socket, from socket open (see Commands + and clock). +- [ ] Serialize conflicting room and vote actions. Capture displayed ownership + and inputs before dispatch. Reconcile uncertain Start, Select, Promote, and + Stop outcomes without replaying them. Expose pending state so repeated taps + can't reorder work. +- [ ] Persist the recent party as described under Invitations and recent + parties. + +Exit: tests cover stale and equal-generation HTTP, ended without close, +replacement, same-room rejoin, proof renewal, blocked hydration, event bursts, +conflicting votes, rotation reconnect timing, and uncertain non-retryable +mutations. + +### M3: Player binding and catch-up + +- [ ] Room playback context through both starters and every session-replacement + path. Paused preparation. Download and resume bypass. +- [ ] Extract attachment, reporting, scheduling, readiness, catch-up, and the + reload budget from the two controllers into the shared binding. Keep thin + adapters that expose observed position, completed load and seek, buffering, + and media mount identity. +- [ ] Reports from observed state; report suppression; buffering after 2 s; + catch-up acknowledgement. +- [ ] Correction policy with rate convergence and the reload budget. Verify + Media3 speed behavior with passthrough and tunneling on TV. +- [ ] Local suspensions and resync. Host report guards. +- [ ] One input-permission decision for every row in the input table. +- [ ] 1x session speed; speed UI hidden in rooms; preference restored on exit. +- [ ] Version and edition pickers disabled in rooms. +- [ ] Coordinated fallback with a capability snapshot in the log. +- [ ] End-of-file handling. +- [ ] Other-playback guard (D5), PiP, and Cast rules (D7). +- [ ] Idempotent teardown. + +Exit: deterministic binding tests pass. Then, under a debug entry, two real +Android players attach separate sessions to the same file and pass pause, seek, +Stop and Start, rotation reconnect, a late join without a room pause, and one +catch-up after the deadline. Repeat the core flow with a web peer. This is the +first live playback checkpoint. + +### M4: Shared lobby, suggestions, picker + +- [ ] Replace the duplicated phone and TV entry, lobby, and suggestion + ViewModels with one shared state and action owner that exposes domain + destinations. Platform navigation builds the routes. +- [ ] Centralize eligibility for stage, start, lobby Ready, stop, end, leave, + mode, policy, suggest, vote, remove, promote, queue, and rejoin. It depends + on phase, role, capabilities, and pending work. Removing a suggestion matches + both user and profile. +- [ ] Host Picks by default. Stage from a detail page with the displayed + library and file. If creation succeeds but staging fails, keep that room and + offer retry or leave; never create a second room silently. +- [ ] Suggestion ranking, pagination, personal votes, and Queue (D9). +- [ ] Picker rows, search, episode navigation, and member state with captured + authority and bounded reads. +- [ ] Give both platforms the same guest policy, mode switching, errors, + pending behavior, connection status, and rejoin semantics. + +Exit: shared tests cover eligibility, ordering, ranking across pages, restricted +catalog responses, stale search, partial create-and-stage failure, and +identical phone and TV destinations. + +### M5: Native UI, gate, invitations + +- [ ] Experimental toggle and injected policy (D1). +- [ ] Hub, entry points, and availability states. +- [ ] Lobby, suggestions, picker, and options menu on phone and TV, built from + existing components. +- [ ] Party panel over the retained player; the Back behavior in D2. +- [ ] In-playback status and notices. +- [ ] Invitations: phone share and copy, paste parser, TV QR, scoped deep links, + and the leave-first message (D4, D5). +- [ ] Background and process-death behavior from the table. +- [ ] TV focus with the existing helpers (`TvControlState`, + `tvControlSemantics`, `tvModalFocusBoundary`, + `TvRestoreFocusOnModalDismiss`, `TvReturnTarget`). Phone + rotation, font scale, large text, touch targets, TalkBack labels, and tablet + layout. +- [ ] String rename. + +The phone app has Robolectric but no Compose UI test dependency. Adding one +means updating the dependency lockfiles and verification metadata. Keep UI +tests to the flows this plan requires. + +Exit: phone and TV complete every scoped flow through rendered UI, keep +playback behind the party panel, and recover from errors without losing focus +or creating duplicate rooms. Debug routes and real entry points call the same +actions. + +### M6: Qualification + +- [ ] Run the validation matrix on shared-dev with phone and TV emulators, then + the web and Apple pairings. Run cross-node and worker-dependent cases on a + two-API stack with workers. Record actual revisions and runtime identities. + Another client's pass does not count for Android. +- [ ] Soak for at least 8 minutes to cover socket rotation. Test proof expiry + with an accelerated clock in fixtures rather than waiting 24 hours. +- [ ] Investigate each failure at the boundary that owns it, add a regression + test, and rerun the affected rows. Record unavailable cases as skipped, never + as passed. + +Exit: all required emulator and software rows pass with native playback +evidence. HDR, Dolby Vision, HDMI passthrough, and physical-remote behavior +need capable hardware before any claim. Keep unqualified routes gated. + +### M7: Cleanup and enablement + +- [ ] Delete duplicated controller mechanics, the old entry policies, dead + readiness fields, and source-text tests that behavioral tests now cover. +- [ ] Remove `ClientSurfacePolicy`'s constant and replace its test with + per-platform, per-build-type policy tests. +- [ ] Update comments and docs that describe immediate selection, a fixed + selection mode, solo-host waiting, or readiness as buffering only. Update + `AGENTS.md`, `README.md`, `FEATURES.md`, and `docs/README.md` to match D1. +- [ ] Run the full checks and inspect the diff for unrelated player changes, + secrets, generated output, and legacy namespaces. +- [ ] Review against this plan and the server and Apple revisions current at + that time. Refresh the baselines table if they moved. +- [ ] Record revision-pinned validation, limitations, and rollout + prerequisites. Rollback: the server capability switch, and the toggle for + testers. Active engagements must tear down safely either way. + +## Validation matrix + +The existing tests assert the old solo-host and content-present rules and must +change. Prefer behavior through fake clocks, sockets, and players over +source-string assertions. + +| ID | Scenario | Required outcome | Evidence | +| --- | --- | --- | --- | +| T01 | Empty and staged lobby, restage, mode switch | No player before Playing; lobby Ready clears as the server dictates | Unit, phone and TV UI | +| T02 | Solo host start; guest joins a playing room | Room keeps playing; the guest syncs alone and is marked ready by a matching report | Unit, live pair | +| T03 | Host rejoins at a nonzero position | Starts at `anchor_position_seconds` as sent; no personal resume; no host report rewinds the room | Unit, live pair | +| T04 | Stop and Start the same title; a new title; a fallback revision | The new revision owns playback; old commands and sessions are inert | Unit, live pair | +| T05 | Ended snapshot without close; stale Playing afterward; `room_closed` `host_left`; idle expiry | Terminal cleanup; no resurrection or reconnect | Unit, socket fixture | +| T06 | Host drops for under 2 min, then for over 2 min | Returns within grace; otherwise the room ends; guests see the host notice | Live pair | +| T07 | Old or equal-generation HTTP after a socket update | The newer attachment, readiness, and selection survive | Unit, delayed transport | +| T08 | Joining the same room again with a new binding | Exactly one socket; the old owner exits | Unit, native rejoin | +| T09 | Five-minute rotation in an 8-minute soak | Immediate reconnect; no reconnect notice; same player session, reattached | Live soak | +| T10 | Ticket 401 at rotation; `expires_in: 0`; ticket 403, 404, 409, 422 | One refresh and a new ticket; 0 renews the expiring credential, then mints again; the others are terminal | Unit, API fixture | +| T11 | Proof renewal and adoption from responses | Membership survives renewal; terminal failure stops work | Accelerated clock | +| T12 | Same-profile replacement on the same API and on different APIs | The displaced client stops without retries; the winner continues; Rejoin works | Unit, live, cross-node | +| T13 | Room socket vs playback-control socket loss | Only the affected socket recovers; no duplicate player or false attachment | Unit, fault injection | +| T14 | Failed attach, stale echo, mid-play session replacement (quality, 404 renewal, replan) | Reports wait for the right echo; the new session reattaches; the room does not pause | Unit, native delivery | +| T15 | A second seek on the same session; a seek while already waiting | Readiness re-arms; guest within 1 s; host re-anchors within 15 s | Unit, real seeks | +| T16 | Lost or rejected `ready`; old `is_ready` snapshot | `state_report` healing; barrier released only on fresh acknowledgement | Unit, integration fixture | +| T17 | Duplicate, out-of-order, and late commands; superseded Play; missing or skewed clock | One current intent runs; late Play advanced; stale work cancelled | Clock and player tests, burst run | +| T18 | Stall under 2 s | No buffering report; local convergence | Player fixture, live shaping | +| T19 | First sustained stall | The room waits up to 10 s; the overlay names the viewer; resumes on recovery | Live shaping | +| T20 | Deadline passes | `self_ignore_wait`; `ready` in playing and paused rooms, retried every 500 ms; fresh target; no reports while unplayable | Unit, live shaping | +| T21 | Stall again within 5 min or within 1 min of the last pause; a lone viewer stalls | The room keeps playing; a lone viewer always pauses it and the room waits; a lone catching-up viewer resumes from its own position | Unit, live | +| T22 | Rate convergence; host drift of 2 s or less | 0.9–1.25x until within 0.35 s, then exactly 1x; host corrected, not re-anchored; passthrough falls back to seek; saved speed untouched | Unit, TV and phone | +| T23 | Unbuffered corrections on a slow link, including copy remux | One reload at a time; backoff from 10 s to 60 s; lead up to 10 s, capped at duration; explicit seeks bypass; no replan loop ([server #1259](https://github.com/Silo-Server/silo-server/issues/1259)) | Unit, live shaping | +| T24 | Two sustained stalls within 5 min | Offer one lower quality of the same file, once per quality | Unit, live | +| T25 | Host local suspensions (focus loss, noisy, sleep timer, TV pause, subtitle preparation, Cast attempt) | Room unaffected; the host resyncs before reporting | Unit, native | +| T26 | Guest controls under both policies on every input in the input table | Denied inputs change nothing and show a notice; permitted ones go to the room | Unit, touch, D-pad, MediaSession, SiloCast | +| T27 | Transport tap during a reconnect | Immediate notice; no silent drop; no queued replay | Unit, native | +| T28 | Saved speed, hold-to-2x, SiloCast speed, auto-skip, next-up, autoplay | The room keeps authority at 1x; preferences survive exit | Unit, native | +| T29 | Version and edition pickers | Disabled in rooms; same-file quality stays local | Native | +| T30 | Downloaded copy, active Cast, solo PiP before entry | Party streams; Cast asks to stop first; PiP ends | Phone | +| T31 | Detail Play, extras, deep link, SiloCast launch while engaged | D5 prompt; no hidden departure | Phone and TV | +| T32 | End of file: natural, premature, seek after end, Play from end | Apple rules; premature end remounts without marking watched; remounts capped | Unit, live | +| T33 | Direct and HLS participants; nonzero origin; window-edge correction | One source; correct clock mapping; no replan loop | Live delivery matrix | +| T34 | Approved refusal with Force HDR on and off; output-route change; stale or failed fallback; access or network failure | Only approved reasons change the room source; no local alternate | API and player tests, real refusal | +| T35 | Suggestions in both modes: vote, unvote, more than 50, removal, promotion, Queue, failed hydration after a receipt | Correct ranking and personal flags; Queue stages "Up next"; no resubmission | Unit, native, cross-node | +| T36 | Restricted picker and member state; stale search; Continue Together alone | No access leakage; bounded reads; omitted IDs not shown as unplayed; empty rows hidden | API, native restricted profile | +| T37 | Uncertain mutations, 401, malformed 2xx, 409 reconcile, 429 | Stable ID and body where replay is allowed; non-retryable operations sent at most once | Request-count and body assertions | +| T38 | Profile, PIN, account, or server change during each async stage; PIN re-unlock | Old results never publish into the new authority; re-unlock keeps the recent party | Unit, native | +| T39 | Invitation with a base path; foreign server; code normalization; invitation while engaged; TV QR scan | Correct authority; no credential reuse; leave-first message; the token never appears in logs or saved state | Unit, phone and TV UI | +| T40 | Party panel, Back, Stop, End, Leave, and focus during live updates; rotation, font scale, TalkBack | Player kept where intended; complete teardown otherwise | Phone and TV UI, recordings | +| T41 | Background and foreground per the table; process death | Behavior matches the table; Rejoin prompt after death; no duplicate owner | Emulators | +| T42 | Experimental toggle, as guest and as host | Off hides entries, ignores invitations, and leaves the party (a host's room ends two minutes later); release defaults off, debug defaults on | Unit, native | +| T43 | Solo playback after leaving | Downloads, resume, speed, tracks, Cast, PiP, autoplay, next-up, and intro preferences behave normally | Focused regression runs | +| T44 | Large seek on a copy-remux delivery | Lands within tolerance or the host re-anchors; barrier duration recorded ([server #1345](https://github.com/Silo-Server/silo-server/pull/1345)) | Live | + +Run these pairings with distinct profiles: + +| Host | Guest | Purpose | +| --- | --- | --- | +| Android phone | Android TV | Core native flow | +| Android TV | Android phone | Reverse authority and TV controls | +| Android phone | Second Android phone | Same form factor | +| Web | Android phone, then TV | Reference host | +| Android phone, then TV | Web | Reference guest | +| Apple tvOS | Android phone | Cross-platform native | +| Android TV | Apple iOS | Cross-platform native | +| Android phone | Apple tvOS | Cross-platform native | +| Apple iOS | Android TV | Cross-platform native | + +Run core start, pause, seek, Stop, restart, and leave on every pairing. Run the +full recovery and catch-up rows on the phone and TV pair, plus representative +web and Apple rows. Add pairings when a difference or failure calls for them. +One three-viewer run (phone, TV, web) covers member changes, stragglers, and +left-behind notices. A scripted socket peer is useful for faults but does not +prove media decoded. At least two real players must advance in native and +interoperability evidence. + +Sync target for a stable fixture: at most 1 s spread between clients after a +5-second settling period, with no persistent pause mismatch or growing drift. +Measure from harness timestamps and record the sampling uncertainty; samples +too far apart are inconclusive. This is a project target, not a server SLA. +Record barrier duration, replan counts, reloads, and rate episodes. A healthy +cached seek that waits out the 10-second deadline is a failure. + +## Running the checks + +Use Java 21 and one Android SDK path. Run filtered suites during development and +the full set at the final gate: + +```sh +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest :androidTvApp:testDebugUnitTest +./gradlew :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ + :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease +./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug +``` + +CI treats `NewApi` and `InlinedApi` lint findings as fatal. Fix new findings +instead of regenerating the baseline. + +Runtime probes use the `silo-runtime` helper. It authenticates as admin, so use +it for health, identity, and room capability checks, and use participant +credentials for playback capabilities and room behavior: + +```sh +~/.agents/skills/silo-runtime/scripts/silo-runtime dev doctor +~/.agents/skills/silo-runtime/scripts/silo-runtime dev api /api/v2/system/info +~/.agents/skills/silo-runtime/scripts/silo-runtime dev api /api/v2/watch-together/capabilities +``` + +Device observation after installing the matching APK on each claimed device: + +```sh +scripts/android-playback-test.sh -s "$WATCH_PARTY_PHONE_SERIAL" status +scripts/android-playback-test.sh -s "$WATCH_PARTY_TV_SERIAL" status +``` + +Fill the serials from current device-hub discovery. M0 adds the party fields +and the two-device runner; do not document commands before they exist. Use +`--quality original` for direct, HDR, and passthrough fixtures. Emulator +decoding is not evidence of HDR output or audio passthrough. + +Keep a run ledger outside tracked source: scenario ID, UTC times, Android, peer, +and server revisions with dirty state, device, API level and ABI, runtime image +and topology, fixture and delivery, roles, room, revision, session, and command +correlation, result, sampling uncertainty, and captures. Keep deployment URLs, +credentials, fixture IDs, and device IDs in protected local configuration. +Summarize durable results here as milestones finish; never commit raw logs or +captures. + +## Dependencies and open items + +| Item | Effect on Android | +| --- | --- | +| [Server #1307](https://github.com/Silo-Server/silo-server/issues/1307) (open) | Its acceptance needs Android, Apple, and web readiness evidence against the buffering policy. M6 supplies Android's | +| [Server #1345](https://github.com/Silo-Server/silo-server/pull/1345) (open) | Web-only pre-roll fix for copy-remux seeks. Android checks Media3's landing behavior in T44 | +| [Server #1259](https://github.com/Silo-Server/silo-server/issues/1259) (open) | Replan storms on copy remux. The reload budget is Android's side; T23 proves it | +| [Server #1284](https://github.com/Silo-Server/silo-server/issues/1284) (open tracker) | Suggestion broadcast and replacement fixes are merged (#1286, #1287). Confirm they are deployed on every API that Android testing uses | +| [Apple #412](https://github.com/Silo-Server/silo-apple/pull/412) (open), for [#409](https://github.com/Silo-Server/silo-apple/issues/409) | Proposes the `silo://watch-party` app link that Android adopts (D4). The server still needs a public join page that offers the link; the web join page currently requires sign-in. Freeze the format before M5's link handling merges; tapping a shared link end to end (T39) needs the server page | +| [Apple #410](https://github.com/Silo-Server/silo-apple/issues/410) | Android uses an immediate notice on taps during reconnect (T27) | +| [Apple #411](https://github.com/Silo-Server/silo-apple/issues/411) | Android keys recent parties by `loginId` from sign-in. Confirm that sessions from older app versions carry it | +| [Server #1379](https://github.com/Silo-Server/silo-server/pull/1379) (merged) | Room-driven starts omit `first_frame_ms`; Rejoin is a viewer start. Android's general first-frame timing, which starts at plan adoption, is separate work | +| Picker `overlay_summary` | Server picker cards carry no overlay summary, so the watchlist row shows no quality badges until the server fills it | +| Access-token lifetime | Not confirmed. If it is shorter than 5 minutes, sockets rotate earlier; T09 and T10 measure it | + +## Completion record + +- [x] 2026-09-24: reviewed the September 21 draft against server `2812ac39`, + Apple `5d9dc764`, the web client, and Android `03ac5b71`; wrote this plan. +- [ ] M0: decisions recorded, environment and harness ready. +- [ ] M1–M2: wire contract and room engagement owner complete. +- [ ] M3: first live two-player checkpoint passes. +- [ ] M4–M5: phone and TV experiences pass rendered checks. +- [ ] M6: emulator, software, and interoperability matrix passes. +- [ ] Hardware and deployment gates recorded and met for each route being + enabled. +- [ ] M7: cleanup, final checks, and enablement per D1. diff --git a/scripts/android-playback-test.sh b/scripts/android-playback-test.sh index dacda81f40..c1f7bf19bb 100755 --- a/scripts/android-playback-test.sh +++ b/scripts/android-playback-test.sh @@ -28,6 +28,12 @@ # long-running health check: cold-start playback, sample # every 10s, fail on crash/stall/error; report drops, # rebuffers, and any fatal exceptions at the end +# party Watch Party room and binding state (the "party" object +# of status); never tokens or tickets +# spread [samples] [intervalSec] +# sample two devices in a Watch Party and report the +# inter-client position spread on the room's server clock +# (defaults: 10 samples, 2s apart). Ignores -s. # # Environment: # SILO_DEVICE_SERIAL default adb serial (e.g. 192.0.2.10:5555 or a USB serial); @@ -147,6 +153,57 @@ case "$CMD" in status) status_json ;; + party) + status_json | python3 -c 'import json,sys; d=json.load(sys.stdin); print(json.dumps(d.get("party", {"party": "none"}), sort_keys=True))' + ;; + spread) + [[ $# -ge 2 ]] || die "spread needs two serials" + serial_a="$1"; serial_b="$2"; samples="${3:-10}"; interval="${4:-2}" + spread_sample() { # spread_sample -> one status JSON line + SERIAL="$1" status_json + } + for ((i = 1; i <= samples; i++)); do + a=$(spread_sample "$serial_a"); b=$(spread_sample "$serial_b") + python3 - "$a" "$b" <<'PY' +import json, sys +def project(raw): + d = json.loads(raw) + party = d.get("party") or {} + offset = party.get("serverOffsetMs") + # The screen position is the source timeline but only ticks every + # 500 ms on TV; the player position is fresh. Use the player position + # when both are on the same timeline, the screen position otherwise + # (a remux or transcode that starts mid-title). + pos = d.get("screenPositionSec") + player = d["positionMs"] / 1000.0 if d.get("positionMs") is not None else None + if player is not None and (pos is None or abs(player - pos) < 1.0): + pos = player + if offset is None or pos is None: + return None, d + server_ms = d["sampledWallMs"] + offset + return (pos, server_ms, bool(d.get("isPlaying"))), d +(a, da), (b, db) = project(sys.argv[1]), project(sys.argv[2]) +if a is None or b is None: + print("inconclusive: missing position or server clock on", "A" if a is None else "B") + sys.exit(0) +# Project the earlier sample forward to the later one's server time while playing. +ref = max(a[1], b[1]) +def at(sample): + pos, t, playing = sample + return pos + ((ref - t) / 1000.0 if playing else 0.0) +gap_ms = abs(a[1] - b[1]) +spread = abs(at(a) - at(b)) +print(json.dumps({ + "spreadSec": round(spread, 3), + "sampleGapMs": gap_ms, + "conclusive": gap_ms <= 1500, + "a": {"pos": a[0], "playing": a[2], "party": (da.get("party") or {}).get("playbackState")}, + "b": {"pos": b[0], "playing": b[2], "party": (db.get("party") or {}).get("playbackState")}, +})) +PY + (( i < samples )) && sleep "$interval" + done + ;; wait-playing) wait_playing "${1:-30}" ;; diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/network/SiloHttpClient.android.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/network/SiloHttpClient.android.kt index 9e270d12fb..ac7ecb7f91 100644 --- a/shared/src/androidMain/kotlin/org/siloserver/silo/network/SiloHttpClient.android.kt +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/network/SiloHttpClient.android.kt @@ -12,12 +12,13 @@ import io.ktor.client.engine.okhttp.* * the process), and per-client dispatchers keep API, diagnostics, and image * traffic from queueing behind each other. */ -actual fun createPlatformHttpClient(): HttpClient { +actual fun createPlatformHttpClient(retryOnConnectionFailure: Boolean): HttpClient { return HttpClient(OkHttp) { engine { config { dispatcher(SiloOkHttp.newDispatcher()) connectionPool(SiloOkHttp.connectionPool) + retryOnConnectionFailure(retryOnConnectionFailure) } } } diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/util/WallClock.android.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/util/WallClock.android.kt new file mode 100644 index 0000000000..d65f1cd690 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/util/WallClock.android.kt @@ -0,0 +1,3 @@ +package org.siloserver.silo.util + +actual fun wallClockMillis(): Long = System.currentTimeMillis() diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/watchtogether/SharedPreferencesRecentWatchPartyStorage.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/watchtogether/SharedPreferencesRecentWatchPartyStorage.kt new file mode 100644 index 0000000000..f24573fa88 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/watchtogether/SharedPreferencesRecentWatchPartyStorage.kt @@ -0,0 +1,22 @@ +package org.siloserver.silo.watchtogether + +import android.content.SharedPreferences + +/** + * Keeps the recent party in the app's encrypted preferences (see + * `createSecureSharedPrefs`). The value holds a room code and display + * metadata, never a room or join token. + */ +class SharedPreferencesRecentWatchPartyStorage( + private val prefs: SharedPreferences, +) : RecentWatchPartyStorage { + override fun read(): String? = prefs.getString(KEY, null) + + override fun write(value: String?) { + prefs.edit().apply { if (value == null) remove(KEY) else putString(KEY, value) }.apply() + } + + private companion object { + const val KEY = "watch_party.recent" + } +} diff --git a/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.android.kt b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.android.kt new file mode 100644 index 0000000000..f3030a17ff --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.android.kt @@ -0,0 +1,6 @@ +package org.siloserver.silo.model.watchtogether + +actual fun readWatchPartyFixture(name: String): String = + checkNotNull(WatchPartyFixtureAnchor::class.java.classLoader?.getResource("watchtogether/v2/$name")) { + "Missing Watch Party fixture watchtogether/v2/$name" + }.readText() diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/RoomSyncEngine.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/RoomSyncEngine.kt index e9e79adb48..d205846cd8 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/RoomSyncEngine.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/RoomSyncEngine.kt @@ -49,6 +49,11 @@ class RoomSyncEngine { private var lastCommandId: String? = null + /** Adopt the room owner's server clock estimate (see `WatchTogetherRepository.clock`). */ + fun setServerTimeOffset(offsetMs: Long?) { + serverTimeOffsetMs = offsetMs + } + /** Record a pong round-trip sample; updates [serverTimeOffsetMs]. */ fun recordPongSample( clientSentMs: Long, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt index e4fb61cec5..4dbcef369c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt @@ -64,6 +64,14 @@ val networkModule = module { single { DefaultSubtitlesApi(get(), get(), get(), get()) } single { NotificationsV2Api(get(), get(), get()) } single { DefaultPushRegistrationApi(get(), get(), get()) } - single { DefaultWatchTogetherApi(get(), get()) } + single { + DefaultWatchTogetherApi( + client = get(), + gate = get(), + tokens = get(), + // Start, select, and promote must never be resent by the engine. + nonReplayingClient = createSiloClient(get(), getOrNull(), getOrNull(), getOrNull(), retryOnConnectionFailure = false), + ) + } single { DefaultDiagnosticsApi(get(), gate = get()) } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index 5e37e8e333..2358c59305 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -26,7 +26,6 @@ import org.siloserver.silo.repository.SettingsRepository import org.siloserver.silo.repository.WatchTogetherRepository import org.siloserver.silo.network.TokenManager import org.siloserver.silo.watchtogether.RoomSession -import org.siloserver.silo.watchtogether.WatchTogetherEntryGateway import org.koin.dsl.module import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -155,7 +154,6 @@ 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/siloserver/silo/model/feature/ClientSurfacePolicy.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/ClientSurfacePolicy.kt index 1a83703107..55ffe01aae 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/ClientSurfacePolicy.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/ClientSurfacePolicy.kt @@ -1,8 +1,13 @@ package org.siloserver.silo.model.feature +import kotlinx.coroutines.flow.StateFlow + /** - * Client-side exposure switches for implemented surfaces that are not ready to - * appear in normal user navigation yet. Routes, repositories, and deep-link - * plumbing can remain compiled while menus/actions stay hidden. + * Whether Watch Party entry points may appear. The platform injects it from + * the device-local Settings → Experimental toggle, which defaults on in debug + * builds and off in release builds. The server's capabilities still decide + * whether the feature works once shown. */ -const val CLIENT_WATCH_TOGETHER_SURFACE_ENABLED: Boolean = false +interface WatchPartyExposure { + val enabled: StateFlow +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt index 6ee0ab0b39..5e23363af4 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt @@ -434,6 +434,13 @@ data class PlaybackStartRequestV3( @SerialName("bandwidth_cap_kbps") val bandwidthCapKbps: Int? = null, @SerialName("client_capabilities") val capabilities: ClientCodecCapabilities, @SerialName("client_playback_context") val clientPlaybackContext: ClientPlaybackContext, + /** + * False keeps this attempt on [fileId] through every replan: the server + * refuses rather than substitutes another version. Watch Party starts send + * false so every viewer stays on the room's file. Null omits the field and + * keeps the server default for solo playback. + */ + @SerialName("allow_alternate_versions") val allowAlternateVersions: Boolean? = null, ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyV2Models.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyV2Models.kt new file mode 100644 index 0000000000..ec4eb2621b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyV2Models.kt @@ -0,0 +1,93 @@ +package org.siloserver.silo.model.watchtogether + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.siloserver.silo.model.catalog.BrowseItem + +/** + * `GET /api/v2/watch-together/capabilities`. Each flag reflects an operation + * the server actually wires. [socketProtocol] is `silo.room.v2` when the v2 + * room socket is served and empty otherwise; `lobby_ready` and + * `connection_replaced` ride on that socket. + */ +@Serializable +data class WatchTogetherCapabilitiesV2( + val state: String = "", + val allowed: Boolean? = null, + @SerialName("staged_selection") val stagedSelection: Boolean = false, + @SerialName("lobby_ready") val lobbyReady: Boolean = false, + @SerialName("connection_replaced") val connectionReplaced: Boolean = false, + @SerialName("selection_mode_switch") val selectionModeSwitch: Boolean = false, + @SerialName("member_state") val memberState: Boolean = false, + val picker: Boolean = false, + @SerialName("vote_host_override") val voteHostOverride: Boolean = false, + @SerialName("stop_playback") val stopPlayback: Boolean = false, + @SerialName("max_member_state_ids") val maxMemberStateIds: Int = 0, + @SerialName("socket_protocol") val socketProtocol: String = "", +) + +/** One member's watch state for one content id (`POST .../member-state`). */ +@Serializable +data class MemberWatchState( + @SerialName("user_id") val userId: String = "", + @SerialName("profile_id") val profileId: String = "", + val state: String = "", + @SerialName("position_seconds") val positionSeconds: Double? = null, + @SerialName("duration_seconds") val durationSeconds: Double? = null, + @SerialName("on_watchlist") val onWatchlist: Boolean = false, +) + +@Serializable +data class ItemMemberState( + @SerialName("content_id") val contentId: String, + val members: List = emptyList(), +) + +/** + * Member watch state for the requested ids that the caller may see, in + * request order. Inaccessible and missing ids are omitted; an omitted id is + * unavailable, not unplayed. + */ +@Serializable +data class MemberStateResponse( + val members: List = emptyList(), + val items: List = emptyList(), +) + +@Serializable +data class PickerMember( + @SerialName("user_id") val userId: String = "", + @SerialName("profile_id") val profileId: String = "", + @SerialName("display_name") val displayName: String = "", + @SerialName("position_seconds") val positionSeconds: Double? = null, + @SerialName("duration_seconds") val durationSeconds: Double? = null, +) + +/** For a series row: the episode most members would play next. */ +@Serializable +data class PickerNextUp( + @SerialName("content_id") val contentId: String, + @SerialName("season_number") val seasonNumber: Int = 0, + @SerialName("episode_number") val episodeNumber: Int = 0, + val title: String = "", + @SerialName("member_count") val memberCount: Int = 0, +) + +@Serializable +data class PickerEntry( + val item: BrowseItem, + val members: List = emptyList(), + @SerialName("next_up") val nextUp: PickerNextUp? = null, +) + +/** + * `GET .../picker`: shared rows resolved through the caller's catalog access. + * Continue Together lists only items two or more connected members are part + * way through, so it is empty in a party of one. + */ +@Serializable +data class PickerResponse( + val members: List = emptyList(), + @SerialName("continue_together") val continueTogether: List = emptyList(), + @SerialName("watchlist_union") val watchlistUnion: List = emptyList(), +) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModels.kt index c35e194779..8c33cb7a72 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModels.kt @@ -124,11 +124,33 @@ object TransportActionSerializer : KSerializer by EnumWireSeria // ---- Core payloads -------------------------------------------------------- +/** + * One connected member of a v2 snapshot (server `MemberSummary`). HTTP v2 + * sends `user_id` as a string and the room socket sends it as a number; the + * lenient decoder reads both into [userId]. The readiness booleans are + * omitted when false, which means false only when the server advertises + * `watch_party_coordinator_v1`. + */ +@Serializable +data class RoomMember( + @SerialName("user_id") val userId: String = "", + @SerialName("profile_id") val profileId: String = "", + @SerialName("display_name") val displayName: String = "", + @SerialName("is_host") val isHost: Boolean = false, + @SerialName("is_self") val isSelf: Boolean = false, + val connected: Boolean = false, + @SerialName("lobby_ready") val lobbyReady: Boolean = false, + @SerialName("is_ready") val isReady: Boolean = false, + @SerialName("is_buffering") val isBuffering: Boolean = false, + @SerialName("is_syncing") val isSyncing: Boolean = false, +) + /** * Universal room payload (server `Snapshot`). Several fields are per-recipient * (`self_*`, `invite_path` is host-only). `selection_revision` and * `generation` are Longs; `anchor_position_seconds` is a Double; timestamps are - * RFC3339 strings. + * RFC3339 strings. [anchorPositionSeconds] is already projected to the time + * the server sent the snapshot; never add the time since [anchorUpdatedAt]. */ @Serializable data class RoomSnapshot( @@ -154,7 +176,21 @@ data class RoomSnapshot( @SerialName("self_ignore_wait") val selfIgnoreWait: Boolean = false, @SerialName("attached_session_id") val attachedSessionId: String? = null, @SerialName("invite_path") val invitePath: String? = null, -) + val members: List = emptyList(), +) { + /** This viewer's own member entry, when the server listed one. */ + val selfMember: RoomMember? get() = members.firstOrNull { it.isSelf } + + /** + * The room kept going without this viewer: its waiting deadline passed, or + * the room no longer waits for this viewer's stalls. The viewer must send + * `ready` once its media is playable again. + */ + val selfCatchingUp: Boolean + get() = phase == RoomPhase.Playing && + (playbackState == RoomPlaybackState.Playing || playbackState == RoomPlaybackState.Paused) && + (selfIgnoreWait || selfMember?.isBuffering == true) +} /** A content suggestion (vote-mode room). In WS broadcasts [votedByMe] is forced false. */ @Serializable @@ -193,10 +229,15 @@ data class TransportCommand( // ---- REST request models -------------------------------------------------- -/** POST /rooms. */ +/** + * POST /rooms. [roomId] is the caller-selected identity: the logical action + * keeps it so an explicit retry after an uncertain outcome replays the same + * room instead of creating a second one. + */ @Serializable data class CreateRoomRequest( - @SerialName("selection_mode") val selectionMode: String? = null, + @SerialName("room_id") val roomId: String, + @SerialName("selection_mode") val selectionMode: String = RoomSelectionMode.HostPick.wire, ) /** POST /join — one of [code]/[joinToken] required; token wins. */ @@ -206,7 +247,7 @@ data class JoinRoomRequest( @SerialName("join_token") val joinToken: String? = null, ) -/** PUT /rooms/{id}/selection (host-only). */ +/** PUT /rooms/{id}/selection and PUT /rooms/{id}/staged-selection (host-only). */ @Serializable data class SetSelectionRequest( @SerialName("content_id") val contentId: String, @@ -214,15 +255,51 @@ data class SetSelectionRequest( @SerialName("library_id") val libraryId: Int? = null, ) +/** PATCH /rooms/{id}/selection-mode (host-only, lobby only). */ +@Serializable +data class SelectionModeRequest( + @SerialName("selection_mode") val selectionMode: String, +) + +/** The playback refusals that may move the whole room to another source file. */ +enum class SourceFallbackReason(val wire: String) { + NoAlternateVersion("no_alternate_version"), + HdrTranscodeUnsupported("hdr_transcode_unsupported"), + SubtitleConversionUnsupported("subtitle_conversion_unsupported"), + TranscodingDisabled("transcoding_disabled"); + + companion object { + fun fromWire(w: String?): SourceFallbackReason? = entries.firstOrNull { it.wire == w } + } +} + +/** POST /rooms/{id}/source-fallback, fenced by the failed file and selection revision. */ +@Serializable +data class SourceFallbackRequest( + @SerialName("selection_revision") val selectionRevision: Long, + @SerialName("failed_file_id") val failedFileId: String, + val reason: String, +) + +/** POST /rooms/{id}/member-state. */ +@Serializable +data class MemberStateRequest( + @SerialName("content_ids") val contentIds: List, +) + /** PATCH /rooms/{id}/policy (host-only). */ @Serializable data class UpdatePolicyRequest( @SerialName("guest_control_policy") val guestControlPolicy: String, ) -/** POST /rooms/{id}/suggestions. */ +/** + * POST /rooms/{id}/suggestions. [suggestionId] is the caller-selected identity, + * kept by the logical action so an explicit retry replays the same suggestion. + */ @Serializable data class AddSuggestionRequest( + @SerialName("suggestion_id") val suggestionId: String, @SerialName("content_id") val contentId: String, @SerialName("content_type") val contentType: String, val title: String, @@ -246,10 +323,23 @@ data class RoomResponse( @SerialName("room_access_token") val roomAccessToken: String = "", ) -/** `{suggestions:[…]}` — all suggestion list/mutation responses. */ +/** One page of GET /rooms/{id}/suggestions, in creation order. */ @Serializable data class SuggestionsResponse( @SerialName("items") val suggestions: List = emptyList(), + val page: SuggestionPageInfo? = null, +) + +@Serializable +data class SuggestionPageInfo( + @SerialName("has_more") val hasMore: Boolean = false, + @SerialName("next_cursor") val nextCursor: String? = null, +) + +/** POST /rooms/{id}/suggestions receipt (HTTP 201). */ +@Serializable +data class SuggestionReceipt( + @SerialName("suggestion_id") val suggestionId: String, ) // ---- Client→server WS frames ---------------------------------------------- @@ -268,20 +358,36 @@ data class WsTransportRequest( @SerialName("is_paused") val isPaused: Boolean, ) +/** + * Periodic position report. While the room waits, [commandId] with + * [isReady] = true acknowledges that command exactly like a `ready` frame, so a + * lost acknowledgement heals on the next report. + */ @Serializable data class WsStateReport( val type: String = "state_report", @SerialName("session_id") val sessionId: String, @SerialName("position_seconds") val positionSeconds: Double, @SerialName("is_paused") val isPaused: Boolean, + @SerialName("command_id") val commandId: String? = null, + @SerialName("is_ready") val isReady: Boolean? = null, ) +/** Readiness acknowledgement for the command named by [commandId]. */ @Serializable data class WsReady( val type: String = "ready", @SerialName("session_id") val sessionId: String, @SerialName("position_seconds") val positionSeconds: Double, @SerialName("is_paused") val isPaused: Boolean, + @SerialName("command_id") val commandId: String? = null, +) + +/** Advisory lobby "I'm ready"; distinct from playback readiness. */ +@Serializable +data class WsLobbyReady( + val type: String = "lobby_ready", + val ready: Boolean, ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClient.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClient.kt index 56f2955c23..d59654d4af 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClient.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClient.kt @@ -5,5 +5,9 @@ import io.ktor.client.* /** * Factory for creating the Ktor HttpClient. * Implementation provided by Agent 2 in SiloHttpClientImpl.kt. + * + * [retryOnConnectionFailure] false stops the engine from silently resending a + * request whose connection failed after it was sent. Operations that must + * never be replayed (Watch Party start, select, promote) use such a client. */ -expect fun createPlatformHttpClient(): HttpClient +expect fun createPlatformHttpClient(retryOnConnectionFailure: Boolean = true): HttpClient diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt index 71af541599..a439f75671 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt @@ -40,8 +40,9 @@ fun createSiloClient( deviceMetadataProvider: DeviceMetadataProvider? = null, diagnosticsObserver: NetworkDiagnosticsObserver? = null, cleartextOriginConsent: CleartextOriginConsent? = null, + retryOnConnectionFailure: Boolean = true, ): HttpClient { - val platformClient = createPlatformHttpClient() + val platformClient = createPlatformHttpClient(retryOnConnectionFailure) return platformClient.config { install(ContentNegotiation) { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClient.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClient.kt index 95da2ffafd..0561df4a39 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClient.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClient.kt @@ -1,14 +1,19 @@ package org.siloserver.silo.network +import org.siloserver.silo.model.watchtogether.RoomPlaybackState import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.model.watchtogether.TransportAction import org.siloserver.silo.model.watchtogether.TransportCommand import org.siloserver.silo.model.watchtogether.WsAttachSession import org.siloserver.silo.model.watchtogether.WsBuffering +import org.siloserver.silo.model.watchtogether.WsLobbyReady import org.siloserver.silo.model.watchtogether.WsPing import org.siloserver.silo.model.watchtogether.WsReady import org.siloserver.silo.model.watchtogether.WsStateReport import org.siloserver.silo.model.watchtogether.WsTransportRequest +import org.siloserver.silo.network.api.ROOM_TOKEN_HEADER +import org.siloserver.silo.util.wallClockMillis import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocketSession @@ -17,7 +22,6 @@ import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.http.HttpHeaders import io.ktor.http.URLProtocol -import io.ktor.http.isSuccess import io.ktor.http.HttpStatusCode import io.ktor.http.encodeURLPathPart import io.ktor.http.encodedPath @@ -28,9 +32,12 @@ import io.ktor.websocket.readText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.builtins.ListSerializer @@ -41,17 +48,23 @@ import kotlinx.serialization.json.JsonPrimitive import org.siloserver.silo.model.notifications.WsTicketResponse import org.siloserver.silo.network.apiv2.ApiV2Gate import org.siloserver.silo.network.apiv2.safeApiV2Call +import org.siloserver.silo.util.parseRfc3339ToEpochMillis /** * Per-room websocket. One [connect] = one ticket mint at * `POST /api/v2/watch-together/rooms/{id}/ws-ticket` (scoped auth plus the - * room JWT in `X-Room-Token`) followed by one upgrade of + * room proof in `X-Room-Token`) followed by one upgrade of * `/api/v2/watch-together/rooms/{id}/ws` that carries only the single-use * ticket in `Sec-WebSocket-Protocol`. No credential travels in the URL. * - * [RoomRealtimeEvent.Closed] is reserved for an explicit decoded - * `room_closed` frame. Physical EOF and socket failures surface as - * [RoomRealtimeEvent.TransportTerminated], while owner cancellation is silent. + * The ticket mint may be refreshed and resent once by the auth interceptor + * after a 401; the server classifies it as naturally idempotent. + * + * [RoomRealtimeEvent.Closed] and [RoomRealtimeEvent.ConnectionReplaced] are + * reserved for decoded terminal frames. Physical EOF and socket failures + * surface as [RoomRealtimeEvent.TransportTerminated], while owner cancellation + * is silent. Frames are delivered with backpressure: a slow collector pauses + * the receive loop instead of losing events. */ interface WatchTogetherRealtimeClient { /** Open one physical room socket and publish [RoomRealtimeEvent.Opened] once writable. */ @@ -64,9 +77,16 @@ interface WatchTogetherRealtimeClient { /** Client→server sends report whether a frame reached the current writable socket. */ suspend fun attachSession(sessionId: String): Boolean suspend fun transportRequest(action: String, positionSeconds: Double?, isPaused: Boolean): Boolean - suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean - suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String? = null, + isReady: Boolean = false, + ): Boolean + suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String? = null): Boolean suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun lobbyReady(ready: Boolean): Boolean = false suspend fun ping(clientSentAt: String): Boolean /** Opaque identity of the currently writable physical socket, if any. */ @@ -105,8 +125,21 @@ internal fun interface WatchTogetherSocketConnector { internal const val ROOM_SOCKET_PROTOCOL = "silo.room.v2" +/** A frame that cannot be handed to the socket in this long is a failed send. */ +private const val SEND_TIMEOUT_MS = 5_000L + private fun roomPath(roomId: String) = "/api/v2/watch-together/rooms/${roomId.encodeURLPathPart()}" +/** + * A room ticket is usable when it names the room protocol, fits the server's + * bounds, and can travel in a header. `expires_in` of 0 is valid but expiring; + * see [RoomTicketExpiringException]. + */ +internal fun validRoomTicket(ticket: WsTicketResponse): Boolean = + ticket.protocol == ROOM_SOCKET_PROTOCOL && ticket.expiresIn in 0..30 && + ticket.maxConnectionSeconds in 1..300 && ticket.ticket.isNotEmpty() && + ticket.ticket.all { it.isLetterOrDigit() && it.code < 128 || it in "!#$%&'*+-.^_`|~" } + private class KtorWatchTogetherSocketConnector( private val client: HttpClient, ) : WatchTogetherSocketConnector { @@ -115,14 +148,15 @@ private class KtorWatchTogetherSocketConnector( client.post { url { encodedPath = "${roomPath(request.roomId)}/ws-ticket" } authScope(request.authScope); requireSiloAuth() - header("X-Room-Token", request.roomToken) - }.also { check(!it.status.isSuccess() || it.status == HttpStatusCode.OK) } + header(ROOM_TOKEN_HEADER, request.roomToken) + }.also { check(it.status.value !in 200..299 || it.status == HttpStatusCode.OK) } }) { is ApiResult.Success -> minted.data - is ApiResult.Error -> throw IllegalStateException("room_ticket_unavailable:${minted.code}") + is ApiResult.Error -> throw RoomTicketRefusedException(minted.code, minted.error) is ApiResult.NetworkError -> throw minted.exception } - check(ticket.protocol == ROOM_SOCKET_PROTOCOL && ticket.ticket.isNotEmpty()) { "room_ticket_invalid" } + check(validRoomTicket(ticket)) { "room_ticket_invalid" } + if (ticket.expiresIn == 0) throw RoomTicketExpiringException() val session = client.webSocketSession { roomSocketUpgrade(request.authScope, request.roomId, ticket) } if (session.call.response.headers[HttpHeaders.SecWebSocketProtocol] != ROOM_SOCKET_PROTOCOL) { runCatching { session.close() } @@ -173,19 +207,21 @@ class DefaultWatchTogetherRealtimeClient private constructor( private val tokenManager: TokenManager, private val json: Json, private val socketConnector: WatchTogetherSocketConnector, + private val wallClockMs: () -> Long, ) : WatchTogetherRealtimeClient { constructor( client: HttpClient, tokenManager: TokenManager, json: Json = SiloJson, - ) : this(tokenManager, json, KtorWatchTogetherSocketConnector(client)) + ) : this(tokenManager, json, KtorWatchTogetherSocketConnector(client), ::wallClockMillis) internal constructor( tokenManager: TokenManager, socketConnector: WatchTogetherSocketConnector, json: Json = SiloJson, - ) : this(tokenManager, json, socketConnector) + wallClockMs: () -> Long = ::wallClockMillis, + ) : this(tokenManager, json, socketConnector, wallClockMs) private val sessionMutex = Mutex() private var session: WatchTogetherSocketConnection? = null @@ -201,7 +237,7 @@ class DefaultWatchTogetherRealtimeClient private constructor( val profileId = scope?.profileId val scopedAccessToken = scope?.let { tokenManager.getAccessTokenForScope(it) } if (scope == null || scopedAccessToken.isNullOrBlank() || profileId.isNullOrBlank()) { - trySend( + send( RoomRealtimeEvent.TransportTerminated( IllegalStateException("missing_auth_scope"), ), @@ -223,16 +259,18 @@ class DefaultWatchTogetherRealtimeClient private constructor( session = connection activeSessionId = ++sessionId } - trySend(RoomRealtimeEvent.Opened) + send(RoomRealtimeEvent.Opened) while (true) { val raw = connection.receiveText() ?: break - decodeRoomFrame(json, raw)?.let { trySend(it) } + decodeRoomFrame(json, raw, wallClockMs())?.let { send(it) } } - trySend(RoomRealtimeEvent.TransportTerminated()) + send(RoomRealtimeEvent.TransportTerminated()) } catch (cancellation: CancellationException) { throw cancellation } catch (failure: Throwable) { - trySend(RoomRealtimeEvent.TransportTerminated(failure)) + // The cause decides whether the owner reconnects, so it must not be + // dropped behind a full buffer. + send(RoomRealtimeEvent.TransportTerminated(failure)) } finally { connection?.let { completed -> // Cancellation is the privacy boundary for an identity/room @@ -256,15 +294,25 @@ class DefaultWatchTogetherRealtimeClient private constructor( private suspend fun sendText(text: String): Boolean { val writable = sessionMutex.withLock { session } ?: return false - return try { - writable.sendText(text) - true - } catch (cancellation: CancellationException) { - throw cancellation + return deliver(writable, text) + } + + /** + * Send one frame, bounded by [SEND_TIMEOUT_MS] so the playback binding's + * event loop never waits on a socket that has stopped draining. A socket + * that dies under a pending send cancels its outgoing channel, which + * surfaces here as a CancellationException even though the caller is + * still running. Only the caller's own cancellation may propagate; + * anything else is a failed send. Letting the channel's exception through + * ended the binding's event loop silently. + */ + private suspend fun deliver(writable: WatchTogetherSocketConnection, text: String): Boolean = + try { + withTimeoutOrNull(SEND_TIMEOUT_MS) { writable.sendText(text) } != null } catch (_: Throwable) { + currentCoroutineContext().ensureActive() false } - } override suspend fun attachSession(sessionId: String) = sendText(json.encodeToString(WsAttachSession.serializer(), WsAttachSession(sessionId = sessionId))) @@ -293,29 +341,38 @@ class DefaultWatchTogetherRealtimeClient private constructor( WsTransportRequest.serializer(), WsTransportRequest(action = action, positionSeconds = positionSeconds, isPaused = isPaused), ) - return try { - writable.sendText(payload) - true - } catch (cancellation: CancellationException) { - throw cancellation - } catch (_: Throwable) { - false - } + return deliver(writable, payload) } - override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) = - sendText( - json.encodeToString( - WsStateReport.serializer(), - WsStateReport(sessionId = sessionId, positionSeconds = positionSeconds, isPaused = isPaused), + override suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String?, + isReady: Boolean, + ) = sendText( + json.encodeToString( + WsStateReport.serializer(), + WsStateReport( + sessionId = sessionId, + positionSeconds = positionSeconds, + isPaused = isPaused, + commandId = commandId?.takeIf { isReady && it.isNotBlank() }, + isReady = isReady.takeIf { it && !commandId.isNullOrBlank() }, ), - ) + ), + ) - override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean) = + override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?) = sendText( json.encodeToString( WsReady.serializer(), - WsReady(sessionId = sessionId, positionSeconds = positionSeconds, isPaused = isPaused), + WsReady( + sessionId = sessionId, + positionSeconds = positionSeconds, + isPaused = isPaused, + commandId = commandId?.takeIf { it.isNotBlank() }, + ), ), ) @@ -327,25 +384,29 @@ class DefaultWatchTogetherRealtimeClient private constructor( ), ) + override suspend fun lobbyReady(ready: Boolean) = + sendText(json.encodeToString(WsLobbyReady.serializer(), WsLobbyReady(ready = ready))) + override suspend fun ping(clientSentAt: String) = sendText(json.encodeToString(WsPing.serializer(), WsPing(clientSentAt = clientSentAt))) } /** - * Pure decode of one room WS server frame into a [RoomRealtimeEvent], or null - * when the frame is not one we surface (unknown `type`, malformed payload, or - * malformed JSON). Never throws — this is the load-bearing, fully-tested - * logic; socket I/O above is kept thin and untested. + * Pure decode of one room WS server frame into a [RoomRealtimeEvent]. Never + * throws — this is the load-bearing, fully-tested logic; socket I/O above is + * kept thin. * * - `snapshot {room}` → [RoomRealtimeEvent.SnapshotEvent] * - `transport_command {command}`→ [RoomRealtimeEvent.TransportCommandEvent] * - `suggestions_update {suggestions}` → [RoomRealtimeEvent.SuggestionsEvent] * - `room_closed {reason}` → [RoomRealtimeEvent.Closed] - * - `pong {…}` → [RoomRealtimeEvent.Pong] + * - `connection_replaced {reason}` → [RoomRealtimeEvent.ConnectionReplaced] + * - `pong {…}` → [RoomRealtimeEvent.Pong], stamped with [receivedAtMs] * - `error {code,message}` → [RoomRealtimeEvent.Error] - * - anything else → null + * - a known type whose payload does not decode → [RoomRealtimeEvent.Malformed] + * - an unknown type or non-object JSON → null (ignored) */ -fun decodeRoomFrame(json: Json, raw: String): RoomRealtimeEvent? { +fun decodeRoomFrame(json: Json, raw: String, receivedAtMs: Long? = null): RoomRealtimeEvent? { val obj: JsonObject = try { val element = json.parseToJsonElement(raw) element as? JsonObject ?: return null @@ -354,51 +415,56 @@ fun decodeRoomFrame(json: Json, raw: String): RoomRealtimeEvent? { } val type = (obj["type"] as? JsonPrimitive)?.content ?: return null + fun str(key: String) = (obj[key] as? JsonPrimitive)?.content ?: "" return when (type) { WatchTogetherRealtime.TypeSnapshot -> { - val room = obj["room"] as? JsonObject ?: return null + val room = obj["room"] as? JsonObject ?: return RoomRealtimeEvent.Malformed(type) val snapshot = try { json.decodeFromJsonElement(RoomSnapshot.serializer(), room) } catch (_: Exception) { - return null + return RoomRealtimeEvent.Malformed(type) } + if (snapshot.roomId.isBlank()) return RoomRealtimeEvent.Malformed(type) RoomRealtimeEvent.SnapshotEvent(snapshot) } WatchTogetherRealtime.TypeTransportCommand -> { - val command = obj["command"] as? JsonObject ?: return null + val command = obj["command"] as? JsonObject ?: return RoomRealtimeEvent.Malformed(type) val parsed = try { json.decodeFromJsonElement(TransportCommand.serializer(), command) } catch (_: Exception) { - return null + return RoomRealtimeEvent.Malformed(type) + } + // A command must say what to do, when, and in what state the room + // ends up. Without any of those it can't be applied safely, so the + // room is reconciled instead. The server always sends all three. + if (parsed.action == TransportAction.Unknown || parsed.playbackState == RoomPlaybackState.Unknown || + parseRfc3339ToEpochMillis(parsed.executeAt) == null + ) { + return RoomRealtimeEvent.Malformed(type) } RoomRealtimeEvent.TransportCommandEvent(parsed) } WatchTogetherRealtime.TypeSuggestionsUpdate -> { - val array = obj["suggestions"] as? JsonArray ?: return null + val array = obj["suggestions"] as? JsonArray ?: return RoomRealtimeEvent.Malformed(type) val list = try { json.decodeFromJsonElement(ListSerializer(Suggestion.serializer()), array) } catch (_: Exception) { - return null + return RoomRealtimeEvent.Malformed(type) } RoomRealtimeEvent.SuggestionsEvent(list) } - WatchTogetherRealtime.TypeRoomClosed -> { - val reason = (obj["reason"] as? JsonPrimitive)?.content - RoomRealtimeEvent.Closed(reason) - } - WatchTogetherRealtime.TypePong -> { - fun str(key: String) = (obj[key] as? JsonPrimitive)?.content ?: "" - RoomRealtimeEvent.Pong( - clientSentAt = str("client_sent_at"), - serverReceivedAt = str("server_received_at"), - serverSentAt = str("server_sent_at"), - ) - } - WatchTogetherRealtime.TypeError -> { - fun str(key: String) = (obj[key] as? JsonPrimitive)?.content ?: "" - RoomRealtimeEvent.Error(code = str("code"), message = str("message")) - } + WatchTogetherRealtime.TypeRoomClosed -> + RoomRealtimeEvent.Closed((obj["reason"] as? JsonPrimitive)?.content) + WatchTogetherRealtime.TypeConnectionReplaced -> + RoomRealtimeEvent.ConnectionReplaced((obj["reason"] as? JsonPrimitive)?.content) + WatchTogetherRealtime.TypePong -> RoomRealtimeEvent.Pong( + clientSentAt = str("client_sent_at"), + serverReceivedAt = str("server_received_at"), + serverSentAt = str("server_sent_at"), + clientReceivedMs = receivedAtMs, + ) + WatchTogetherRealtime.TypeError -> RoomRealtimeEvent.Error(code = str("code"), message = str("message")) else -> null } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeEvent.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeEvent.kt index 1bdb1a9960..1e28ccc9bf 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeEvent.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeEvent.kt @@ -10,13 +10,15 @@ object WatchTogetherRealtime { const val TypeTransportCommand = "transport_command" const val TypeSuggestionsUpdate = "suggestions_update" const val TypeRoomClosed = "room_closed" + const val TypeConnectionReplaced = "connection_replaced" const val TypePong = "pong" const val TypeError = "error" } /** * A decoded room realtime event. The repository folds these into its - * StateFlows / feeds them to the RoomSyncEngine. + * StateFlows and hands transport commands and clock samples to the player + * binding. */ sealed class RoomRealtimeEvent { /** A physical connection completed its handshake and is writable. */ @@ -25,18 +27,66 @@ sealed class RoomRealtimeEvent { data class SnapshotEvent(val room: RoomSnapshot) : RoomRealtimeEvent() data class TransportCommandEvent(val command: TransportCommand) : RoomRealtimeEvent() data class SuggestionsEvent(val suggestions: List) : RoomRealtimeEvent() + + /** + * A clock sample. [clientReceivedMs] is stamped on the wall clock when the + * frame left the socket, before any downstream collector could delay it. + */ data class Pong( val clientSentAt: String, val serverReceivedAt: String, val serverSentAt: String, + val clientReceivedMs: Long? = null, ) : RoomRealtimeEvent() - /** Explicit server `room_closed{reason}`. Terminal at the protocol layer. */ + /** + * Explicit server `room_closed{reason}`. Terminal. At connect time the + * reason is `not_found` or `ended`; every later close says `host_left`, + * including the host's explicit End. + */ data class Closed(val reason: String? = null) : RoomRealtimeEvent() - /** Physical EOF, handshake failure, or socket I/O failure. Transient. */ + /** + * Another connection for this account and profile took over the room + * membership. Terminal for this device only: the room keeps going, and + * only an explicit user rejoin may reclaim it. + */ + data class ConnectionReplaced(val reason: String? = null) : RoomRealtimeEvent() + + /** Physical EOF, handshake failure, or socket I/O failure. Transient unless [cause] is terminal. */ data class TransportTerminated(val cause: Throwable? = null) : RoomRealtimeEvent() - /** Server `error{code,message}`. */ + /** Server `error{code,message}`: an action-level rejection, not the end of the room. */ data class Error(val code: String, val message: String) : RoomRealtimeEvent() + + /** + * A frame of a known [type] that did not decode. The repository reconciles + * by reading the room instead of silently dropping state. + */ + data class Malformed(val type: String) : RoomRealtimeEvent() } + +/** + * The room ticket mint refused the request. [terminal] refusals (403, 404, + * 409, 422) end the engagement; anything else is a transport failure that may + * be retried. A 401 left after the interceptor's one refresh is retried too: a + * refresh that failed on the network says nothing about the party, and a real + * sign-out leaves the party through the identity transition. + */ +class RoomTicketRefusedException( + val status: Int, + val code: String, +) : IllegalStateException("room_ticket_refused:$status:$code") { + val terminal: Boolean get() = status in TERMINAL_STATUSES + + private companion object { + val TERMINAL_STATUSES = setOf(403, 404, 409, 422) + } +} + +/** + * The minted ticket was valid but `expires_in` was 0: the access token or the + * room proof has under a second left. Renew whichever is expiring, then mint + * again; minting again unchanged cannot help. + */ +class RoomTicketExpiringException : IllegalStateException("room_ticket_expiring") diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/WatchTogetherApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/WatchTogetherApi.kt index 96301a02bd..5cffee4693 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/WatchTogetherApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/WatchTogetherApi.kt @@ -3,271 +3,407 @@ package org.siloserver.silo.network.api import org.siloserver.silo.model.watchtogether.AddSuggestionRequest import org.siloserver.silo.model.watchtogether.CreateRoomRequest import org.siloserver.silo.model.watchtogether.JoinRoomRequest +import org.siloserver.silo.model.watchtogether.MemberStateRequest +import org.siloserver.silo.model.watchtogether.MemberStateResponse +import org.siloserver.silo.model.watchtogether.PickerResponse import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest import org.siloserver.silo.model.watchtogether.RoomResponse +import org.siloserver.silo.model.watchtogether.SelectionModeRequest import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.model.watchtogether.SourceFallbackRequest +import org.siloserver.silo.model.watchtogether.SuggestionReceipt import org.siloserver.silo.model.watchtogether.SuggestionsResponse import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest +import org.siloserver.silo.model.watchtogether.WatchTogetherCapabilitiesV2 import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot -import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.apiv2.ApiV2Gate -import org.siloserver.silo.network.apiv2.safeApiV2Call +import org.siloserver.silo.network.apiv2.OwnerPolicy +import org.siloserver.silo.network.apiv2.ownedV2Call import org.siloserver.silo.network.authScope import org.siloserver.silo.network.requireSiloAuth +import org.siloserver.silo.network.singleAttempt import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header +import io.ktor.client.request.parameter import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.encodeURLPathPart -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid /** - * Watch Together REST surface (`/api/v2/watch-together`). Create/join return a - * second token (the **room JWT**) distinct from the auth JWT; every - * room-scoped call passes it as the `X-Room-Token` header. Behind an - * interface so the repository's tests fake the transport (matching - * NotificationsApi). + * Watch Party REST surface (`/api/v2/watch-together`). * - * The room WS is a separate transport (see WatchTogetherRealtimeClient); this - * is REST only. Suggestion mutations return 201/204 on the server; this - * transport follows each with a suggestions read so callers still receive the - * current list. Non-2xx replies are RFC 9457 problems surfaced as - * [ApiResult.Error]. + * Create and join return a room access token (the room proof) distinct from + * the account token. Member operations send it as `X-Room-Token`; host-only + * operations authorize by host identity and send no proof. Every successful + * room response carries a renewed proof. + * + * Retry classes follow each operation's server `RetrySafety`. The three + * non-retryable operations (start, select, promote) are sent with + * [singleAttempt] so no authentication refresh replays them, and on + * [nonReplayingClient], whose engine never resends a request after its + * connection fails (OkHttp otherwise does on a reused connection). The + * repository reconciles such uncertain outcomes by reading the room. + * + * Suggestion mutations return only a receipt. Reading the list afterwards is + * the caller's reconciliation, so a failed read cannot turn a successful vote + * into a reported failure. */ interface WatchTogetherApi { + /** GET /capabilities (account authority). */ + suspend fun capabilities(scope: AuthScopeSnapshot): ApiResult - /** POST /rooms — caller becomes host; 201 with room + room_access_token. */ + /** POST /rooms — 201; exact replay of the same `room_id` and body returns the same room. */ suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult - /** POST /join — resolves a code or join token; 200 with room + room_access_token. */ + /** POST /join — resolves a code or join token and issues proof. */ suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult - /** GET /rooms/{id} — current room snapshot. */ + /** GET /rooms/{id} — current snapshot and renewed proof. */ suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult - /** PUT /rooms/{id}/selection (host-only). */ - suspend fun setSelection( - roomId: String, - roomToken: String, - request: SetSelectionRequest, - scope: AuthScopeSnapshot, - ): ApiResult + /** PATCH /rooms/{id}/policy (host). */ + suspend fun updatePolicy(roomId: String, request: UpdatePolicyRequest, scope: AuthScopeSnapshot): ApiResult - /** PATCH /rooms/{id}/policy (host-only). */ - suspend fun updatePolicy( - roomId: String, - roomToken: String, - request: UpdatePolicyRequest, - scope: AuthScopeSnapshot, - ): ApiResult + /** PUT /rooms/{id}/staged-selection (host, host-pick lobby). Staging never plays. */ + suspend fun stageSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot): ApiResult - /** DELETE /rooms/{id} (host-only) — 204 → Unit. */ - suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult + /** POST /rooms/{id}/playback/start (host). Non-retryable. */ + suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult - /** GET /rooms/{id}/suggestions. */ - suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult + /** POST /rooms/{id}/playback/stop (host). Returns everyone to the staged lobby. */ + suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult - /** POST /rooms/{id}/suggestions. */ - suspend fun addSuggestion( - roomId: String, - roomToken: String, - request: AddSuggestionRequest, - scope: AuthScopeSnapshot, - ): ApiResult + /** PATCH /rooms/{id}/selection-mode (host, lobby only). */ + suspend fun setSelectionMode(roomId: String, request: SelectionModeRequest, scope: AuthScopeSnapshot): ApiResult - /** DELETE /rooms/{id}/suggestions/{sid} (host or suggester). */ - suspend fun deleteSuggestion( + /** PUT /rooms/{id}/selection (host, host-pick rooms). Starts playback. Non-retryable. */ + suspend fun setSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot): ApiResult + + /** DELETE /rooms/{id} (host) — 204. */ + suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult + + /** POST /rooms/{id}/source-fallback (connected member). */ + suspend fun sourceFallback( roomId: String, roomToken: String, - suggestionId: String, + request: SourceFallbackRequest, scope: AuthScopeSnapshot, - ): ApiResult + ): ApiResult - /** POST /rooms/{id}/suggestions/{sid}/vote — 409 on dup. */ - suspend fun vote( + /** GET /rooms/{id}/suggestions — one page in creation order. */ + suspend fun listSuggestions( roomId: String, roomToken: String, - suggestionId: String, scope: AuthScopeSnapshot, + cursor: String? = null, + limit: Int = SUGGESTION_PAGE_LIMIT, ): ApiResult - /** DELETE /rooms/{id}/suggestions/{sid}/vote — 409 if not voted. */ - suspend fun unvote( + /** POST /rooms/{id}/suggestions — 201 receipt echoing the sent id. */ + suspend fun addSuggestion( roomId: String, roomToken: String, - suggestionId: String, + request: AddSuggestionRequest, scope: AuthScopeSnapshot, - ): ApiResult + ): ApiResult + + /** DELETE /rooms/{id}/suggestions/{sid} (host or suggester) — 204. */ + suspend fun deleteSuggestion(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot): ApiResult + + /** POST /rooms/{id}/suggestions/{sid}/vote — 204, also when already voted. */ + suspend fun vote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot): ApiResult + + /** DELETE /rooms/{id}/suggestions/{sid}/vote — 204, also when not voted. */ + suspend fun unvote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot): ApiResult - /** POST /rooms/{id}/suggestions/promote (host-only) → room. */ + /** POST /rooms/{id}/suggestions/promote (host). Starts playback. Non-retryable. */ suspend fun promoteSuggestion( roomId: String, roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, ): ApiResult + + /** POST /rooms/{id}/member-state — a bounded read. */ + suspend fun memberState( + roomId: String, + roomToken: String, + request: MemberStateRequest, + scope: AuthScopeSnapshot, + ): ApiResult + + /** GET /rooms/{id}/picker. */ + suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult + + companion object { + const val SUGGESTION_PAGE_LIMIT = 50 + } } -@OptIn(ExperimentalUuidApi::class) class DefaultWatchTogetherApi( private val client: HttpClient, private val gate: ApiV2Gate, + private val tokens: TokenManager? = null, + private val nonReplayingClient: HttpClient = client, ) : WatchTogetherApi { + override suspend fun capabilities(scope: AuthScopeSnapshot): ApiResult = + call(scope, HttpStatusCode.OK, { + client.get("$BASE/capabilities") { pin(scope) } + }) { it } + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult = - safeApiV2Call(gate) { + call(scope, HttpStatusCode.Created, { client.post("$BASE/rooms") { pin(scope) contentType(ContentType.Application.Json) - setBody( - buildJsonObject { - put("room_id", Uuid.random().toString()) - put("selection_mode", request.selectionMode ?: "host_pick") - }, - ) + setBody(request) } - } + }) { it.validated(request.roomId) } override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult = - safeApiV2Call(gate) { + call(scope, HttpStatusCode.OK, { client.post("$BASE/join") { pin(scope) contentType(ContentType.Application.Json) setBody(request) } - } + }) { it.validated(expectedRoomId = null) } override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult = - safeApiV2Call(gate) { - client.get(room(roomId)) { pin(scope, roomToken) } + roomCall(roomId, scope) { client.get(room(roomId)) { pin(scope, roomToken) } } + + override suspend fun updatePolicy( + roomId: String, + request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, + ): ApiResult = roomCall(roomId, scope) { + client.patch("${room(roomId)}/policy") { + pin(scope) + contentType(ContentType.Application.Json) + setBody(request) + } + } + + override suspend fun stageSelection( + roomId: String, + request: SetSelectionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = roomCall(roomId, scope) { + client.put("${room(roomId)}/staged-selection") { + pin(scope) + contentType(ContentType.Application.Json) + setBody(selectionBody(request)) + } + } + + override suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult = + roomCall(roomId, scope) { + nonReplayingClient.post("${room(roomId)}/playback/start") { pin(scope); singleAttempt() } } + override suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot): ApiResult = + roomCall(roomId, scope) { client.post("${room(roomId)}/playback/stop") { pin(scope) } } + + override suspend fun setSelectionMode( + roomId: String, + request: SelectionModeRequest, + scope: AuthScopeSnapshot, + ): ApiResult = roomCall(roomId, scope) { + client.patch("${room(roomId)}/selection-mode") { + pin(scope) + contentType(ContentType.Application.Json) + setBody(request) + } + } + override suspend fun setSelection( roomId: String, - roomToken: String, request: SetSelectionRequest, scope: AuthScopeSnapshot, - ): ApiResult = safeApiV2Call(gate) { - client.put("${room(roomId)}/selection") { - pin(scope, roomToken) + ): ApiResult = roomCall(roomId, scope) { + nonReplayingClient.put("${room(roomId)}/selection") { + pin(scope) + singleAttempt() contentType(ContentType.Application.Json) - setBody( - buildJsonObject { - put("content_id", request.contentId) - request.fileId?.let { put("file_id", it.toString()) } - request.libraryId?.let { put("library_id", it.toString()) } - }, - ) + setBody(selectionBody(request)) } } - override suspend fun updatePolicy( + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult = + call(scope, HttpStatusCode.NoContent, { client.delete(room(roomId)) { pin(scope) } }) { it } + + override suspend fun sourceFallback( roomId: String, roomToken: String, - request: UpdatePolicyRequest, + request: SourceFallbackRequest, scope: AuthScopeSnapshot, - ): ApiResult = safeApiV2Call(gate) { - client.patch("${room(roomId)}/policy") { + ): ApiResult = roomCall(roomId, scope) { + client.post("${room(roomId)}/source-fallback") { pin(scope, roomToken) contentType(ContentType.Application.Json) setBody(request) } } - override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult = - safeApiV2Call(gate) { - client.delete(room(roomId)) { pin(scope, roomToken) } - } - override suspend fun listSuggestions( roomId: String, roomToken: String, scope: AuthScopeSnapshot, - ): ApiResult = safeApiV2Call(gate) { - client.get("${room(roomId)}/suggestions") { pin(scope, roomToken) } - } + cursor: String?, + limit: Int, + ): ApiResult = + call(scope, HttpStatusCode.OK, { + client.get("${room(roomId)}/suggestions") { + pin(scope, roomToken) + parameter("limit", limit) + cursor?.let { parameter("cursor", it) } + } + }) { page -> + require(page.suggestions.all { it.id.isNotBlank() && it.roomId == roomId }) { + "The suggestion page named another room." + } + require(page.page?.hasMore != true || !page.page.nextCursor.isNullOrBlank()) { + "The suggestion page has more items but no cursor." + } + page + } override suspend fun addSuggestion( roomId: String, roomToken: String, request: AddSuggestionRequest, scope: AuthScopeSnapshot, - ): ApiResult = thenList(roomId, roomToken, scope) { - client.post("${room(roomId)}/suggestions") { - pin(scope, roomToken) - contentType(ContentType.Application.Json) - val fields = SiloJson.encodeToJsonElement(AddSuggestionRequest.serializer(), request).jsonObject - setBody(JsonObject(fields + ("suggestion_id" to JsonPrimitive(Uuid.random().toString())))) + ): ApiResult = + call(scope, HttpStatusCode.Created, { + client.post("${room(roomId)}/suggestions") { + pin(scope, roomToken) + contentType(ContentType.Application.Json) + setBody(request) + } + }) { receipt -> + require(receipt.suggestionId == request.suggestionId) { "The suggestion receipt named another suggestion." } + receipt } - } override suspend fun deleteSuggestion( roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ): ApiResult = thenList(roomId, roomToken, scope) { + ): ApiResult = call(scope, HttpStatusCode.NoContent, { client.delete(suggestion(roomId, suggestionId)) { pin(scope, roomToken) } - } + }) { it } override suspend fun vote( roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ): ApiResult = thenList(roomId, roomToken, scope) { + ): ApiResult = call(scope, HttpStatusCode.NoContent, { client.post("${suggestion(roomId, suggestionId)}/vote") { pin(scope, roomToken) } - } + }) { it } override suspend fun unvote( roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ): ApiResult = thenList(roomId, roomToken, scope) { + ): ApiResult = call(scope, HttpStatusCode.NoContent, { client.delete("${suggestion(roomId, suggestionId)}/vote") { pin(scope, roomToken) } - } + }) { it } override suspend fun promoteSuggestion( roomId: String, roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, - ): ApiResult = safeApiV2Call(gate) { - client.post("${room(roomId)}/suggestions/promote") { + ): ApiResult = roomCall(roomId, scope) { + nonReplayingClient.post("${room(roomId)}/suggestions/promote") { pin(scope, roomToken) + singleAttempt() contentType(ContentType.Application.Json) setBody(request) } } - /** Runs a bodiless suggestion mutation, then reads the current list. */ - private suspend inline fun thenList( + override suspend fun memberState( roomId: String, roomToken: String, + request: MemberStateRequest, scope: AuthScopeSnapshot, - mutation: () -> io.ktor.client.statement.HttpResponse, - ): ApiResult { - return when (val mutated = safeApiV2Call(gate, mutation)) { - is ApiResult.Success -> listSuggestions(roomId, roomToken, scope) - is ApiResult.Error -> mutated - is ApiResult.NetworkError -> mutated + ): ApiResult { + if (request.contentIds.isEmpty() || request.contentIds.any { it.isBlank() }) { + return ApiResult.Error(422, "validation_failed", "Member state needs at least one content id.") + } + val requested = request.contentIds.toSet() + return call(scope, HttpStatusCode.OK, { + client.post("${room(roomId)}/member-state") { + pin(scope, roomToken) + contentType(ContentType.Application.Json) + setBody(request) + } + }) { body -> + require(body.items.all { it.contentId in requested }) { "Member state named an unrequested item." } + body } } + override suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult = + call(scope, HttpStatusCode.OK, { + client.get("${room(roomId)}/picker") { pin(scope, roomToken) } + }) { body -> + require((body.continueTogether + body.watchlistUnion).none { it.item.contentId.isBlank() }) { + "The picker returned a card without an id." + } + body + } + + /** + * One owned exchange; the captured [scope] must still own the session + * before and after. Without a token manager there is nothing to compare + * against, so only the status and body checks apply. + */ + private suspend inline fun call( + scope: AuthScopeSnapshot, + expected: HttpStatusCode, + crossinline block: suspend () -> HttpResponse, + crossinline project: (T) -> R, + ): ApiResult = + ownedV2Call(gate, tokens, scope.takeIf { tokens != null }, OwnerPolicy.PROFILE, expected, { block() }, project) + + private suspend inline fun roomCall( + roomId: String, + scope: AuthScopeSnapshot, + crossinline block: suspend () -> HttpResponse, + ): ApiResult = + call(scope, HttpStatusCode.OK, block) { it.validated(roomId) } + + private fun RoomResponse.validated(expectedRoomId: String?): RoomResponse { + require(room.roomId.isNotBlank() && roomAccessToken.isNotBlank()) { "The room response was incomplete." } + require(expectedRoomId == null || room.roomId == expectedRoomId) { "The room response named another room." } + return this + } + + private fun selectionBody(request: SetSelectionRequest) = buildJsonObject { + put("content_id", request.contentId) + request.fileId?.let { put("file_id", it.toString()) } + request.libraryId?.let { put("library_id", it.toString()) } + } + private companion object { const val BASE = "/api/v2/watch-together" } @@ -277,9 +413,11 @@ class DefaultWatchTogetherApi( private fun suggestion(roomId: String, suggestionId: String) = "${room(roomId)}/suggestions/${suggestionId.encodeURLPathPart()}" - private fun io.ktor.client.request.HttpRequestBuilder.pin(scope: AuthScopeSnapshot, roomToken: String? = null) { + private fun HttpRequestBuilder.pin(scope: AuthScopeSnapshot, roomToken: String? = null) { authScope(scope) requireSiloAuth() - roomToken?.let { header("X-Room-Token", it) } + roomToken?.let { header(ROOM_TOKEN_HEADER, it) } } } + +internal const val ROOM_TOKEN_HEADER = "X-Room-Token" diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/WatchTogetherRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/WatchTogetherRepository.kt index d9a7e22047..cbeb3964fd 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/WatchTogetherRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/WatchTogetherRepository.kt @@ -1,31 +1,54 @@ package org.siloserver.silo.repository -import org.siloserver.silo.RoomSyncEngine import org.siloserver.silo.model.watchtogether.AddSuggestionRequest import org.siloserver.silo.model.watchtogether.CreateRoomRequest import org.siloserver.silo.model.watchtogether.JoinRoomRequest +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.MemberStateRequest +import org.siloserver.silo.model.watchtogether.MemberStateResponse +import org.siloserver.silo.model.watchtogether.PickerResponse import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest +import org.siloserver.silo.model.watchtogether.RoomPhase import org.siloserver.silo.model.watchtogether.RoomResponse +import org.siloserver.silo.model.watchtogether.RoomSelectionMode import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.SelectionModeRequest import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.model.watchtogether.SourceFallbackReason +import org.siloserver.silo.model.watchtogether.SourceFallbackRequest import org.siloserver.silo.model.watchtogether.Suggestion -import org.siloserver.silo.model.watchtogether.SuggestionsResponse +import org.siloserver.silo.model.watchtogether.SuggestionReceipt import org.siloserver.silo.model.watchtogether.TransportCommand import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest +import org.siloserver.silo.model.watchtogether.GuestControlPolicy import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.RoomRealtimeEvent +import org.siloserver.silo.network.RoomTicketExpiringException +import org.siloserver.silo.network.RoomTicketRefusedException import org.siloserver.silo.network.WatchTogetherRealtimeClient import org.siloserver.silo.network.api.WatchTogetherApi +import org.siloserver.silo.util.formatEpochMillisRfc3339 import org.siloserver.silo.util.parseRfc3339ToEpochMillis +import org.siloserver.silo.util.wallClockMillis +import org.siloserver.silo.watchtogether.RoomClockEstimate +import org.siloserver.silo.watchtogether.RoomClockEstimator import org.siloserver.silo.watchtogether.RoomDeliveryEcho import org.siloserver.silo.watchtogether.RoomDeliveryLatch -import org.siloserver.silo.watchtogether.WatchTogetherEntryGateway +import org.siloserver.silo.watchtogether.RoomPlaybackRoom +import org.siloserver.silo.watchtogether.RoomProof import org.siloserver.silo.watchtogether.RoomSessionRepository import org.siloserver.silo.watchtogether.RoomTransportIntent +import org.siloserver.silo.watchtogether.rankSuggestions +import org.siloserver.silo.watchtogether.roomProofExpiryMs import org.siloserver.silo.watchtogether.roomTransportAuthorized +import kotlin.concurrent.Volatile import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,41 +56,35 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.random.Random import kotlin.time.TimeSource /** * A transport command paired with its `execute_at` already parsed to a * server-epoch millisecond value, so the player binding never has to touch the * RFC3339Nano wire string. [executeAtMs] is null when the wire timestamp is - * malformed (the binding should then apply immediately / fall back). + * malformed. [receivedAtMs] is the monotonic receipt time. */ data class ScheduledTransportCommand( val command: TransportCommand, val executeAtMs: Long?, val connection: WatchTogetherConnectionState, + val receivedAtMs: Long = 0L, ) /** - * A pong frame with its three server-clock RFC3339Nano timestamps parsed to - * epoch millis. The fourth NTP sample value, `clientReceivedMs`, is NOT here: - * it must be stamped by the player binding at the instant it receives the pong - * (the shared module has no wall clock — `Date.now()`/`System.currentTimeMillis` - * are platform APIs), and supplied when the binding calls - * `RoomSyncEngine.recordPongSample(clientSentMs, serverReceivedMs, serverSentMs, clientReceivedMs)`. - * Any field is null when its wire timestamp was malformed. + * [disconnectedAtMs] is the monotonic time the room socket stopped being + * writable, so the UI can wait before warning about a routine reconnect. */ -data class PongSample( - val clientSentMs: Long?, - val serverReceivedMs: Long?, - val serverSentMs: Long?, -) - data class WatchTogetherConnectionState( val generation: Long = 0L, val epoch: Long = 0L, val writable: Boolean = false, + val disconnectedAtMs: Long? = null, ) /** Immutable, atomically-published authority for one room + physical socket. */ @@ -79,101 +96,172 @@ class RoomTransportAuthorization internal constructor( val snapshot: RoomSnapshot, ) +/** Why this device's Watch Party engagement ended. */ +object WatchPartyEndReason { + /** The host ended the party, or the host-disconnect grace ran out. The server does not say which. */ + const val HostLeft = "host_left" + const val NotFound = "not_found" + const val Ended = "ended" + /** This profile joined the party on another device. The room keeps going. */ + const val Replaced = "connection_replaced" + const val ConnectionLost = "connection_lost" + const val Unauthorized = "unauthorized" +} + +/** The last engagement that ended on this device, kept so the UI can explain it and offer Rejoin. */ +data class WatchPartyEnded( + val roomId: String, + val code: String, + val reason: String, + val wasHost: Boolean, +) + +/** A room or vote mutation in flight. One runs at a time so repeated taps cannot reorder work. */ +enum class WatchPartyPendingAction { + Create, Join, Stage, Start, Stop, End, Mode, Policy, Select, Promote, Suggest, RemoveSuggestion, Vote, Fallback, +} + +/** Client timing choices; the web client's values unless evidence says otherwise. */ +data class WatchPartyTiming( + val pingIntervalMs: Long = 15_000L, + val initialPings: Int = 3, + val initialPingSpacingMs: Long = 1_000L, + /** Renew the room proof this long before it expires: a socket lifetime plus a ticket. */ + val proofRenewalMarginMs: Long = 6 * 60_000L, + val proofUnknownRenewalMs: Long = 60 * 60_000L, + val proofRetryMs: Long = 30_000L, + val stableConnectionMs: Long = 30_000L, + /** Keep reconnecting at least this long after the last healthy socket; covers the host grace. */ + val reconnectBudgetMs: Long = 150_000L, + val maxReconnectFailures: Int = 6, + val backoffMs: List = listOf(500L, 1_000L, 2_000L, 5_000L), + val expiringTicketRetryMs: Long = 1_500L, + /** Test seam: background pings and proof renewal off. */ + val backgroundWork: Boolean = true, +) + /** - * Singleton owner of one Watch Together room's state and websocket lifecycle. - * The per-room WS IS the feature (no REST fallback); the REST calls are for - * create/join + host management + suggestion mutations. + * The single owner of this device's Watch Party engagement: membership, room + * proof, the room socket, the server clock estimate, snapshot ordering, + * suggestions, and room actions. The per-room socket IS the feature (no REST + * fallback); REST carries create/join, host management, reads, and suggestion + * mutations. * - * Holds the **room JWT** internally after create/join so every room-scoped op - * passes it transparently. Folds snapshot/suggestions WS events into - * [roomSnapshot]/[suggestions] StateFlows, re-merging `voted_by_me` (forced - * false in broadcasts) from a locally-tracked vote set. Exposes the - * sync-relevant client→server send passthroughs and a [transportCommands] flow - * the player binding feeds to its [RoomSyncEngine] (the engine needs the - * player's local position/playing/clock, which live in the binding, not here). + * Identities are kept apart. A membership ([RoomBinding]) is the room, the + * captured authority, and a local generation; joining the same room again + * creates a new membership. The room proof renews from every successful room + * response without changing membership. Socket attempts are fenced by a + * connection owner and epoch. * - * [realtimeFactory] is injected so tests supply a fake event flow. + * Snapshot ordering: `generation` orders transport changes, not membership or + * readiness updates. An HTTP snapshot is accepted only when its generation is + * newer, or equal with no socket snapshot received since the request started, + * so a delayed response cannot erase a newer attachment or readiness update. + * A terminal engagement is never reopened by a late result. */ class WatchTogetherRepository( private val api: WatchTogetherApi, private val realtimeFactory: () -> WatchTogetherRealtimeClient? = { null }, private val monotonicNowMs: () -> Long = { MONOTONIC_ORIGIN.elapsedNow().inWholeMilliseconds }, private val authScopeProvider: suspend () -> AuthScopeSnapshot? = { null }, -) : RoomSessionRepository, WatchTogetherEntryGateway { + private val wallClockMs: () -> Long = ::wallClockMillis, + private val timing: WatchPartyTiming = WatchPartyTiming(), + private val random: Random = Random.Default, +) : RoomSessionRepository, RoomPlaybackRoom { /** Successful delivery state follows the process connection, not a UI controller. */ val roomDeliveryLatch = RoomDeliveryLatch() private data class RoomBinding( val roomId: String, - val roomToken: String, val authScope: AuthScopeSnapshot, val generation: Long, - ) + ) { + override fun toString(): String = "RoomBinding(roomId=, generation=$generation)" + } private val stateMutex = Mutex() + private val actionMutex = Mutex() + private val suggestionReadMutex = Mutex() private var nextGeneration = 0L private var latestRoomRequest = 0L private var binding: RoomBinding? = null + private var proof: RoomProof? = null private var terminalGeneration: Long? = null private var realtimeGeneration: Long? = null private var realtimeConnectionId: Long? = null private var nextConnectionOwner = 0L private var activeConnectionOwner: Long? = null + private var socketSnapshotSeq = 0L + private var socketSuggestionsSeq = 0L + private var suggestionReadRequests = 0L + private var suggestionReadStartedAt = 0L + private var lastSuggestionRead: Pair>>? = null + private var rawSuggestions: List = emptyList() + private val clockEstimator = RoomClockEstimator() + private val _roomSnapshot = MutableStateFlow(null) private val _suggestions = MutableStateFlow>(emptyList()) + private val _personalVotesKnown = MutableStateFlow(false) private val _roomDeliveryEcho = MutableStateFlow(null) + private val _connectionState = MutableStateFlow(WatchTogetherConnectionState()) + private val _latestCommand = MutableStateFlow(null) + private val _clock = MutableStateFlow(clockEstimator.estimate) + private val _roomClosedReason = MutableStateFlow(null) + + private val _closedByThisDevice = MutableSharedFlow(extraBufferCapacity = 4) + + /** + * Rooms this device ended for everyone. Leaving clears the engagement without + * an end reason, so this is how the recent party learns the room is gone. + */ + val closedByThisDevice: SharedFlow = _closedByThisDevice.asSharedFlow() + private val _ended = MutableStateFlow(null) + private val _pendingAction = 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 - private var transportAuthorization: RoomTransportAuthorization? = null - /** One atomic read; never pair independently-read snapshot/generation values. */ - fun currentTransportAuthorization(): RoomTransportAuthorization? = transportAuthorization + /** Suggestions in vote order, with this profile's own votes merged in. */ + val suggestions: StateFlow> = _suggestions.asStateFlow() /** - * Transport commands surfaced for the player binding to feed to its - * [RoomSyncEngine]. Buffered + drop-oldest so emission never suspends the - * collect loop. replay=0 — a late subscriber should not re-apply a stale - * command. + * False until an authenticated suggestion read has told this profile which + * suggestions it voted for. Socket broadcasts never carry personal votes. */ - private val _transportCommands = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val transportCommands: SharedFlow = _transportCommands.asSharedFlow() + val personalVotesKnown: StateFlow = _personalVotesKnown.asStateFlow() + override val roomDeliveryEcho: StateFlow = _roomDeliveryEcho.asStateFlow() + override val connectionState: StateFlow = _connectionState.asStateFlow() /** - * Pong samples for the player binding's engine clock-sync, with the three - * server-clock timestamps parsed to epoch millis. The binding stamps - * `clientReceivedMs` itself (see [PongSample]). + * The newest accepted transport command. Commands supersede each other, so + * the binding needs only the latest; a slow collector can never lose it. */ - private val _pongs = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val pongs: SharedFlow = _pongs.asSharedFlow() + override val latestTransportCommand: StateFlow = _latestCommand.asStateFlow() + + /** Server clock estimate, sampled on the room socket from the moment it opens. */ + override val clock: StateFlow = _clock.asStateFlow() + + /** The action in flight, if any. */ + val pendingAction: StateFlow = _pendingAction.asStateFlow() + + /** The last engagement that ended, with its reason. Cleared by [reset] and by a new membership. */ + val ended: StateFlow = _ended.asStateFlow() + + @kotlin.concurrent.Volatile + private var transportAuthorization: RoomTransportAuthorization? = null + + /** One atomic read; never pair independently-read snapshot/generation values. */ + override fun currentTransportAuthorization(): RoomTransportAuthorization? = transportAuthorization /** - * Why the room ended — TERMINAL only. Set exclusively on the server - * `room_closed` / [RoomRealtimeEvent.Closed] reason path (host left / explicit - * close); the player observes this and exits. Cleared on [reset] and at the - * start of a fresh [connect]. Transient server `error` frames do NOT populate - * this (they would eject the user) — see [errors]. + * Why the engagement ended — terminal only (see [WatchPartyEndReason]). + * Transient server `error` frames never populate this; they flow on + * [errors]. */ - private val _roomClosedReason = MutableStateFlow(null) override val roomClosedReason: StateFlow = _roomClosedReason.asStateFlow() /** - * Transient, non-terminal server `error` frames (e.g. a rejected - * transport_request). The UI may surface these as a snackbar/toast WITHOUT - * exiting the room. Buffered + drop-oldest so emission never suspends the - * fold; replay=0 so a late subscriber doesn't re-show a stale error. + * Transient, non-terminal notices (a rejected transport request, a local + * delivery failure). Buffered + drop-oldest; a missed notice is harmless. */ private val _errors = MutableSharedFlow( replay = 0, @@ -187,53 +275,35 @@ class WatchTogetherRepository( if (message.isNotBlank()) _errors.tryEmit(message) } - // Locally-tracked vote set: ids the local user has voted for. Used to - // re-merge voted_by_me into broadcast suggestion lists (which force false). + // Ids this profile has voted for, known only after an authenticated read. private val votedIds = mutableSetOf() @kotlin.concurrent.Volatile private var realtime: WatchTogetherRealtimeClient? = null - // ---- REST: create / join (store the room token) --------------------------- + override suspend fun membershipGeneration(): Long? = activeBinding()?.generation - override suspend fun createRoom(request: CreateRoomRequest): ApiResult { - val scope = authScopeProvider() ?: return missingAuthScope() + // ---- REST: create / join ------------------------------------------------ + + /** + * Create a room with the caller-selected identity in [request]. After an + * uncertain outcome, retry with the same request: the server replays it. + */ + suspend fun createRoom(request: CreateRoomRequest): ApiResult = action(WatchPartyPendingAction.Create) { + val scope = authScopeProvider() ?: return@action missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.createRoom(request, scope) - if (authScopeProvider() != scope) return obsoleteRoomRequest() - return if (r is ApiResult.Success) installRoomResponse(r.data, scope, requestGeneration) else r + if (authScopeProvider() != scope) return@action obsoleteRoomRequest() + if (r is ApiResult.Success) installRoomResponse(r.data, scope, requestGeneration) else r } - override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { - val scope = authScopeProvider() ?: return missingAuthScope() + suspend fun joinRoom(request: JoinRoomRequest): ApiResult = action(WatchPartyPendingAction.Join) { + val scope = authScopeProvider() ?: return@action missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.joinRoom(request, scope) - if (authScopeProvider() != scope) return obsoleteRoomRequest() - if (r !is ApiResult.Success) return r - val installed = installRoomResponse(r.data, scope, requestGeneration) - if (installed !is ApiResult.Success) return installed - - // The realtime handshake's initial event is a room snapshot; it does - // not replay suggestions that existed before this member connected. - // Hydrate them while the join lease is current so a re-entered lobby - // does not render an empty vote list until somebody mutates it. - val lease = stateMutex.withLock { - binding?.takeIf { current -> - latestRoomRequest == requestGeneration && - current.roomId == r.data.room.roomId && - isCurrentLocked(current) - } - } ?: return obsoleteRoomRequest() - publishSuggestionsResponse( - lease, - api.listSuggestions(lease.roomId, lease.roomToken, lease.authScope), - expectedRoomRequest = requestGeneration, - ) - val stillCurrent = stateMutex.withLock { - latestRoomRequest == requestGeneration && isCurrentLocked(lease) - } - if (!stillCurrent) return obsoleteRoomRequest() - return installed + if (authScopeProvider() != scope) return@action obsoleteRoomRequest() + if (r !is ApiResult.Success) return@action r + installRoomResponse(r.data, scope, requestGeneration) } private suspend fun beginRoomRequest(): Long = stateMutex.withLock { ++latestRoomRequest } @@ -250,17 +320,25 @@ class WatchTogetherRepository( if (requestGeneration != latestRoomRequest) return obsoleteRoomRequest() val installed = RoomBinding( roomId = data.room.roomId, - roomToken = data.roomAccessToken, authScope = scope, generation = ++nextGeneration, ) binding = installed + proof = RoomProof(data.roomAccessToken, roomProofExpiryMs(data.roomAccessToken)) terminalGeneration = null + // Offsets measured against another room, possibly on another + // server, must not schedule this one's commands. + clockEstimator.reset() + _clock.value = clockEstimator.estimate votedIds.clear() + rawSuggestions = emptyList() _suggestions.value = emptyList() + _personalVotesKnown.value = false _roomClosedReason.value = null + _ended.value = null _roomSnapshot.value = data.room _roomDeliveryEcho.value = null + _latestCommand.value = null _connectionState.value = WatchTogetherConnectionState(generation = installed.generation) realtimeConnectionId = null refreshTransportAuthorizationLocked() @@ -270,74 +348,324 @@ class WatchTogetherRepository( // ---- REST: host management ------------------------------------------------ - 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) + suspend fun stageSelection(request: SetSelectionRequest): ApiResult = + roomAction(WatchPartyPendingAction.Stage) { lease, _ -> api.stageSelection(lease.roomId, request, lease.authScope) } + + suspend fun startPlayback(): ApiResult = + roomAction(WatchPartyPendingAction.Start) { lease, _ -> api.startPlayback(lease.roomId, lease.authScope) } + + suspend fun stopPlayback(): ApiResult = + roomAction(WatchPartyPendingAction.Stop) { lease, _ -> api.stopPlayback(lease.roomId, lease.authScope) } + + suspend fun setSelectionMode(mode: RoomSelectionMode): ApiResult = + roomAction(WatchPartyPendingAction.Mode) { lease, _ -> + api.setSelectionMode(lease.roomId, SelectionModeRequest(mode.wire), lease.authScope) + } + + /** Direct selection: starts playback. Never use it to stage. */ + suspend fun setSelection(request: SetSelectionRequest): ApiResult = + roomAction(WatchPartyPendingAction.Select) { lease, _ -> api.setSelection(lease.roomId, request, lease.authScope) } + + suspend fun updatePolicy(policy: GuestControlPolicy): ApiResult = + roomAction(WatchPartyPendingAction.Policy) { lease, _ -> + api.updatePolicy(lease.roomId, UpdatePolicyRequest(policy.wire), lease.authScope) + } + + /** End the party for everyone (host). */ + override suspend fun closeRoom(): ApiResult = action(WatchPartyPendingAction.End) { + val lease = activeBinding() ?: return@action missingRoom() + val result = api.closeRoom(lease.roomId, lease.authScope) + when { + !isCurrent(lease) -> obsoleteRoomRequest() + result is ApiResult.Error && result.code == 409 -> { + // Already ended: converge on the ended state. + endIfCurrent(lease, WatchPartyEndReason.Ended) + _closedByThisDevice.tryEmit(lease.roomId) + ApiResult.Success(Unit) + } + else -> result.also { if (it is ApiResult.Success) _closedByThisDevice.tryEmit(lease.roomId) } + } } - suspend fun updatePolicy(request: UpdatePolicyRequest): ApiResult { - val lease = activeBinding() ?: return missingRoom() - val r = api.updatePolicy(lease.roomId, lease.roomToken, request, lease.authScope) - return publishRoomResponse(lease, r) + suspend fun promoteSuggestion(suggestionId: String): ApiResult = + roomAction(WatchPartyPendingAction.Promote) { lease, token -> + api.promoteSuggestion(lease.roomId, token, PromoteSuggestionRequest(suggestionId), lease.authScope) + } + + /** + * Ask the server to move the whole room to another source after an + * approved playback refusal. Fenced by [selectionRevision] and + * [failedFileId]; a stale request returns the current snapshot. + */ + suspend fun requestSourceFallback( + selectionRevision: Long, + failedFileId: String, + reason: SourceFallbackReason, + ): ApiResult = roomAction(WatchPartyPendingAction.Fallback) { lease, token -> + api.sourceFallback( + lease.roomId, + token, + SourceFallbackRequest(selectionRevision, failedFileId, reason.wire), + lease.authScope, + ) } - override suspend fun closeRoom(): ApiResult { + /** Read the room, adopting its snapshot (when newer) and renewed proof. */ + suspend fun refreshRoom(): ApiResult { val lease = activeBinding() ?: return missingRoom() - val result = api.closeRoom(lease.roomId, lease.roomToken, lease.authScope) - return if (isCurrent(lease)) result else obsoleteRoomRequest() + return reconcileRoom(lease) } - // ---- REST: suggestions ---------------------------------------------------- + // ---- REST: shared browsing ----------------------------------------------- - suspend fun refreshSuggestions(): ApiResult { + suspend fun memberState(contentIds: List): ApiResult { val lease = activeBinding() ?: return missingRoom() - val r = api.listSuggestions(lease.roomId, lease.roomToken, lease.authScope) - return publishSuggestionsResponse(lease, r) + val token = proofToken(lease) ?: return missingRoom() + val result = api.memberState(lease.roomId, token, MemberStateRequest(contentIds), lease.authScope) + return if (isCurrent(lease)) result else obsoleteRoomRequest() } - suspend fun addSuggestion(request: AddSuggestionRequest): ApiResult { + suspend fun picker(): ApiResult { val lease = activeBinding() ?: return missingRoom() - val r = api.addSuggestion(lease.roomId, lease.roomToken, request, lease.authScope) - return publishSuggestionsResponse(lease, r) + val token = proofToken(lease) ?: return missingRoom() + val result = api.picker(lease.roomId, token, lease.authScope) + return if (isCurrent(lease)) result else obsoleteRoomRequest() } - suspend fun deleteSuggestion(suggestionId: String): ApiResult { + // ---- REST: suggestions ---------------------------------------------------- + + /** + * Read every suggestion page under one captured membership and publish the + * merged list with authoritative personal votes. Concurrent requests + * coalesce: a caller whose request predates a read that already started + * gets that read's result. + */ + suspend fun refreshSuggestions(): ApiResult> { val lease = activeBinding() ?: return missingRoom() - val r = api.deleteSuggestion(lease.roomId, lease.roomToken, suggestionId, lease.authScope) - return publishSuggestionsResponse(lease, r) + val ticket = stateMutex.withLock { ++suggestionReadRequests } + return suggestionReadMutex.withLock { + val shared = lastSuggestionRead + if (shared?.first == lease && stateMutex.withLock { suggestionReadStartedAt >= ticket }) { + // A read for this membership started after this request and + // already ran: share its outcome, a failure included. + return@withLock when { + !isCurrent(lease) -> obsoleteRoomRequest() + shared.second is ApiResult.Success -> ApiResult.Success(_suggestions.value) + else -> shared.second + } + } + val socketSeqAtStart = stateMutex.withLock { + suggestionReadStartedAt = suggestionReadRequests + socketSuggestionsSeq + } + readSuggestionPages(lease, socketSeqAtStart).also { lastSuggestionRead = lease to it } + } } - suspend fun vote(suggestionId: String): ApiResult { - val lease = activeBinding() ?: return missingRoom() - val r = api.vote(lease.roomId, lease.roomToken, suggestionId, lease.authScope) - if (r !is ApiResult.Success) return r + private suspend fun readSuggestionPages(lease: RoomBinding, socketSeqAtStart: Long): ApiResult> { + val pages = mutableListOf() + var cursor: String? = null + while (true) { + val token = proofToken(lease) ?: return missingRoom() + when (val page = api.listSuggestions(lease.roomId, token, lease.authScope, cursor)) { + is ApiResult.Success -> { + pages += page.data.suggestions + cursor = page.data.page?.nextCursor?.takeIf { page.data.page.hasMore } + if (cursor == null) break + } + is ApiResult.Error -> return page + is ApiResult.NetworkError -> return page + } + } + val merged = pages.distinctBy { it.id } val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false - votedIds.add(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = true) + votedIds.clear() + votedIds.addAll(merged.filter { it.votedByMe }.map { it.id }) + _personalVotesKnown.value = true + // A socket broadcast that arrived during the read carries newer + // tallies; keep its rows and take only personal votes from HTTP. + if (socketSuggestionsSeq == socketSeqAtStart) rawSuggestions = merged + publishSuggestionsLocked() true } - return if (published) r else obsoleteRoomRequest() + return if (published) ApiResult.Success(_suggestions.value) else obsoleteRoomRequest() } - suspend fun unvote(suggestionId: String): ApiResult { - val lease = activeBinding() ?: return missingRoom() - val r = api.unvote(lease.roomId, lease.roomToken, suggestionId, lease.authScope) - if (r !is ApiResult.Success) return r + /** + * Add a suggestion with the caller-selected id in [request]. After an + * uncertain outcome, retry with the same request. A failed list refresh + * after the receipt does not fail the suggestion. + */ + suspend fun addSuggestion(request: AddSuggestionRequest): ApiResult = + suggestionAction(WatchPartyPendingAction.Suggest) { lease, token -> + api.addSuggestion(lease.roomId, token, request, lease.authScope) + } + + /** Remove a suggestion (host, or its suggester). A 404 means it is already gone. */ + suspend fun deleteSuggestion(suggestionId: String): ApiResult { + val result = suggestionAction(WatchPartyPendingAction.RemoveSuggestion) { lease, token -> + api.deleteSuggestion(lease.roomId, token, suggestionId, lease.authScope) + } + return if (result is ApiResult.Error && result.code == 404 && result.error != "no_active_room") { + ApiResult.Success(Unit) + } else { + result + } + } + + suspend fun vote(suggestionId: String): ApiResult = setVote(suggestionId, voted = true) + + suspend fun unvote(suggestionId: String): ApiResult = setVote(suggestionId, voted = false) + + private suspend fun setVote(suggestionId: String, voted: Boolean): ApiResult = + suggestionAction(WatchPartyPendingAction.Vote) { lease, token -> + val result = if (voted) { + api.vote(lease.roomId, token, suggestionId, lease.authScope) + } else { + api.unvote(lease.roomId, token, suggestionId, lease.authScope) + } + if (result is ApiResult.Success) { + stateMutex.withLock { + if (isCurrentLocked(lease)) { + if (voted) votedIds.add(suggestionId) else votedIds.remove(suggestionId) + publishSuggestionsLocked() + } + } + } + result + } + + private fun publishSuggestionsLocked() { + _suggestions.value = rankSuggestions(rawSuggestions).map { s -> + if ((s.id in votedIds) != s.votedByMe) s.copy(votedByMe = s.id in votedIds) else s + } + } + + // ---- Actions --------------------------------------------------------------- + + private suspend fun action(kind: WatchPartyPendingAction, block: suspend () -> ApiResult): ApiResult = + actionMutex.withLock { + _pendingAction.value = kind + try { + block() + } finally { + _pendingAction.value = null + } + } + + /** + * One room mutation returning a snapshot. Success publishes it (and the + * renewed proof). An uncertain outcome (network failure, malformed + * response) or an ambiguous refusal (403, 404, 409) reconciles by reading + * the room; the read, not the refusal, decides whether the room ended. + * Nothing is replayed. + */ + private suspend fun roomAction( + kind: WatchPartyPendingAction, + call: suspend (RoomBinding, String) -> ApiResult, + ): ApiResult = action(kind) { + val lease = activeBinding() ?: return@action missingRoom() + val token = proofToken(lease) ?: return@action missingRoom() + val seq = stateMutex.withLock { socketSnapshotSeq } + when (val result = call(lease, token)) { + is ApiResult.Success -> publishRoomResponse(lease, result, seq) + is ApiResult.Error -> { + if (result.code in RECONCILE_STATUSES || result.error == INVALID_RESPONSE) reconcileRoom(lease) + result + } + is ApiResult.NetworkError -> { + reconcileRoom(lease) + result + } + } + } + + private suspend fun suggestionAction( + kind: WatchPartyPendingAction, + call: suspend (RoomBinding, String) -> ApiResult, + ): ApiResult { + val (lease, result) = action(kind) { + val lease = activeBinding() ?: return@action missingRoom() + val token = proofToken(lease) ?: return@action missingRoom() + ApiResult.Success(lease to call(lease, token)) + }.let { outer -> + when (outer) { + is ApiResult.Success -> outer.data + is ApiResult.Error -> return outer + is ApiResult.NetworkError -> return outer + } + } + if (!isCurrent(lease)) return obsoleteRoomRequest() + // Reconcile the list whatever the outcome; the mutation result stands + // even if this read fails. + refreshSuggestions() + if (result is ApiResult.Error && result.code == 409) reconcileRoom(lease) + return result + } + + // ---- Publication ------------------------------------------------------------ + + private suspend fun proofToken(lease: RoomBinding): String? = stateMutex.withLock { + if (isCurrentLocked(lease)) proof?.token else null + } + + private suspend fun publishRoomResponse( + lease: RoomBinding, + result: ApiResult.Success, + socketSeqAtRequest: Long, + ): ApiResult { + val response = result.data + if (response.room.roomId != lease.roomId || response.roomAccessToken.isBlank()) return invalidRoomResponse() + var ended = false val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false - votedIds.remove(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = true) + proof = RoomProof(response.roomAccessToken, roomProofExpiryMs(response.roomAccessToken)) + if (response.room.phase == RoomPhase.Ended) { + endLocked(lease, WatchPartyEndReason.Ended) + ended = true + return@withLock true + } + val current = _roomSnapshot.value + val newer = current == null || + response.room.generation > current.generation || + (response.room.generation == current.generation && socketSnapshotSeq == socketSeqAtRequest) + if (newer) { + _roomSnapshot.value = response.room + val echo = _roomDeliveryEcho.value + if (echo != null && echo.playbackSessionId != response.room.attachedSessionId) { + _roomDeliveryEcho.value = null + } + refreshTransportAuthorizationLocked() + } true } - return if (published) r else obsoleteRoomRequest() + return when { + !published -> obsoleteRoomRequest() + ended -> ApiResult.Error(409, WatchPartyEndReason.Ended, "The Watch Party has ended.") + else -> result + } } - suspend fun promoteSuggestion(request: PromoteSuggestionRequest): ApiResult { - val lease = activeBinding() ?: return missingRoom() - val r = api.promoteSuggestion(lease.roomId, lease.roomToken, request, lease.authScope) - return publishRoomResponse(lease, r) + /** + * Read the room to settle an uncertain or ambiguous outcome. A 403, 404, + * or 409 here means this device can no longer take part. + */ + private suspend fun reconcileRoom(lease: RoomBinding): ApiResult { + val token = proofToken(lease) ?: return missingRoom() + val seq = stateMutex.withLock { socketSnapshotSeq } + return when (val r = api.getRoom(lease.roomId, token, lease.authScope)) { + is ApiResult.Success -> publishRoomResponse(lease, r, seq) + is ApiResult.Error -> { + when (r.code) { + 404 -> endIfCurrent(lease, WatchPartyEndReason.NotFound) + 409 -> endIfCurrent(lease, WatchPartyEndReason.Ended) + 403 -> endIfCurrent(lease, WatchPartyEndReason.Unauthorized) + } + r + } + is ApiResult.NetworkError -> r + } } private suspend fun activeBinding(): RoomBinding? = stateMutex.withLock { @@ -370,72 +698,42 @@ class WatchTogetherRepository( } } - private suspend fun publishRoomResponse( - lease: RoomBinding, - result: ApiResult, - ): ApiResult { - if (result !is ApiResult.Success) return result - if (result.data.room.roomId != lease.roomId) return invalidRoomResponse() - val published = stateMutex.withLock { - if (!isCurrentLocked(lease)) return@withLock false - _roomSnapshot.value = result.data.room - _roomDeliveryEcho.value = null - refreshTransportAuthorizationLocked() - true - } - return if (published) result else obsoleteRoomRequest() + private suspend fun endIfCurrent(lease: RoomBinding, reason: String) { + stateMutex.withLock { if (isCurrentLocked(lease)) endLocked(lease, reason) } } - private suspend fun publishSuggestionsResponse( - 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) || - (expectedConnectionOwner != null && activeConnectionOwner != expectedConnectionOwner) - ) { - return@withLock false - } - applySuggestions(result.data.suggestions, fromBroadcast = false) - true - } - return if (published) result else obsoleteRoomRequest() + /** Terminal for this membership: nothing may reopen it. */ + private fun endLocked(lease: RoomBinding, reason: String) { + val last = _roomSnapshot.value + terminalGeneration = lease.generation + _ended.value = WatchPartyEnded( + roomId = lease.roomId, + code = last?.code.orEmpty(), + reason = reason, + wasHost = last?.selfRole == MemberRole.Host, + ) + _roomClosedReason.value = reason + _roomSnapshot.value = null + _roomDeliveryEcho.value = null + _latestCommand.value = null + _connectionState.value = _connectionState.value.copy(writable = false) + realtimeConnectionId = null + refreshTransportAuthorizationLocked() } private fun invalidRoomResponse(): ApiResult.Error = ApiResult.Error(502, "invalid_room_response", "The room response was missing or mismatched.") private fun missingRoom(): ApiResult.Error = - ApiResult.Error(409, "no_active_room", "No active Watch Together room.") + ApiResult.Error(409, "no_active_room", "No active Watch Party.") private fun missingAuthScope(): ApiResult.Error = - ApiResult.Error(401, "missing_auth_scope", "No authenticated Watch Together scope.") + ApiResult.Error(401, "missing_auth_scope", "No authenticated Watch Party scope.") private fun obsoleteRoomRequest(): ApiResult.Error = - ApiResult.Error(409, "obsolete_room_request", "The Watch Together identity changed.") + ApiResult.Error(409, "obsolete_room_request", "The Watch Party identity changed.") - /** - * Publish suggestions, re-merging `voted_by_me` from the local [votedIds] - * 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) { - 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 - } - } - - // ---- WS: client→server send passthroughs ---------------------------------- + // ---- WS: client→server sends ------------------------------------------------ private suspend fun currentWritableRealtime(): WatchTogetherRealtimeClient? = stateMutex.withLock { @@ -449,7 +747,7 @@ class WatchTogetherRepository( } } - suspend fun attachSession(sessionId: String): Boolean = + override suspend fun attachSession(sessionId: String): Boolean = currentWritableRealtime()?.attachSession(sessionId) ?: false suspend fun transportRequest( @@ -464,7 +762,7 @@ class WatchTogetherRepository( * lock that guards room replacement, so a delayed UI coroutine cannot send * an old room's command through a replacement room or changed policy. */ - suspend fun transportRequestForAuthorization( + override suspend fun transportRequestForAuthorization( authorization: RoomTransportAuthorization, intent: RoomTransportIntent, action: String, @@ -501,34 +799,47 @@ class WatchTogetherRepository( ) } - suspend fun stateReport( + override suspend fun stateReport( sessionId: String, positionSeconds: Double, isPaused: Boolean, - ): Boolean = currentWritableRealtime()?.stateReport(sessionId, positionSeconds, isPaused) ?: false + commandId: String?, + isReady: Boolean, + ): Boolean = currentWritableRealtime()?.stateReport(sessionId, positionSeconds, isPaused, commandId, isReady) ?: false - suspend fun ready( + override suspend fun ready( sessionId: String, positionSeconds: Double, isPaused: Boolean, - ): Boolean = currentWritableRealtime()?.ready(sessionId, positionSeconds, isPaused) ?: false + commandId: String?, + ): Boolean = currentWritableRealtime()?.ready(sessionId, positionSeconds, isPaused, commandId) ?: false - suspend fun buffering( + override suspend fun buffering( sessionId: String, positionSeconds: Double, isPaused: Boolean, ): Boolean = currentWritableRealtime()?.buffering(sessionId, positionSeconds, isPaused) ?: false - suspend fun ping(clientSentAt: String): Boolean = - currentWritableRealtime()?.ping(clientSentAt) ?: false + /** Advisory lobby Ready over the room socket. */ + suspend fun setLobbyReady(ready: Boolean): Boolean = + currentWritableRealtime()?.lobbyReady(ready) ?: false + + // ---- WS lifecycle ------------------------------------------------------------ - // ---- WS lifecycle: connect + reconnect-with-backoff ------------------------ + private sealed interface AttemptEnd { + data class Closed(val reason: String?) : AttemptEnd + data object Replaced : AttemptEnd + data class Transport(val cause: Throwable?) : AttemptEnd + } /** - * Collect the room socket with capped-backoff reconnect, folding each event - * into the state flows. Suspends until the caller's scope is cancelled OR a - * server `room_closed` arrives (which stops reconnecting). Backoff steps are - * [BACKOFF_MS]; a healthy event resets the index. + * Own the room socket for the current membership until the engagement ends + * or the caller's scope is cancelled. A socket that stayed up reconnects at + * once (the server rotates every socket after five minutes); a socket that + * fails quickly backs off. Reconnecting continues for at least + * [WatchPartyTiming.reconnectBudgetMs] after the last healthy socket, which + * covers the host's two-minute grace. Terminal frames and terminal ticket + * refusals end the engagement; `connection_replaced` never reclaims it. */ override suspend fun connect(roomId: String) { val lease = activeBinding()?.takeIf { it.roomId == roomId } ?: return @@ -543,100 +854,220 @@ class WatchTogetherRepository( _connectionState.value = _connectionState.value.copy( generation = lease.generation, writable = false, + disconnectedAtMs = monotonicNowMs(), ) - _roomClosedReason.value = null refreshTransportAuthorizationLocked() newOwner } + try { + coroutineScope { + val renewal = if (timing.backgroundWork) launch { renewProofLoop(lease, owner) } else null + socketLoop(lease, owner, client, this) + renewal?.cancel() + } + } finally { + withContext(NonCancellable) { clearRealtimeIfCurrent(lease, client, owner) } + } + } + + private suspend fun socketLoop( + lease: RoomBinding, + owner: Long, + client: WatchTogetherRealtimeClient, + scope: CoroutineScope, + ) { var backoffIndex = 0 var failures = 0 - while (true) { - if (!isCurrent(lease, owner)) break - var closedByServer = false + var lastHealthyMs = monotonicNowMs() + var proofRenewedForRefusal = false + while (isCurrent(lease, owner)) { + val token = proofToken(lease) ?: break var openedAtMs: Long? = null var sawSnapshot = false + var end: AttemptEnd = AttemptEnd.Transport(null) + var pings: Job? = null try { - client.connect(lease.roomId, lease.roomToken, lease.authScope).collect { event -> + client.connect(lease.roomId, token, lease.authScope).collect { event -> if (!isCurrent(lease, owner)) throw ObsoleteBinding - if (event is RoomRealtimeEvent.Closed) { - // Any server-initiated close (with or without a reason) is terminal. - // The event flow (a hot SharedFlow) never completes on its - // own, so we stop collecting by throwing a private sentinel. - closedByServer = true - stateMutex.withLock { - if (isCurrentOwnerLocked(lease, owner)) { - terminalGeneration = lease.generation - _roomClosedReason.value = event.reason ?: "room_closed" - _roomSnapshot.value = null - _roomDeliveryEcho.value = null - refreshTransportAuthorizationLocked() + when (event) { + RoomRealtimeEvent.Opened -> { + openedAtMs = monotonicNowMs() + markOpened(lease, owner, client.currentConnectionId()) + pings?.cancel() + if (timing.backgroundWork) pings = scope.launch { pingLoop(lease, owner, client) } + // Suggestions that predate this socket are not replayed on + // it. Hydrate off the receive path. + scope.launch { refreshSuggestions() } + } + is RoomRealtimeEvent.Closed -> { + end = AttemptEnd.Closed(event.reason) + throw AttemptStopped + } + is RoomRealtimeEvent.ConnectionReplaced -> { + end = AttemptEnd.Replaced + throw AttemptStopped + } + is RoomRealtimeEvent.TransportTerminated -> { + end = AttemptEnd.Transport(event.cause) + throw AttemptStopped + } + is RoomRealtimeEvent.Malformed -> scope.launch { reconcileRoom(lease) } + else -> { + if (event is RoomRealtimeEvent.SnapshotEvent && event.room.roomId == lease.roomId) { + sawSnapshot = true } + fold(lease, owner, event) } - throw ServerClosed - } else if (event is RoomRealtimeEvent.TransportTerminated) { - markNotWritable(lease, owner) - throw TransportEnded - } 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 - ) { - sawSnapshot = true } - fold(lease, owner, event) } - markNotWritable(lease, owner) - if (isStableAttempt(openedAtMs, sawSnapshot)) { - failures = 0 - backoffIndex = 0 - } - failures++ } catch (e: CancellationException) { - clearRealtimeIfCurrent(lease, client, owner) + pings?.cancel() throw e } catch (_: ObsoleteBinding) { + pings?.cancel() break - } catch (_: ServerClosed) { - // terminal — handled below via closedByServer - } catch (_: Throwable) { - // Any throw (including from a flapping server) counts as a failure, - // regardless of whether a healthy event arrived in the same attempt. - markNotWritable(lease, owner) - if (isStableAttempt(openedAtMs, sawSnapshot)) { - failures = 0 - backoffIndex = 0 + } catch (_: AttemptStopped) { + // `end` records why. + } catch (failure: Throwable) { + end = AttemptEnd.Transport(failure) + } + pings?.cancel() + markNotWritable(lease, owner) + lastSocketEnd = describeSocketEnd(end, openedAtMs?.let { monotonicNowMs() - it }) + when (val ended = end) { + is AttemptEnd.Closed -> { + endIfOwner(lease, owner, ended.reason?.takeIf { it.isNotBlank() } ?: WatchPartyEndReason.HostLeft) + break + } + AttemptEnd.Replaced -> { + endIfOwner(lease, owner, WatchPartyEndReason.Replaced) + break + } + is AttemptEnd.Transport -> when (val cause = ended.cause) { + is RoomTicketRefusedException -> { + if (cause.status == 403 && !proofRenewedForRefusal) { + // The proof may have lapsed; one renewal decides. + proofRenewedForRefusal = true + if (reconcileRoom(lease) is ApiResult.Success) continue + } + if (cause.terminal) { + endIfOwner(lease, owner, ticketEndReason(cause.status)) + break + } + } + is RoomTicketExpiringException -> { + // The proof or the access token is about to lapse. Renew + // the proof when it may be the one; an expiring access + // token lapses within the retry and the next mint's 401 + // refreshes it. Each attempt counts against the budget, + // so a server that keeps answering this way ends the + // engagement instead of retrying forever. + val expiry = stateMutex.withLock { proof?.expiresAtEpochMs } + if (expiry == null || expiry - serverWallNowMs() < timing.proofRenewalMarginMs) { + reconcileRoom(lease) + } + failures++ + if (reconnectBudgetSpent(failures, lastHealthyMs)) { + endIfOwner(lease, owner, WatchPartyEndReason.ConnectionLost) + break + } + delay(timing.expiringTicketRetryMs) + continue + } + else -> Unit } - failures++ } - if (closedByServer) break if (!isCurrent(lease, owner)) break - if (failures >= MAX_RECONNECT_ATTEMPTS) { - stateMutex.withLock { - if (isCurrentOwnerLocked(lease, owner)) { - terminalGeneration = lease.generation - _roomClosedReason.value = "connection_lost" - _roomSnapshot.value = null - _roomDeliveryEcho.value = null - refreshTransportAuthorizationLocked() - } + val now = monotonicNowMs() + val opened = openedAtMs + if (opened != null && sawSnapshot) { + proofRenewedForRefusal = false + if (now - opened >= timing.stableConnectionMs) { + // Routine rotation: reconnect at once. + lastHealthyMs = now + failures = 0 + backoffIndex = 0 + continue } + } + failures++ + if (reconnectBudgetSpent(failures, lastHealthyMs)) { + endIfOwner(lease, owner, WatchPartyEndReason.ConnectionLost) break } - delay(BACKOFF_MS[backoffIndex]) - backoffIndex = (backoffIndex + 1).coerceAtMost(BACKOFF_MS.lastIndex) + val step = timing.backoffMs[backoffIndex.coerceAtMost(timing.backoffMs.lastIndex)] + delay(step + random.nextLong(0L, step / 4 + 1)) + backoffIndex = (backoffIndex + 1).coerceAtMost(timing.backoffMs.lastIndex) + } + } + + private fun reconnectBudgetSpent(failures: Int, lastHealthyMs: Long): Boolean = + failures >= timing.maxReconnectFailures && monotonicNowMs() - lastHealthyMs >= timing.reconnectBudgetMs + + /** + * The server's wall clock as this device best knows it. Proof expiry is a + * server timestamp, so a device clock that runs fast or slow must not + * decide when to renew. + */ + private fun serverWallNowMs(): Long = wallClockMs() + (_clock.value.offsetMs ?: 0L) + + /** + * Why the last room socket ended and how long it had been open, for the + * debug harness. Carries exception class names and messages only, never + * tickets or tokens. + */ + @Volatile + var lastSocketEnd: String? = null + private set + + private fun describeSocketEnd(end: AttemptEnd, openMs: Long?): String { + val what = when (end) { + is AttemptEnd.Closed -> "closed:${end.reason}" + AttemptEnd.Replaced -> "replaced" + is AttemptEnd.Transport -> end.cause?.let { cause -> + "transport:${cause::class.simpleName}:${cause.message?.take(160)}" + } ?: "transport:eof" + } + return "$what after ${openMs ?: -1}ms" + } + + private fun ticketEndReason(status: Int): String = when (status) { + 404 -> WatchPartyEndReason.NotFound + 409 -> WatchPartyEndReason.Ended + else -> WatchPartyEndReason.Unauthorized + } + + private suspend fun pingLoop(lease: RoomBinding, owner: Long, client: WatchTogetherRealtimeClient) { + var sent = 0 + while (isCurrent(lease, owner)) { + client.ping(formatEpochMillisRfc3339(wallClockMs())) + sent++ + delay(if (sent < timing.initialPings) timing.initialPingSpacingMs else timing.pingIntervalMs) + } + } + + private suspend fun renewProofLoop(lease: RoomBinding, owner: Long) { + var renewedLastPass = false + while (isCurrent(lease, owner)) { + val expiry = stateMutex.withLock { proof?.expiresAtEpochMs } + val waitMs = if (expiry == null) { + timing.proofUnknownRenewalMs + } else { + expiry - serverWallNowMs() - timing.proofRenewalMarginMs + } + // A proof that is already due right after a successful renewal + // (a short-lived proof, or a clock this device cannot correct) + // would otherwise be read again at once, in a tight loop. + val pause = if (renewedLastPass) waitMs.coerceAtLeast(timing.proofRetryMs) else waitMs + if (pause > 0) delay(pause) + if (!isCurrent(lease, owner)) break + val renewed = reconcileRoom(lease) + renewedLastPass = renewed is ApiResult.Success + if (!renewedLastPass) { + if (!isCurrent(lease, owner)) break + delay(timing.proofRetryMs) + } } - clearRealtimeIfCurrent(lease, client, owner) } private suspend fun isCurrent(lease: RoomBinding): Boolean = @@ -648,6 +1079,10 @@ class WatchTogetherRepository( private fun isCurrentOwnerLocked(lease: RoomBinding, owner: Long): Boolean = isCurrentLocked(lease) && activeConnectionOwner == owner + private suspend fun endIfOwner(lease: RoomBinding, owner: Long, reason: String) { + stateMutex.withLock { if (isCurrentOwnerLocked(lease, owner)) endLocked(lease, reason) } + } + private suspend fun clearRealtimeIfCurrent( lease: RoomBinding, client: WatchTogetherRealtimeClient, @@ -682,6 +1117,7 @@ class WatchTogetherRepository( generation = lease.generation, epoch = if (previous.generation == lease.generation) previous.epoch + 1 else 1, writable = true, + disconnectedAtMs = null, ) refreshTransportAuthorizationLocked() } @@ -691,79 +1127,117 @@ class WatchTogetherRepository( private suspend fun markNotWritable(lease: RoomBinding, owner: Long) { stateMutex.withLock { if (binding == lease && activeConnectionOwner == owner) { - _connectionState.value = _connectionState.value.copy(writable = false) + val previous = _connectionState.value + _connectionState.value = previous.copy( + writable = false, + disconnectedAtMs = previous.disconnectedAtMs ?: monotonicNowMs(), + ) realtimeConnectionId = null refreshTransportAuthorizationLocked() } } } - private fun isStableAttempt(openedAtMs: Long?, sawSnapshot: Boolean): Boolean = - sawSnapshot && openedAtMs != null && monotonicNowMs() - openedAtMs >= STABLE_CONNECTION_MS - - /** Sentinel to unwind the [connect] collect loop on a server `room_closed`. */ - private object ServerClosed : Throwable() - private object TransportEnded : Throwable() + /** Sentinels that unwind one socket attempt's collect loop. */ + private object AttemptStopped : Throwable() private object ObsoleteBinding : Throwable() - /** Pure-ish fold of one realtime event into the state flows + side streams. */ + /** Fold one realtime event into the state flows. */ private suspend fun fold(lease: RoomBinding, owner: Long, event: RoomRealtimeEvent) { stateMutex.withLock { if (!isCurrentOwnerLocked(lease, owner)) return when (event) { - RoomRealtimeEvent.Opened -> Unit - 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.SnapshotEvent -> { + val room = event.room + if (room.roomId != lease.roomId) return + if (room.phase == RoomPhase.Ended) { + endLocked(lease, WatchPartyEndReason.Ended) + return } - is RoomRealtimeEvent.SuggestionsEvent -> applySuggestions(event.suggestions, fromBroadcast = true) - is RoomRealtimeEvent.TransportCommandEvent -> _transportCommands.tryEmit( - ScheduledTransportCommand( + val current = _roomSnapshot.value + if (current != null && room.generation < current.generation) return + socketSnapshotSeq++ + _roomSnapshot.value = room + _roomDeliveryEcho.value = room.attachedSessionId + ?.takeIf { it.isNotBlank() } + ?.let { sessionId -> + val connection = _connectionState.value + RoomDeliveryEcho( + connectionGeneration = connection.generation, + connectionEpoch = connection.epoch, + playbackSessionId = sessionId, + ) + } + refreshTransportAuthorizationLocked() + } + is RoomRealtimeEvent.SuggestionsEvent -> { + socketSuggestionsSeq++ + rawSuggestions = event.suggestions.filter { it.roomId.isBlank() || it.roomId == lease.roomId } + publishSuggestionsLocked() + } + is RoomRealtimeEvent.TransportCommandEvent -> { + _latestCommand.value = ScheduledTransportCommand( command = event.command, executeAtMs = parseRfc3339ToEpochMillis(event.command.executeAt), connection = _connectionState.value, - ), - ) - is RoomRealtimeEvent.Pong -> _pongs.tryEmit( - PongSample( - clientSentMs = parseRfc3339ToEpochMillis(event.clientSentAt), - serverReceivedMs = parseRfc3339ToEpochMillis(event.serverReceivedAt), - serverSentMs = parseRfc3339ToEpochMillis(event.serverSentAt), - ), - ) - is RoomRealtimeEvent.Closed -> { /* lifecycle handled in connect() */ } - is RoomRealtimeEvent.TransportTerminated -> Unit + receivedAtMs = monotonicNowMs(), + ) + } + is RoomRealtimeEvent.Pong -> { + val sent = parseRfc3339ToEpochMillis(event.clientSentAt) + val serverReceived = parseRfc3339ToEpochMillis(event.serverReceivedAt) + val serverSent = parseRfc3339ToEpochMillis(event.serverSentAt) + val received = event.clientReceivedMs + if (sent != null && serverReceived != null && serverSent != null && received != null) { + // The receipt was stamped when the frame was decoded; it + // may be folded later. Pair it with the monotonic time + // of that same moment, or the wait reads as a wall-clock + // jump and wipes the estimate. + val waitedMs = (wallClockMs() - received).coerceAtLeast(0L) + clockEstimator.record(sent, serverReceived, serverSent, received, monotonicNowMs() - waitedMs) + _clock.value = clockEstimator.estimate + } + } is RoomRealtimeEvent.Error -> - // Transient, NON-terminal: a server `error` frame (e.g. a rejected - // transport_request) must NOT feed roomClosedReason. + // Transient, NON-terminal: a rejected request must not end the party. _errors.tryEmit(event.message.ifBlank { event.code }) + RoomRealtimeEvent.Opened, + is RoomRealtimeEvent.Closed, + is RoomRealtimeEvent.ConnectionReplaced, + is RoomRealtimeEvent.TransportTerminated, + is RoomRealtimeEvent.Malformed -> Unit } } } - /** Clear all room state on leave. The connect() loop ends via scope cancellation. */ + /** + * Check the local wall clock against the monotonic clock. A jump resets + * the server-time estimate so no timing work uses a stale offset. + */ + override suspend fun checkClockContinuity(): RoomClockEstimate = stateMutex.withLock { + if (clockEstimator.checkContinuity(wallClockMs(), monotonicNowMs())) _clock.value = clockEstimator.estimate + _clock.value + } + + /** Clear all room state on leave or identity change. The connect() loop ends via scope cancellation. */ override suspend fun reset() { stateMutex.withLock { nextGeneration++ latestRoomRequest++ binding = null + proof = null terminalGeneration = null _roomSnapshot.value = null _roomDeliveryEcho.value = null + _latestCommand.value = null + rawSuggestions = emptyList() _suggestions.value = emptyList() + _personalVotesKnown.value = false _roomClosedReason.value = null + _ended.value = null votedIds.clear() + clockEstimator.reset() + _clock.value = clockEstimator.estimate realtime = null realtimeGeneration = null realtimeConnectionId = null @@ -774,16 +1248,10 @@ class WatchTogetherRepository( } companion object { - /** Reconnect backoff steps (ms) — spec: not after room_closed. */ - val BACKOFF_MS = longArrayOf(500L, 1_000L, 2_000L, 5_000L) - - /** - * Maximum number of consecutive connection failures (e.g. throws during - * [connect] collection, factory/handshake errors) before the reconnect - * loop gives up. Reset to zero on any healthy server event. - */ - const val MAX_RECONNECT_ATTEMPTS = 6 - const val STABLE_CONNECTION_MS = 30_000L + private const val INVALID_RESPONSE = "invalid_response" + + /** Refusals that may mean the room ended or this membership lapsed; the room read decides. */ + private val RECONCILE_STATUSES = setOf(403, 404, 409) private val MONOTONIC_ORIGIN = TimeSource.Monotonic.markNow() } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/util/Rfc3339.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/util/Rfc3339.kt index aa16542de9..2a290b3a66 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/util/Rfc3339.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/util/Rfc3339.kt @@ -93,3 +93,34 @@ private fun daysFromCivil(year: Int, month: Int, day: Int): Long { val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy return era * 146097L + doe - 719468L } + +/** + * Formats epoch milliseconds as a UTC RFC3339 timestamp with millisecond + * precision (`2026-06-12T09:30:00.123Z`), the inverse of + * [parseRfc3339ToEpochMillis] for the Watch Party clock-sync pings. + */ +fun formatEpochMillisRfc3339(epochMillis: Long): String { + val epochSecond = epochMillis.floorDiv(1_000L) + val millis = epochMillis.mod(1_000L).toInt() + val epochDay = epochSecond.floorDiv(86_400L) + val secondOfDay = epochSecond.mod(86_400L).toInt() + val (year, month, day) = civilFromDays(epochDay) + fun pad(value: Int, width: Int) = value.toString().padStart(width, '0') + return "${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}T" + + "${pad(secondOfDay / 3_600, 2)}:${pad(secondOfDay / 60 % 60, 2)}:${pad(secondOfDay % 60, 2)}." + + "${pad(millis, 3)}Z" +} + +/** Proleptic-Gregorian civil date for days since 1970-01-01 (Hinnant). */ +private fun civilFromDays(epochDay: Long): Triple { + val z = epochDay + 719468L + val era = z.floorDiv(146097L) + val doe = z - era * 146097L + val yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 + val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + val mp = (5 * doy + 2) / 153 + val day = (doy - (153 * mp + 2) / 5 + 1).toInt() + val month = (if (mp < 10) mp + 3 else mp - 9).toInt() + val year = (yoe + era * 400 + if (month <= 2) 1 else 0).toInt() + return Triple(year, month, day) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/util/WallClock.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/util/WallClock.kt new file mode 100644 index 0000000000..0974c75646 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/util/WallClock.kt @@ -0,0 +1,8 @@ +package org.siloserver.silo.util + +/** + * Current wall-clock time in epoch milliseconds. Room clock sampling compares + * it with server timestamps, which are wall-clock instants; local scheduling + * uses a monotonic source instead. + */ +expect fun wallClockMillis(): Long diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt new file mode 100644 index 0000000000..dba75b745a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt @@ -0,0 +1,600 @@ +package org.siloserver.silo.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.watchtogether.AddSuggestionRequest +import org.siloserver.silo.model.watchtogether.CreateRoomRequest +import org.siloserver.silo.model.watchtogether.GuestControlPolicy +import org.siloserver.silo.model.watchtogether.ItemMemberState +import org.siloserver.silo.model.watchtogether.JoinRoomRequest +import org.siloserver.silo.model.watchtogether.PickerResponse +import org.siloserver.silo.model.watchtogether.RoomResponse +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.WatchPartyEnded +import org.siloserver.silo.repository.WatchPartyPendingAction +import org.siloserver.silo.repository.WatchTogetherConnectionState +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.LobbyReadiness +import org.siloserver.silo.watchtogether.RecentWatchParties +import org.siloserver.silo.watchtogether.RecentWatchParty +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.WatchPartyAvailability +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.WatchPartyDestination +import org.siloserver.silo.watchtogether.WatchPartyEligibility +import org.siloserver.silo.watchtogether.WatchPartyFeatures +import org.siloserver.silo.watchtogether.WatchPartyInvite +import org.siloserver.silo.watchtogether.canRemoveSuggestion +import org.siloserver.silo.watchtogether.lobbyReadiness +import org.siloserver.silo.watchtogether.newWatchPartyId +import org.siloserver.silo.watchtogether.parseWatchPartyInvite +import org.siloserver.silo.watchtogether.watchPartyDestination +import org.siloserver.silo.watchtogether.watchPartyEligibility +import org.siloserver.silo.watchtogether.watchPartyErrorMessage +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** An item to stage or suggest, as the detail page or picker displayed it. */ +data class WatchPartyItem( + val contentId: String, + val contentType: String, + val title: String, + val subtitle: String? = null, + val posterUrl: String? = null, + val fileId: Int? = null, + val libraryId: Int? = null, +) + +/** + * The Watch Party hub: availability, the live party (Return to Party), the + * recent party (Rejoin), and hosting or joining. A create keeps its + * caller-selected room id until it definitely succeeds or fails, so an + * explicit retry after an uncertain outcome replays the same room instead of + * creating a second one. + */ +class WatchPartyHubViewModel( + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val availability: WatchPartyAvailabilityRepository, + private val recents: RecentWatchParties, + /** Whether a link's server is the server this app is signed in to (base path included). */ + private val isCurrentServer: (String) -> Boolean, +) : ViewModel() { + + data class UiState( + /** Null while the first probe runs. */ + val availability: WatchPartyAvailability? = null, + val current: RoomSnapshot? = null, + val recent: RecentWatchParty? = null, + val ended: WatchPartyEnded? = null, + val busy: WatchPartyPendingAction? = null, + val error: String? = null, + /** A create whose outcome is unknown can be retried with the same identity. */ + val createRetryable: Boolean = false, + val destination: WatchPartyDestination? = null, + /** + * A forced re-check is running. [availability] still shows the last + * answer meanwhile, so one-shot handoffs wait for this to clear. + */ + val checking: Boolean = false, + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** Forced re-checks still running; overlapping ones must all finish. */ + private val forcedChecks = MutableStateFlow(0) + + private var pendingCreate: CreateRoomRequest? = null + private var pendingStage: WatchPartyItem? = null + + init { + viewModelScope.launch { + combine( + repository.roomSnapshot, + repository.ended, + repository.pendingAction, + availability.availability, + ) { room, ended, pending, available -> + _uiState.update { + it.copy( + current = room?.takeIf { snapshot -> watchPartyDestination(snapshot) != null }, + ended = ended, + busy = pending, + availability = available ?: it.availability, + ) + } + }.collect {} + } + refresh() + } + + /** Probe availability (again when [force]) and reload the recent party. */ + fun refresh(force: Boolean = false) { + if (force) forcedChecks.update { it + 1 } + _uiState.update { it.copy(checking = forcedChecks.value > 0) } + viewModelScope.launch { + try { + val result = availability.refresh(force) + _uiState.update { it.copy(availability = result, recent = recents.current()) } + } finally { + if (force) forcedChecks.update { it - 1 } + _uiState.update { it.copy(checking = forcedChecks.value > 0) } + } + } + } + + /** Host an empty party. Host Picks unless [mode] says otherwise. */ + fun host(mode: RoomSelectionMode = RoomSelectionMode.HostPick) { + pendingStage = null + create(mode) + } + + /** + * Host a party with [item] staged. The item is staged in the lobby; nothing + * plays until the host starts it. If staging fails after the room exists, + * the room is kept and the lobby offers to try again. + */ + fun hostWithItem(item: WatchPartyItem) { + pendingStage = item + create(RoomSelectionMode.HostPick) + } + + /** Retry the last create with the same room identity. */ + fun retryCreate() { + val request = pendingCreate ?: return + launchCreate(request) + } + + private fun create(mode: RoomSelectionMode) { + if (_uiState.value.busy != null) return + val features = (availability.availability.value as? WatchPartyAvailability.Available)?.features + if (features?.stagedSelection != true) { + fail("This server can't host a Watch Party. Update the server to host one.") + return + } + // A different mode is a different draft; the same mode reuses an uncertain one. + val request = pendingCreate?.takeIf { it.selectionMode == mode.wire } + ?: CreateRoomRequest(roomId = newWatchPartyId(), selectionMode = mode.wire) + pendingCreate = request + launchCreate(request) + } + + private fun launchCreate(request: CreateRoomRequest) { + _uiState.update { it.copy(error = null, createRetryable = false) } + viewModelScope.launch { + when (val created = repository.createRoom(request)) { + is ApiResult.Success -> { + pendingCreate = null + enter(created.data.room) + pendingStage?.let { item -> stageAfterCreate(item) } + pendingStage = null + } + is ApiResult.NetworkError -> { + _uiState.update { + it.copy( + error = "Couldn't confirm the party was created. Try again.", + createRetryable = true, + ) + } + } + is ApiResult.Error -> { + if (created.error == "invalid_response") { + _uiState.update { + it.copy(error = watchPartyErrorMessage(created, CREATE_FAILED), createRetryable = true) + } + } else { + pendingCreate = null + fail(watchPartyErrorMessage(created, CREATE_FAILED)) + } + } + } + } + } + + private suspend fun stageAfterCreate(item: WatchPartyItem) { + val staged = repository.stageSelection( + SetSelectionRequest(contentId = item.contentId, fileId = item.fileId, libraryId = item.libraryId), + ) + if (staged !is ApiResult.Success) { + fail("The party is ready, but ${item.title} couldn't be added. Add it from the lobby.") + } + } + + /** Join by typed code or pasted invitation. */ + fun join(input: String) { + when (val invite = parseWatchPartyInvite(input)) { + null -> fail("Enter the 8-character party code or paste an invitation link.") + else -> joinInvite(invite) + } + } + + fun joinInvite(invite: WatchPartyInvite) { + if (_uiState.value.busy != null) return + if (repository.roomSnapshot.value != null) { + fail("You're already in a Watch Party. Leave it first to join a different one.") + return + } + val request = when (invite) { + is WatchPartyInvite.Code -> JoinRoomRequest(code = invite.code) + is WatchPartyInvite.Link -> { + if (!isCurrentServer(invite.serverUrl)) { + fail("This invitation is for a different Silo server. Sign in to that server to join.") + return + } + JoinRoomRequest(joinToken = invite.token) + } + } + launchJoin(request, "Couldn't join. Check the code and try again.") + } + + /** Explicit rejoin of the recent party: a fresh join by code. */ + fun rejoinRecent() { + val recent = _uiState.value.recent ?: return + if (_uiState.value.busy != null) return + launchJoin(JoinRoomRequest(code = recent.code), "Couldn't rejoin. The party may have ended.") { failure -> + if (failure is ApiResult.Error && (failure.code == 404 || failure.code == 409)) { + recents.forget(recent.roomId) + _uiState.update { it.copy(recent = null) } + } + } + } + + /** Return to the live party. */ + fun returnToParty() { + val room = repository.roomSnapshot.value ?: return + enter(room) + } + + private fun launchJoin( + request: JoinRoomRequest, + fallback: String, + onFailure: suspend (ApiResult) -> Unit = {}, + ) { + _uiState.update { it.copy(error = null) } + viewModelScope.launch { + when (val joined = repository.joinRoom(request)) { + is ApiResult.Success -> enter(joined.data.room) + else -> { + onFailure(joined) + fail( + when { + joined is ApiResult.Error && joined.code == 404 -> "No Watch Party uses that code." + // None of the ordinary conflicts apply to joining, + // so a join 409 is a party that has ended. + joined is ApiResult.Error && joined.code == 409 -> "That Watch Party has ended." + else -> watchPartyErrorMessage(joined, fallback) + }, + ) + } + } + } + } + + private fun enter(room: RoomSnapshot) { + roomSession.adopt(room.roomId) + _uiState.update { it.copy(destination = watchPartyDestination(room), error = null) } + } + + private fun fail(message: String) { + _uiState.update { it.copy(error = message) } + } + + fun consumeDestination() = _uiState.update { it.copy(destination = null) } + fun clearError() = _uiState.update { it.copy(error = null, createRetryable = false) } + + private companion object { + const val CREATE_FAILED = "Couldn't create the party. Try again." + } +} + +/** How the room socket is doing, for the reconnect notice. */ +sealed interface WatchPartyConnectionStatus { + data object Connected : WatchPartyConnectionStatus + /** [sinceMs] is monotonic; show a notice only once it has lasted 2 s. */ + data class Reconnecting(val sinceMs: Long) : WatchPartyConnectionStatus +} + +/** + * The lobby: the empty or staged item, members and lobby Ready, Start, mode + * and guest policy, suggestions and votes. Every action goes through the room + * owner, which runs one at a time; the room's phase alone decides when to + * open the player. + */ +class WatchPartyLobbyViewModel( + private val roomId: String, + private val repository: WatchTogetherRepository, + private val roomSession: RoomSession, + private val availability: WatchPartyAvailabilityRepository, +) : ViewModel() { + + data class LobbyState( + val room: RoomSnapshot? = null, + val suggestions: List = emptyList(), + val personalVotesKnown: Boolean = false, + val eligibility: WatchPartyEligibility = WatchPartyEligibility(), + val features: WatchPartyFeatures? = null, + val readiness: LobbyReadiness = LobbyReadiness(0, 0), + val connection: WatchPartyConnectionStatus = WatchPartyConnectionStatus.Connected, + val pending: WatchPartyPendingAction? = null, + val ended: WatchPartyEnded? = null, + /** The last suggestion draft whose outcome is unknown; retry keeps its id. */ + val suggestionRetry: WatchPartyItem? = null, + ) + + private val suggestionDraft = MutableStateFlow?>(null) + + val state: StateFlow = combine( + combine(repository.roomSnapshot, repository.suggestions, repository.personalVotesKnown, ::Triple), + combine(repository.pendingAction, repository.connectionState, repository.ended, ::Triple), + availability.availability, + suggestionDraft, + ) { (room, suggestions, votesKnown), (pending, connection, ended), available, draft -> + val features = (available as? WatchPartyAvailability.Available)?.features + LobbyState( + room = room, + suggestions = suggestions, + personalVotesKnown = votesKnown, + eligibility = watchPartyEligibility(room, features, busy = pending != null, personalVotesKnown = votesKnown), + features = features, + readiness = lobbyReadiness(room), + connection = connection.status(), + pending = pending, + ended = ended, + suggestionRetry = draft?.second, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, LobbyState()) + + /** Where to go: the player once the room plays. */ + val destination: StateFlow = repository.roomSnapshot + .map { room -> room?.takeIf { it.roomId == roomId }?.let(::watchPartyDestination) } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val _messages = MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** One-shot notices for failed actions. */ + val messages: SharedFlow = _messages.asSharedFlow() + + init { + roomSession.adopt(roomId) + viewModelScope.launch { availability.refresh() } + viewModelScope.launch { repository.errors.collect { _messages.tryEmit(it) } } + } + + fun stage(item: WatchPartyItem) = act("Couldn't add ${item.title}.") { + repository.stageSelection(SetSelectionRequest(item.contentId, item.fileId, item.libraryId)) + } + + fun start() = act("Couldn't start. Check the room and try again.") { repository.startPlayback() } + + fun setMode(mode: RoomSelectionMode) = act("Couldn't switch modes.") { repository.setSelectionMode(mode) } + + fun setGuestPolicy(policy: GuestControlPolicy) = act("Couldn't change who can play and pause.") { + repository.updatePolicy(policy) + } + + fun setLobbyReady(ready: Boolean) { + viewModelScope.launch { + if (!repository.setLobbyReady(ready)) _messages.tryEmit("Reconnecting to the party…") + } + } + + /** Suggest [item]. After an uncertain outcome, [retrySuggestion] resends the same suggestion. */ + fun suggest(item: WatchPartyItem) { + // An uncertain or in-flight suggestion of the same title resends its + // own id, so the server can't end up with the title twice. + val request = (suggestionDraft.value?.first ?: inFlightSuggestion)?.takeIf { it.contentId == item.contentId } ?: AddSuggestionRequest( + suggestionId = newWatchPartyId(), + contentId = item.contentId, + contentType = item.contentType, + title = item.title, + subtitle = item.subtitle, + posterUrl = item.posterUrl, + ) + sendSuggestion(request, item) + } + + fun retrySuggestion() { + val (request, item) = suggestionDraft.value ?: return + sendSuggestion(request, item) + } + + fun dismissSuggestionRetry() { + suggestionDraft.value = null + } + + /** The suggestion being sent now; a repeat of the same title reuses its id. */ + private var inFlightSuggestion: AddSuggestionRequest? = null + + private fun sendSuggestion(request: AddSuggestionRequest, item: WatchPartyItem) { + inFlightSuggestion = request + // Only this request's own outcome may clear or replace the retry draft. + fun draftIsMineOrEmpty() = suggestionDraft.value?.first?.suggestionId.let { it == null || it == request.suggestionId } + viewModelScope.launch { + val result = repository.addSuggestion(request) + if (inFlightSuggestion == request) inFlightSuggestion = null + when (result) { + is ApiResult.Success -> if (draftIsMineOrEmpty()) suggestionDraft.value = null + is ApiResult.NetworkError -> { + suggestionDraft.value = request to item + _messages.tryEmit("Couldn't confirm your suggestion. Try again.") + } + is ApiResult.Error -> { + if (draftIsMineOrEmpty()) suggestionDraft.value = (request to item).takeIf { result.error == "invalid_response" } + _messages.tryEmit(watchPartyErrorMessage(result, "Couldn't suggest ${item.title}.")) + } + } + } + } + + fun vote(suggestion: Suggestion) = act("Couldn't vote.") { + if (suggestion.votedByMe) repository.unvote(suggestion.id) else repository.vote(suggestion.id) + } + + fun remove(suggestion: Suggestion) { + if (!canRemoveSuggestion(repository.roomSnapshot.value, suggestion)) return + act("Couldn't remove the suggestion.") { repository.deleteSuggestion(suggestion.id) } + } + + /** Vote rooms: start a suggestion now (host). */ + fun promote(suggestion: Suggestion) = act("Couldn't start ${suggestion.title}.") { + repository.promoteSuggestion(suggestion.id) + } + + /** + * Host-pick rooms: stage a suggestion as the next pick ("Up next"). This + * uses the stage operation, never promote, so nothing starts playing. + */ + fun queue(suggestion: Suggestion) = stage( + WatchPartyItem( + contentId = suggestion.contentId, + contentType = suggestion.contentType, + title = suggestion.title, + subtitle = suggestion.subtitle, + posterUrl = suggestion.posterUrl, + ), + ) + + /** Who in the room has watched [contentId]: one bounded read for the selected candidate. */ + suspend fun memberState(contentId: String): ItemMemberState? { + val features = (availability.availability.value as? WatchPartyAvailability.Available)?.features + if (features?.memberState != true) return null + return (repository.memberState(listOf(contentId)) as? ApiResult.Success)?.data?.items?.firstOrNull() + } + + fun endForEveryone() = roomSession.depart(closeRoom = true) + + fun leave() = roomSession.depart(closeRoom = false) + + private fun act(fallback: String, operation: suspend () -> ApiResult): Job = + viewModelScope.launch { + val result = operation() + if (result !is ApiResult.Success) _messages.tryEmit(watchPartyErrorMessage(result, fallback)) + } +} + +private fun WatchTogetherConnectionState.status(): WatchPartyConnectionStatus = + if (writable) { + WatchPartyConnectionStatus.Connected + } else { + WatchPartyConnectionStatus.Reconnecting(disconnectedAtMs ?: 0L) + } + +/** + * The room picker: the server's shared rows (Continue Together, members' + * watchlists) and catalog search. Search is debounced and a newer query + * discards an older response. Rows only list what this profile may browse; + * they do not prove every member can play an item. + */ +class WatchPartyPickerViewModel( + private val repository: WatchTogetherRepository, + private val availability: WatchPartyAvailabilityRepository, + private val search: suspend (String) -> ApiResult>, +) : ViewModel() { + + data class PickerState( + val rows: PickerResponse? = null, + val rowsLoading: Boolean = false, + val query: String = "", + val results: List = emptyList(), + val searching: Boolean = false, + val error: String? = null, + ) + + private val _state = MutableStateFlow(PickerState()) + val state: StateFlow = _state.asStateFlow() + private val queries = MutableStateFlow("") + private var generation = 0L + + init { + viewModelScope.launch { + queries.collectLatest { query -> + val ticket = generation + if (query.length >= MIN_QUERY) { + delay(SEARCH_DEBOUNCE_MS) + runSearch(query, ticket) + } + } + } + } + + /** Load the shared rows when the server offers them; otherwise search alone. */ + fun loadRows() { + viewModelScope.launch { + val features = (availability.refresh() as? WatchPartyAvailability.Available)?.features + if (features?.picker != true) { + _state.update { it.copy(rows = null, rowsLoading = false) } + return@launch + } + _state.update { it.copy(rowsLoading = true) } + when (val result = repository.picker()) { + // The picker reads profile catalog access, which can include + // ebooks and other non-video items; a party plays video only, + // and TV never shows reading items. + is ApiResult.Success -> _state.update { + it.copy(rows = result.data.playableOnly(), rowsLoading = false, error = null) + } + else -> _state.update { + it.copy(rowsLoading = false, error = watchPartyErrorMessage(result, "Couldn't load suggestions.")) + } + } + } + } + + fun onQuery(query: String) { + val normalized = query.trim() + _state.update { it.copy(query = query) } + if (normalized == queries.value) return + // Invalidate before the debounce so an old completion cannot replace + // the results or loading state for the text already on screen. + generation++ + _state.update { + it.copy(results = emptyList(), searching = normalized.length >= MIN_QUERY, error = null) + } + queries.value = normalized + } + + private suspend fun runSearch(query: String, ticket: Long) { + if (ticket != generation) return + val result = search(query) + if (ticket != generation) return + when (result) { + is ApiResult.Success -> _state.update { + it.copy(results = result.data.filter { item -> item.type in PLAYABLE_TYPES }, searching = false, error = null) + } + else -> _state.update { it.copy(searching = false, error = watchPartyErrorMessage(result, "Search failed.")) } + } + } + + private fun PickerResponse.playableOnly(): PickerResponse = copy( + continueTogether = continueTogether.filter { it.item.type in PLAYABLE_TYPES }, + watchlistUnion = watchlistUnion.filter { it.item.type in PLAYABLE_TYPES }, + ) + + private companion object { + const val SEARCH_DEBOUNCE_MS = 300L + const val MIN_QUERY = 2 + val PLAYABLE_TYPES = setOf("movie", "series", "episode") + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RecentWatchParty.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RecentWatchParty.kt new file mode 100644 index 0000000000..f8aade9d2e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RecentWatchParty.kt @@ -0,0 +1,180 @@ +package org.siloserver.silo.watchtogether + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionKind +import org.siloserver.silo.network.IdentityTransitionPhase +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.repository.WatchPartyEndReason +import org.siloserver.silo.repository.WatchPartyEnded +import org.siloserver.silo.util.wallClockMillis + +/** + * The durable identity a recent party belongs to: the verified server, the + * persisted login, and the profile. Re-unlocking a PIN profile keeps the same + * owner. + */ +data class RecentPartyOwner( + val serverId: String, + val loginId: String, + val profileId: String, +) { + override fun toString(): String = "RecentPartyOwner()" +} + +/** + * The one party this identity may explicitly rejoin. Only the room code and + * display metadata are kept; rejoining exchanges the code for fresh proof, so + * no room token or join token is ever persisted. + */ +@Serializable +data class RecentWatchParty( + @SerialName("room_id") val roomId: String, + val code: String, + val title: String? = null, + @SerialName("was_host") val wasHost: Boolean = false, + @SerialName("saved_at_ms") val savedAtEpochMs: Long, +) + +@Serializable +private data class StoredRecentParty( + @SerialName("server_id") val serverId: String, + @SerialName("login_id") val loginId: String, + @SerialName("profile_id") val profileId: String, + val party: RecentWatchParty, +) + +/** Platform storage for one encrypted value. */ +interface RecentWatchPartyStorage { + fun read(): String? + fun write(value: String?) +} + +/** + * One recent party per device, shown only to the identity that saved it and + * only for [lifetimeMs]. Expired or foreign entries are never returned. + */ +class RecentWatchParties( + private val storage: RecentWatchPartyStorage, + private val owner: suspend () -> RecentPartyOwner?, + private val nowEpochMs: () -> Long = ::wallClockMillis, + private val lifetimeMs: Long = 24 * 60 * 60_000L, +) { + private val mutex = Mutex() + + suspend fun current(): RecentWatchParty? = mutex.withLock { + val stored = readLocked() ?: return@withLock null + if (nowEpochMs() - stored.party.savedAtEpochMs !in 0..lifetimeMs) { + storage.write(null) + return@withLock null + } + val current = owner() ?: return@withLock null + stored.party.takeIf { stored.matches(current) } + } + + suspend fun remember(roomId: String, code: String, title: String? = null, wasHost: Boolean) { + if (roomId.isBlank() || code.isBlank()) return + val current = owner() ?: return + mutex.withLock { + val previous = readLocked()?.takeIf { it.matches(current) && it.party.roomId == roomId } + val party = RecentWatchParty( + roomId = roomId, + code = code, + title = title ?: previous?.party?.title, + wasHost = wasHost, + savedAtEpochMs = nowEpochMs(), + ) + val stored = StoredRecentParty(current.serverId, current.loginId, current.profileId, party) + storage.write(SiloJson.encodeToString(StoredRecentParty.serializer(), stored)) + } + } + + /** Forget the entry, or only [roomId]'s entry when given. */ + suspend fun forget(roomId: String? = null) = mutex.withLock { + val stored = readLocked() ?: return@withLock + if (roomId == null || stored.party.roomId == roomId) storage.write(null) + } + + private fun readLocked(): StoredRecentParty? { + val raw = storage.read() ?: return null + return try { + SiloJson.decodeFromString(StoredRecentParty.serializer(), raw) + } catch (_: Exception) { + storage.write(null) + null + } + } + + private fun StoredRecentParty.matches(owner: RecentPartyOwner) = + serverId == owner.serverId && loginId == owner.loginId && profileId == owner.profileId +} + +/** + * Keeps the recent party in step with the engagement: remembered while a + * membership is live, kept after a replacement, a lost connection, or Leave so + * the user can rejoin, and forgotten when the room itself ended, including + * when this device ended it ([closedByThisDevice]). Sign-out, account + * replacement, and server removal clear it before the identity changes. + */ +class WatchPartyRecentsRecorder( + private val recents: RecentWatchParties, + room: StateFlow, + ended: StateFlow, + scope: CoroutineScope, + identityTransitions: IdentityTransitionBarrier, + closedByThisDevice: Flow = emptyFlow(), +) { + init { + identityTransitions.installGate { transition -> + if (transition.phase == IdentityTransitionPhase.WILL_CHANGE && transition.kind in CLEARING_KINDS) { + recents.forget() + } + } + scope.launch { + closedByThisDevice.collect { roomId -> recents.forget(roomId) } + } + scope.launch { + // Snapshots arrive often during playback; persist only identity changes. + val membership = room + .map { snapshot -> + snapshot?.takeIf { it.code.isNotBlank() } + ?.let { Membership(it.roomId, it.code, it.selfRole == MemberRole.Host) } + } + .distinctUntilChanged() + // The repository publishes an end while the last snapshot is still + // live, then clears both, so an end must win over the membership + // it ends; otherwise the room is remembered again as it closes. + combine(membership, ended) { live, end -> live to end }.collect { (live, end) -> + when { + end != null && end.reason in ROOM_GONE -> recents.forget(end.roomId) + live != null && end?.roomId != live.roomId -> + recents.remember(live.roomId, live.code, wasHost = live.isHost) + } + } + } + } + + private data class Membership(val roomId: String, val code: String, val isHost: Boolean) + + private companion object { + val CLEARING_KINDS = setOf( + IdentityTransitionKind.SIGN_OUT, + IdentityTransitionKind.ACCOUNT_REPLACE, + IdentityTransitionKind.SERVER_REMOVE, + ) + val ROOM_GONE = setOf(WatchPartyEndReason.HostLeft, WatchPartyEndReason.NotFound, WatchPartyEndReason.Ended) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomCatchup.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomCatchup.kt new file mode 100644 index 0000000000..f40a9acf9a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomCatchup.kt @@ -0,0 +1,157 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.TransportAction +import kotlin.math.abs + +/** + * How a Watch Party client applies a room command's position. Ported from the + * web reference client (`web/src/player/utils/roomSyncCatchup.ts`). + * + * Small drift is absorbed with a temporary playback-rate nudge instead of a + * seek, because on copy-remux deliveries a seek outside the downloaded window + * rebuilds the whole stream. Every position the player can reach without new + * media stays a seek. The rate is a correction, never a user speed: it is not + * saved or shown, and it returns to exactly 1x. + */ +object RoomCatchup { + /** Drift within this band converges by rate instead of rebuilding the stream. */ + const val BAND_SECONDS = 2.0 + + /** Playback already this close to the room needs no correction. */ + const val DEADBAND_SECONDS = 0.35 + const val MIN_RATE = 0.9 + const val MAX_RATE = 1.25 + + /** A 1 s deficit plays at 1.125x and converges in about 8 s; the band edge reaches the cap. */ + private const val DIVISOR_SECONDS = 8.0 + + /** Rate for [driftSeconds] (room minus local), clamped to the correction bounds. */ + fun rateFor(driftSeconds: Double): Double = + (1.0 + driftSeconds / DIVISOR_SECONDS).coerceIn(MIN_RATE, MAX_RATE) + + sealed interface Decision { + data object Seek : Decision + data class Rate(val rate: Double) : Decision + data object None : Decision + } + + /** + * Decide how to reach [targetSeconds] from [localSeconds] for a command of + * [action]. [targetReachableLocally] is true when the player can take the + * target without loading new media. + */ + fun decide( + action: TransportAction, + targetSeconds: Double, + localSeconds: Double, + targetReachableLocally: Boolean, + ): Decision { + if (action == TransportAction.Seek) return Decision.Seek + val drift = targetSeconds - localSeconds + if (abs(drift) <= DEADBAND_SECONDS) return Decision.None + if (targetReachableLocally) return Decision.Seek + // Pausing is cheap and the next Play realigns; never rebuild a stream + // just to park a paused member at the anchor. + if (action == TransportAction.Pause) return Decision.None + if (abs(drift) > BAND_SECONDS) return Decision.Seek + return Decision.Rate(rateFor(drift)) + } + + /** The room position at [nowMs] for a target that starts advancing at 1x from [executeAtMs]. */ + fun expectedPosition(targetSeconds: Double, executeAtMs: Long, nowMs: Long): Double = + (targetSeconds + (nowMs - executeAtMs).coerceAtLeast(0L) / 1000.0).coerceAtLeast(0.0) + + fun converged(targetSeconds: Double, executeAtMs: Long, localSeconds: Double, nowMs: Long): Boolean = + abs(expectedPosition(targetSeconds, executeAtMs, nowMs) - localSeconds) <= DEADBAND_SECONDS +} + +/** + * Correction-driven media reloads. A Play correction whose target is not + * buffered loads new media, so the viewer lands late by the load time. Without + * a budget a viewer on a slow link chases the advancing room forever. Only one + * reload runs at a time; the next waits 10 s after the previous one started + * playing, doubling to 60 s until the viewer converges, and each aims ahead by + * the previous load time, at most 10 s and never past the end of the media. + * Explicit room seeks never go through this budget. + */ +class RoomReloadBudget( + private val minIntervalMs: Long = 10_000L, + private val maxIntervalMs: Long = 60_000L, + private val staleMs: Long = 30_000L, + private val maxLeadSeconds: Double = 10.0, +) { + var targetSeconds: Double? = null + private set + private var loadStarted = false + private var startedAtMs = 0L + private var nextAllowedAtMs = 0L + private var attempts = 0 + private var leadSeconds = 0.0 + + val inFlight: Boolean get() = targetSeconds != null + + fun allowed(nowMs: Long): Boolean = + if (targetSeconds != null) nowMs - startedAtMs >= staleMs else nowMs >= nextAllowedAtMs + + /** Record a reload toward [roomSeconds]; returns where to aim it. */ + fun begin(roomSeconds: Double, nowMs: Long, durationSeconds: Double? = null): Double { + var target = roomSeconds + leadSeconds + if (durationSeconds != null && durationSeconds > 0.0) { + target = minOf(target, maxOf(roomSeconds, durationSeconds)) + } + targetSeconds = target + loadStarted = false + startedAtMs = nowMs + attempts++ + nextAllowedAtMs = nowMs + staleMs + return target + } + + /** The reload's own seek or re-anchor was taken. */ + fun noteLoading() { + if (targetSeconds != null) loadStarted = true + } + + /** Whether playback at [localSeconds] is the in-flight reload playing. */ + fun landed(localSeconds: Double): Boolean { + val target = targetSeconds ?: return false + if (!loadStarted) return false + val offset = localSeconds - target + return offset >= -RoomCatchup.DEADBAND_SECONDS && offset <= RoomCatchup.BAND_SECONDS + } + + /** The reload is playing: remember its load time and space the next one. */ + fun land(nowMs: Long) { + if (targetSeconds == null) return + leadSeconds = ((nowMs - startedAtMs) / 1000.0).coerceIn(0.0, maxLeadSeconds) + targetSeconds = null + nextAllowedAtMs = nowMs + backoffMs() + } + + /** The reload was refused or superseded; space the next one. */ + fun abandon(nowMs: Long) { + targetSeconds = null + nextAllowedAtMs = nowMs + backoffMs() + } + + /** The viewer reached the room: the next drift starts a fresh backoff. */ + fun settle() { + attempts = 0 + nextAllowedAtMs = 0L + } + + /** A quality change starts pacing over for the new stream. */ + fun reset() { + targetSeconds = null + loadStarted = false + attempts = 0 + nextAllowedAtMs = 0L + leadSeconds = 0.0 + } + + private fun backoffMs(): Long { + var interval = minIntervalMs + repeat((attempts - 1).coerceAtLeast(0)) { interval = minOf(maxIntervalMs, interval * 2) } + return interval + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomClock.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomClock.kt new file mode 100644 index 0000000000..e57b6254a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomClock.kt @@ -0,0 +1,93 @@ +package org.siloserver.silo.watchtogether + +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * The room's estimate of server time. [offsetMs] is server wall clock minus + * local wall clock, or null before a usable sample. [generation] changes + * whenever the estimate is reset, so scheduled work computed from an older + * estimate can tell it is stale. + */ +data class RoomClockEstimate( + val offsetMs: Long?, + val rttMs: Long?, + val generation: Long, +) + +/** + * NTP-style offset estimation from room ping/pong samples. + * + * The estimate is the offset of the lowest round-trip sample among the most + * recent [maxSamples] within [sampleLifetimeMs], because queueing delay only + * ever lengthens a round trip. Samples with a negative or implausibly long + * round trip are rejected. A jump in the local wall clock relative to the + * monotonic clock invalidates every sample, since offsets measured before the + * jump no longer describe this device. + */ +class RoomClockEstimator( + private val maxSamples: Int = 8, + private val maxRttMs: Long = 5_000L, + private val sampleLifetimeMs: Long = 10 * 60_000L, + private val discontinuityToleranceMs: Long = 1_000L, +) { + private data class Sample(val offsetMs: Long, val rttMs: Long, val monotonicMs: Long) + + private val samples = ArrayDeque() + private var wallMinusMonotonicMs: Long? = null + private var generation = 0L + + var estimate: RoomClockEstimate = RoomClockEstimate(offsetMs = null, rttMs = null, generation = 0L) + private set + + /** + * Record one sample. [clientReceivedMs] must be stamped when the pong left + * the socket, and [monotonicNowMs] read at the same moment. Returns true + * when the sample was accepted. + */ + fun record( + clientSentMs: Long, + serverReceivedMs: Long, + serverSentMs: Long, + clientReceivedMs: Long, + monotonicNowMs: Long, + ): Boolean { + checkContinuity(clientReceivedMs, monotonicNowMs) + val serverHoldMs = serverSentMs - serverReceivedMs + val rttMs = (clientReceivedMs - clientSentMs) - serverHoldMs + if (serverHoldMs < 0 || rttMs < 0 || rttMs > maxRttMs) return false + val offsetMs = (((serverReceivedMs - clientSentMs) + (serverSentMs - clientReceivedMs)) / 2.0).roundToLong() + samples.addLast(Sample(offsetMs, rttMs, monotonicNowMs)) + while (samples.size > maxSamples) samples.removeFirst() + samples.removeAll { monotonicNowMs - it.monotonicMs > sampleLifetimeMs } + publish() + return true + } + + /** + * Detect a local wall-clock jump. Returns true (and resets the estimate) + * when the wall clock moved relative to the monotonic clock by more than + * the tolerance since the last check. + */ + fun checkContinuity(wallNowMs: Long, monotonicNowMs: Long): Boolean { + val skew = wallNowMs - monotonicNowMs + val previous = wallMinusMonotonicMs + wallMinusMonotonicMs = skew + if (previous == null || abs(skew - previous) <= discontinuityToleranceMs) return false + reset() + wallMinusMonotonicMs = skew + return true + } + + fun reset() { + samples.clear() + wallMinusMonotonicMs = null + generation++ + publish() + } + + private fun publish() { + val best = samples.minByOrNull { it.rttMs } + estimate = RoomClockEstimate(offsetMs = best?.offsetMs, rttMs = best?.rttMs, generation = generation) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBinding.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBinding.kt new file mode 100644 index 0000000000..45b5431e8a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBinding.kt @@ -0,0 +1,722 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.TransportAction +import org.siloserver.silo.model.watchtogether.TransportCommand +import org.siloserver.silo.repository.RoomTransportAuthorization +import org.siloserver.silo.repository.ScheduledTransportCommand +import org.siloserver.silo.repository.WatchTogetherConnectionState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.math.abs + +/** The room side of the playback binding. [org.siloserver.silo.repository.WatchTogetherRepository] implements it. */ +interface RoomPlaybackRoom { + val roomSnapshot: StateFlow + val connectionState: StateFlow + val roomDeliveryEcho: StateFlow + val latestTransportCommand: StateFlow + val clock: StateFlow + suspend fun attachSession(sessionId: String): Boolean + suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String? = null, + isReady: Boolean = false, + ): Boolean + suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String? = null): Boolean + suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean + suspend fun checkClockContinuity(): RoomClockEstimate + fun currentTransportAuthorization(): RoomTransportAuthorization? + suspend fun transportRequestForAuthorization( + authorization: RoomTransportAuthorization, + intent: RoomTransportIntent, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ): Boolean +} + +enum class RoomPlayerState { Idle, Buffering, Ready, Ended } + +/** + * What the player is actually doing. [sourcePositionSeconds] is the engine + * position mapped to the source timeline, never an optimistic seek target. + */ +data class RoomPlayerObservation( + /** The committed playback session, or null while none is committed. */ + val sessionId: String? = null, + /** The room selection revision this player was prepared for. */ + val selectionRevision: Long = -1L, + val sourcePositionSeconds: Double = 0.0, + val durationSeconds: Double = 0.0, + val playWhenReady: Boolean = false, + val isPlaying: Boolean = false, + val state: RoomPlayerState = RoomPlayerState.Idle, + /** A seek, replan, or media mount is in flight; the position is not a decision. */ + val seekPending: Boolean = false, + /** + * Playback is held for a local reason (audio focus, background, sleep + * timer, subtitle preparation). Nothing is reported while it lasts. + */ + val suspended: Boolean = false, +) + +/** The player side of the binding, implemented by each platform's player. */ +interface RoomPlayerPort { + val observations: StateFlow + + /** Apply a room seek. Maps or re-anchors as the current stream requires. */ + fun seekTo(sourceSeconds: Double) + + /** Apply the room's play/pause state. Never echoed back as user intent. */ + fun setPlaying(playing: Boolean) + + /** A temporary correction rate, or null for exactly 1x. Never saved or shown. */ + fun setCorrectionRate(rate: Double?) + + /** Whether [sourceSeconds] is already buffered, so reaching it loads no new media. */ + fun isBuffered(sourceSeconds: Double): Boolean +} + +/** Client timing choices from the web reference client. */ +data class RoomPlaybackTiming( + val reportIntervalMs: Long = 1_500L, + val readyRetryMs: Long = 500L, + val bufferingGraceMs: Long = 2_000L, + val tickMs: Long = 250L, + val clockWaitMs: Long = 3_000L, + val commandQuietMs: Long = 250L, + val attachEchoTimeoutMs: Long = 2_000L, + val mountDeferMs: Long = 5_000L, + val maxLeadMs: Long = 5_000L, + val guestSeekToleranceSeconds: Double = 1.0, + val hostSeekToleranceSeconds: Double = 15.0, + val stallWindowMs: Long = 5 * 60_000L, +) + +/** Something the viewer should be told. */ +sealed interface RoomPlaybackNotice { + /** Only the host can seek; guests may play and pause only when the host allows it. */ + data class Denied(val intent: RoomTransportIntent) : RoomPlaybackNotice + /** The room socket is reconnecting; the action was not sent. */ + data object Reconnecting : RoomPlaybackNotice + /** Playback is paused until a usable server clock sample arrives. */ + data object ClockUnavailable : RoomPlaybackNotice + /** The request could not be delivered. */ + data object Undelivered : RoomPlaybackNotice +} + +/** What the binding is doing, for debug tooling and harness runs. Never contains credentials. */ +data class RoomPlaybackDebug( + val attachedSessionId: String? = null, + val serverAttached: Boolean = false, + val pendingCommandId: String? = null, + val appliedCommandId: String? = null, + val readinessCommandId: String? = null, + val catchingUp: Boolean = false, + val correctionRate: Double? = null, + val reloadInFlight: Boolean = false, + val stallReported: Boolean = false, + val suspended: Boolean = false, +) + +/** Outcome of a user transport intent. */ +enum class RoomTransportResult { Sent, Denied, Reconnecting, NotInRoom, Ignored } + +/** + * Binds one room engagement to one player for the current playback epoch. + * + * It attaches the committed playback session on every socket epoch and waits + * for the server's echo before reporting; reports what the player is actually + * doing every 1.5 s and stays quiet while stalled, seeking, or suspended; + * schedules accepted commands on the server clock and advances a late Play; + * acknowledges readiness for the exact waiting command until a fresh snapshot + * confirms it; reports buffering after a 2 s stall; acknowledges recovery + * while catching up; and converges small drift by a temporary rate with media + * reloads paced by [RoomReloadBudget]. + * + * Everything runs on one event loop, so no state is shared between coroutines. + */ +class RoomPlaybackBinding( + private val room: RoomPlaybackRoom, + private val player: RoomPlayerPort, + parentScope: CoroutineScope, + private val monotonicNowMs: () -> Long, + private val wallNowMs: () -> Long, + private val timing: RoomPlaybackTiming = RoomPlaybackTiming(), +) { + private sealed interface Event { + data object Tick : Event + data object Changed : Event + data class Command(val scheduled: ScheduledTransportCommand) : Event + data class Execute(val token: Long) : Event + data object QualityChanged : Event + } + + private val job = SupervisorJob(parentScope.coroutineContext[Job]) + private val scope = CoroutineScope(parentScope.coroutineContext + job) + private val events = Channel(Channel.UNLIMITED) + + private val _notices = MutableSharedFlow( + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val notices: SharedFlow = _notices.asSharedFlow() + + private val _catchingUp = MutableStateFlow(false) + + /** This viewer is catching up on its own while the room keeps going. */ + val catchingUp: StateFlow = _catchingUp.asStateFlow() + + private val _debug = MutableStateFlow(RoomPlaybackDebug()) + + /** The binding's state for debug tooling. */ + val debug: StateFlow = _debug.asStateFlow() + + private val _offerLowerQuality = MutableStateFlow(false) + + /** Two sustained stalls within five minutes: offer one step down the same file's ladder. */ + val offerLowerQuality: StateFlow = _offerLowerQuality.asStateFlow() + + // ---- loop state (touched only by the event loop) ---- + private var attachedKey: RoomDeliveryKey? = null + private var lastAttachAttemptMs = NEVER_MS + private var lastReportMs = NEVER_MS + private var lastSeenCommandId: String? = null + private var lastCommandConnection: Pair? = null + private var pending: PendingCommand? = null + private var executionJob: Job? = null + private var nextToken = 0L + private var applied: TransportCommand? = null + private var appliedAtMs = 0L + private var readiness: Readiness? = null + private var lastCatchUpAckMs = NEVER_MS + private var stallStartedAtMs: Long? = null + private var stallReported = false + private val sustainedStalls = ArrayDeque() + private var convergence: Convergence? = null + + // A Play correction that needs new media while the reload budget is + // spacing reloads. It runs once the budget allows, unless the viewer + // reaches the room first or a newer command replaces it. + private var deferredReload: Convergence? = null + private var rateApplied: Double? = null + private val reloads = RoomReloadBudget() + private var wasSuspended = false + private var lastRevision = NEVER_MS + private var lastContinuityCheckMs = NEVER_MS + + private data class PendingCommand( + val token: Long, + val scheduled: ScheduledTransportCommand, + val executeAtServerMs: Long?, + ) { + /** When execution was first held for a media mount in flight. */ + var deferredSinceMs: Long? = null + } + + private data class Readiness( + val commandId: String?, + val seekTarget: Double?, + val armedWith: RoomSnapshot?, + var lastSentMs: Long = NEVER_MS, + ) + + private data class Convergence(val targetSeconds: Double, val executeAtMonotonicMs: Long) + + fun start() { + scope.launch { loop() } + scope.launch { room.roomSnapshot.collect { events.send(Event.Changed) } } + scope.launch { room.connectionState.collect { events.send(Event.Changed) } } + scope.launch { room.roomDeliveryEcho.collect { events.send(Event.Changed) } } + scope.launch { room.clock.collect { events.send(Event.Changed) } } + scope.launch { player.observations.collect { events.send(Event.Changed) } } + scope.launch { room.latestTransportCommand.collect { it?.let { command -> events.send(Event.Command(command)) } } } + scope.launch { + while (true) { + delay(timing.tickMs) + events.send(Event.Tick) + } + } + } + + /** Stop this binding. The room engagement continues; the correction rate returns to 1x. */ + fun dispose() { + job.cancel() + if (rateApplied != null) player.setCorrectionRate(null) + rateApplied = null + } + + /** + * Acknowledge that the viewer changed quality: the offer and reload pacing + * start over. The offer hides at once; the loop clears its own state. + */ + fun onQualityChanged() { + _offerLowerQuality.value = false + events.trySend(Event.QualityChanged) + } + + // ---- user intents --------------------------------------------------------- + + /** Ask the room to play or pause. The player changes only when the room's command arrives. */ + fun requestPlayPause(pause: Boolean): RoomTransportResult { + if (!pause && player.observations.value.state == RoomPlayerState.Ended) return RoomTransportResult.Ignored + return request(RoomTransportIntent.PlayPause, if (pause) TransportAction.Pause else TransportAction.Play, null, pause) + } + + /** Ask the room to seek (host only). */ + fun requestSeek(sourceSeconds: Double): RoomTransportResult { + if (!sourceSeconds.isFinite() || sourceSeconds < 0.0) return RoomTransportResult.Ignored + val paused = room.roomSnapshot.value?.isPaused ?: true + return request(RoomTransportIntent.Seek, TransportAction.Seek, sourceSeconds, paused) + } + + private fun request( + intent: RoomTransportIntent, + action: TransportAction, + positionSeconds: Double?, + isPaused: Boolean, + ): RoomTransportResult { + val snapshot = room.roomSnapshot.value ?: return RoomTransportResult.NotInRoom + if (snapshot.phase != RoomPhase.Playing) return RoomTransportResult.NotInRoom + if (!roomTransportAuthorized(snapshot, intent)) { + _notices.tryEmit(RoomPlaybackNotice.Denied(intent)) + return RoomTransportResult.Denied + } + val authorization = room.currentTransportAuthorization() + if (authorization == null || !room.connectionState.value.writable) { + _notices.tryEmit(RoomPlaybackNotice.Reconnecting) + return RoomTransportResult.Reconnecting + } + val position = positionSeconds ?: player.observations.value.sourcePositionSeconds + scope.launch { + val delivered = room.transportRequestForAuthorization(authorization, intent, action.wire, position, isPaused) + if (!delivered) _notices.tryEmit(RoomPlaybackNotice.Undelivered) + } + return RoomTransportResult.Sent + } + + // ---- event loop ----------------------------------------------------------- + + private suspend fun loop() { + for (event in events) { + when (event) { + is Event.Command -> accept(event.scheduled) + is Event.Execute -> execute(event.token) + Event.QualityChanged -> { + sustainedStalls.clear() + reloads.reset() + _offerLowerQuality.value = false + } + Event.Tick, Event.Changed -> Unit + } + step() + publishDebug() + } + } + + private fun publishDebug() { + val obs = player.observations.value + val echo = room.roomDeliveryEcho.value + _debug.value = RoomPlaybackDebug( + attachedSessionId = attachedKey?.playbackSessionId, + serverAttached = attachedKey != null && echo?.playbackSessionId == attachedKey?.playbackSessionId && + echo?.connectionEpoch == attachedKey?.connectionEpoch, + pendingCommandId = pending?.scheduled?.command?.commandId, + appliedCommandId = applied?.commandId, + readinessCommandId = readiness?.commandId, + catchingUp = _catchingUp.value, + correctionRate = rateApplied, + reloadInFlight = reloads.inFlight, + stallReported = stallReported, + suspended = obs.suspended, + ) + } + + private suspend fun step() { + val now = monotonicNowMs() + val snapshot = room.roomSnapshot.value + val connection = room.connectionState.value + val obs = player.observations.value + if (snapshot == null || snapshot.phase != RoomPhase.Playing) { + resetEpoch() + return + } + if (snapshot.selectionRevision != lastRevision) { + lastRevision = snapshot.selectionRevision + resetEpoch() + // A new title is a new stream: the old one's reload backoff and + // stall history say nothing about it. + reloads.reset() + sustainedStalls.clear() + _offerLowerQuality.value = false + } + if (now - lastContinuityCheckMs >= 1_000L) { + lastContinuityCheckMs = now + room.checkClockContinuity() + } + + val sessionId = obs.sessionId?.takeIf { obs.selectionRevision == snapshot.selectionRevision } + val key = sessionId?.takeIf { connection.writable }?.let { + RoomDeliveryKey(connection.generation, connection.epoch, it) + } + + // A local suspension ends: resynchronize before reporting again. While + // the room plays, re-attaching makes the server send a command at the + // room position; a paused room sends nothing, so align locally. + if (wasSuspended && !obs.suspended) { + attachedKey = null + if (snapshot.isPaused) alignPausedToAnchor(snapshot, obs) + } + if (obs.suspended && !wasSuspended) stopCorrections(now) + wasSuspended = obs.suspended + + val echo = room.roomDeliveryEcho.value + val echoed = key != null && echo != null && + echo.connectionGeneration == key.connectionGeneration && + echo.connectionEpoch == key.connectionEpoch && + echo.playbackSessionId == key.playbackSessionId + // Attach on every socket epoch and session change; resend if the + // server never echoes it. + val attachDue = attachedKey != key || + (!echoed && now - lastAttachAttemptMs >= timing.attachEchoTimeoutMs) + if (key != null && attachDue && now - lastAttachAttemptMs >= timing.readyRetryMs) { + lastAttachAttemptMs = now + if (room.attachSession(key.playbackSessionId)) attachedKey = key + } + val serverAttached = key != null && attachedKey == key && echoed + if (!serverAttached || sessionId == null) { + // No room to converge toward while detached or reconnecting; a + // correction rate left running would carry this viewer past it. + clearRate() + return + } + + val playable = !obs.seekPending && (obs.state == RoomPlayerState.Ready || obs.state == RoomPlayerState.Ended) + val executedLatest = pending == null + val stalled = obs.state == RoomPlayerState.Buffering && obs.playWhenReady && !obs.seekPending + val positionOk = { readiness: Readiness -> + val target = readiness.seekTarget + val tolerance = if (snapshot.selfRole == MemberRole.Host) { + timing.hostSeekToleranceSeconds + } else { + timing.guestSeekToleranceSeconds + } + target == null || obs.state == RoomPlayerState.Ended || abs(obs.sourcePositionSeconds - target) <= tolerance + } + val paused = obs.state == RoomPlayerState.Ended || !obs.playWhenReady + + // Readiness for the waiting command. + val waiting = readiness + if (waiting != null) { + val settled = snapshot.playbackState != RoomPlaybackState.Waiting || + (snapshot !== waiting.armedWith && snapshot.selfMember?.isReady == true) + if (settled) { + readiness = null + } else if (executedLatest && playable && !obs.suspended && positionOk(waiting) && + now - waiting.lastSentMs >= timing.readyRetryMs + ) { + waiting.lastSentMs = now + room.ready(sessionId, obs.sourcePositionSeconds, paused, waiting.commandId) + room.stateReport(sessionId, obs.sourcePositionSeconds, paused, waiting.commandId, isReady = true) + lastReportMs = now + } + } + + // Catching up after the room stopped waiting for this viewer. + val catchingUp = snapshot.selfCatchingUp + _catchingUp.value = catchingUp + if (catchingUp && readiness == null && snapshot.selfMember?.isReady != true && executedLatest && playable && + !obs.suspended && now - lastCatchUpAckMs >= timing.readyRetryMs + ) { + lastCatchUpAckMs = now + room.ready(sessionId, obs.sourcePositionSeconds, paused, lastSeenCommandId) + } + + // Buffering: stalls under the grace stay local; a longer one is reported once. + if (stalled && snapshot.playbackState == RoomPlaybackState.Playing && !obs.suspended) { + val started = stallStartedAtMs ?: now.also { stallStartedAtMs = it } + if (!stallReported && now - started >= timing.bufferingGraceMs) { + stallReported = true + room.buffering(sessionId, obs.sourcePositionSeconds, false) + recordSustainedStall(now) + } + } else if (!stalled) { + stallStartedAtMs = null + stallReported = false + } + + // Rate convergence and reload pacing. + converge(now, obs) + retryDeferredReload(now, obs) + + // Ordinary reports: only positions that are decisions. + val readinessPending = readiness != null || (catchingUp && snapshot.selfMember?.isReady != true) + val quiet = pending?.let { abs(expectedLocalExecuteMs(it) - now) <= timing.commandQuietMs } ?: false + if (!obs.suspended && !stalled && !obs.seekPending && !(readinessPending && !playable) && !quiet && + now - lastReportMs >= timing.reportIntervalMs + ) { + lastReportMs = now + room.stateReport(sessionId, obs.sourcePositionSeconds, paused, null, isReady = false) + } + } + + private fun resetEpoch() { + attachedKey = null + pending = null + executionJob?.cancel() + applied = null + readiness = null + deferredReload = null + stallStartedAtMs = null + stallReported = false + _catchingUp.value = false + clearRate() + } + + // ---- commands ------------------------------------------------------------- + + private fun accept(scheduled: ScheduledTransportCommand) { + val command = scheduled.command + val connection = room.connectionState.value + if (scheduled.connection.generation != connection.generation || scheduled.connection.epoch != connection.epoch) return + val commandConnection = connection.generation to connection.epoch + if (command.commandId.isBlank() || + (command.commandId == lastSeenCommandId && commandConnection == lastCommandConnection) + ) return + lastSeenCommandId = command.commandId + lastCommandConnection = commandConnection + stopCorrections(monotonicNowMs()) + val token = ++nextToken + val queued = PendingCommand(token, scheduled, scheduled.executeAtMs) + pending = queued + readiness = null + scheduleExecution(queued) + } + + private fun scheduleExecution(command: PendingCommand) { + executionJob?.cancel() + if (room.clock.value.offsetMs == null) player.setPlaying(false) + executionJob = scope.launch { + val waitStarted = monotonicNowMs() + var notified = false + // Socket pings keep trying. Retain the command until one supplies + // the server clock; a device clock is never a usable substitute. + while (room.clock.value.offsetMs == null) { + if (!notified && monotonicNowMs() - waitStarted >= timing.clockWaitMs) { + _notices.tryEmit(RoomPlaybackNotice.ClockUnavailable) + notified = true + } + delay(timing.tickMs) + } + val serverNow = serverNowMs() ?: return@launch + delay(((command.executeAtServerMs ?: serverNow) - serverNow).coerceIn(0L, timing.maxLeadMs)) + events.send(Event.Execute(command.token)) + } + } + + private fun execute(token: Long) { + val scheduled = pending?.takeIf { it.token == token } ?: return + // A command that lands while media is mounting or seeking would act + // on media that is being replaced; hold it briefly until it settles. + // A late Play still advances by the full elapsed server time. + if (player.observations.value.seekPending) { + val now = monotonicNowMs() + val since = scheduled.deferredSinceMs ?: now.also { scheduled.deferredSinceMs = it } + if (now - since < timing.mountDeferMs) { + scope.launch { + delay(timing.tickMs) + events.send(Event.Execute(token)) + } + return + } + } + val command = scheduled.scheduled.command + val snapshot = room.roomSnapshot.value ?: return + val connection = room.connectionState.value + val obs = player.observations.value + val sessionId = obs.sessionId + if ( + snapshot.phase != RoomPhase.Playing || + command.selectionRevision != snapshot.selectionRevision || + obs.selectionRevision != snapshot.selectionRevision || + (command.sessionId.isNotBlank() && command.sessionId != sessionId) || + scheduled.scheduled.connection.generation != connection.generation || + scheduled.scheduled.connection.epoch != connection.epoch + ) { + pending = null + return + } + val serverNow = serverNowMs() + if (serverNow == null) { + scheduleExecution(scheduled) + return + } + pending = null + val now = monotonicNowMs() + val advancing = command.action != TransportAction.Pause && command.playbackState == RoomPlaybackState.Playing + val lateMs = (serverNow - (scheduled.executeAtServerMs ?: serverNow)).coerceAtLeast(0L) + val target = command.positionSeconds + if (advancing) lateMs / 1000.0 else 0.0 + val playing = when (command.action) { + TransportAction.Play -> command.playbackState != RoomPlaybackState.Waiting + TransportAction.Pause -> false + else -> command.playbackState == RoomPlaybackState.Playing + } + when (val decision = RoomCatchup.decide( + action = command.action, + targetSeconds = target, + localSeconds = obs.sourcePositionSeconds, + targetReachableLocally = player.isBuffered(target), + )) { + RoomCatchup.Decision.Seek -> { + if (command.action == TransportAction.Seek || player.isBuffered(target)) { + player.seekTo(target) + } else if (reloads.allowed(now)) { + // A Play correction that must load new media is paced. + beginReload(target, now, obs) + } else { + // Held until the budget allows, so the viewer is not left + // playing at the wrong position with nothing to fix it. + deferredReload = Convergence(target, now) + } + } + is RoomCatchup.Decision.Rate -> { + // [target] already includes the late start, so it advances from now. + convergence = Convergence(target, now) + setRate(decision.rate) + } + RoomCatchup.Decision.None -> if (command.action == TransportAction.Play) reloads.settle() + } + // A local suspension holds playback: the viewer resumes on their own and + // the binding re-attaches, so the server resends the room position. + if (!playing || !obs.suspended) player.setPlaying(playing) + applied = command + appliedAtMs = now + if (command.playbackState == RoomPlaybackState.Waiting) { + readiness = Readiness( + commandId = command.commandId, + seekTarget = target.takeIf { command.action == TransportAction.Seek }, + armedWith = snapshot, + ) + } + } + + private fun converge(now: Long, obs: RoomPlayerObservation) { + if (reloads.inFlight && obs.isPlaying && reloads.landed(obs.sourcePositionSeconds)) reloads.land(now) + val target = convergence ?: return + if (obs.seekPending || !obs.isPlaying || obs.suspended) { + stopCorrections(now) + return + } + val expected = RoomCatchup.expectedPosition(target.targetSeconds, target.executeAtMonotonicMs, now) + val drift = expected - obs.sourcePositionSeconds + if (abs(drift) <= RoomCatchup.DEADBAND_SECONDS || abs(drift) > RoomCatchup.BAND_SECONDS) { + convergence = null + setRate(null) + if (abs(drift) <= RoomCatchup.DEADBAND_SECONDS) reloads.settle() + } else { + setRate(RoomCatchup.rateFor(drift)) + } + } + + /** + * Run a held Play correction once the reload budget allows, aimed at where + * the room is now. Drift that shrank in the meantime is settled the + * cheaper way: nothing, a buffered seek, or a rate. + */ + private fun retryDeferredReload(now: Long, obs: RoomPlayerObservation) { + val held = deferredReload ?: return + if (obs.seekPending || !obs.isPlaying || obs.suspended) return + val expected = RoomCatchup.expectedPosition(held.targetSeconds, held.executeAtMonotonicMs, now) + val drift = expected - obs.sourcePositionSeconds + when { + abs(drift) <= RoomCatchup.DEADBAND_SECONDS -> { + deferredReload = null + reloads.settle() + } + player.isBuffered(expected) -> { + deferredReload = null + player.seekTo(expected) + } + abs(drift) <= RoomCatchup.BAND_SECONDS -> { + deferredReload = null + convergence = held + setRate(RoomCatchup.rateFor(drift)) + } + reloads.allowed(now) -> { + deferredReload = null + beginReload(expected, now, obs) + } + } + } + + private fun beginReload(target: Double, now: Long, obs: RoomPlayerObservation) { + val aimed = reloads.begin(target, now, obs.durationSeconds.takeIf { it > 0.0 }) + player.seekTo(aimed) + reloads.noteLoading() + } + + private fun stopCorrections(now: Long) { + clearRate() + deferredReload = null + if (reloads.inFlight) reloads.abandon(now) + } + + private fun clearRate() { + if (convergence != null || rateApplied != null) { + convergence = null + setRate(null) + } + } + + private fun setRate(rate: Double?) { + val normalized = rate?.takeIf { abs(it - 1.0) > 0.001 } + if (normalized == rateApplied) return + rateApplied = normalized + player.setCorrectionRate(normalized) + } + + private fun alignPausedToAnchor(snapshot: RoomSnapshot, obs: RoomPlayerObservation) { + val anchor = snapshot.anchorPositionSeconds + if (abs(anchor - obs.sourcePositionSeconds) > RoomCatchup.DEADBAND_SECONDS && player.isBuffered(anchor)) { + player.seekTo(anchor) + } + player.setPlaying(false) + } + + private fun recordSustainedStall(now: Long) { + sustainedStalls.addLast(now) + while (sustainedStalls.isNotEmpty() && now - sustainedStalls.first() > timing.stallWindowMs) { + sustainedStalls.removeFirst() + } + if (sustainedStalls.size >= 2) _offerLowerQuality.value = true + } + + private fun serverNowMs(): Long? = room.clock.value.offsetMs?.let { wallNowMs() + it } + + private companion object { + /** A "last sent" time far enough in the past that any interval has elapsed, without overflow. */ + const val NEVER_MS = -1_000_000_000_000L + } + + private fun expectedLocalExecuteMs(pending: PendingCommand): Long { + val serverNow = serverNowMs() ?: return monotonicNowMs() + return monotonicNowMs() + ((pending.executeAtServerMs ?: serverNow) - serverNow) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomProof.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomProof.kt new file mode 100644 index 0000000000..d26ee3a149 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomProof.kt @@ -0,0 +1,37 @@ +package org.siloserver.silo.watchtogether + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull +import org.siloserver.silo.network.SiloJson +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * A room access token and the expiry the server signed into it. Renewing the + * proof never changes room membership. + */ +class RoomProof( + val token: String, + val expiresAtEpochMs: Long?, +) { + override fun toString(): String = "RoomProof(token=, expiresAtEpochMs=$expiresAtEpochMs)" +} + +/** + * Reads the signed `exp` claim (epoch seconds) from a room proof JWT without + * verifying it; the server verifies. Returns epoch milliseconds, or null when + * the token does not carry a readable expiry. + */ +@OptIn(ExperimentalEncodingApi::class) +fun roomProofExpiryMs(token: String): Long? { + val payload = token.split('.').takeIf { it.size == 3 }?.get(1)?.takeIf { it.isNotEmpty() } ?: return null + val padded = payload + "=".repeat((4 - payload.length % 4) % 4) + val claims = try { + SiloJson.parseToJsonElement(Base64.UrlSafe.decode(padded).decodeToString()) as? JsonObject + } catch (_: Exception) { + null + } ?: return null + val exp = (claims["exp"] as? JsonPrimitive)?.longOrNull ?: return null + return exp.takeIf { it > 0 }?.times(1_000L) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomSession.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomSession.kt index cfebb1fd4d..6061bb4578 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomSession.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomSession.kt @@ -21,21 +21,28 @@ interface RoomSessionRepository { val roomSnapshot: StateFlow val roomClosedReason: StateFlow + /** + * The local generation of the current membership, or null when there is no + * live membership. Joining the same room again produces a new generation. + */ + suspend fun membershipGeneration(): Long? suspend fun connect(roomId: String) suspend fun reset() suspend fun closeRoom(): ApiResult } -/** A Watch Together room, not a device-local episode queue, owns title changes. */ +/** A Watch Party room, not a device-local episode queue, owns title changes. */ fun shouldNavigateToLocalNext(inWatchTogetherRoom: Boolean): Boolean = !inWatchTogetherRoom /** - * The single application-scoped owner of a Watch Together connection. + * The single application-scoped owner of a Watch Party connection. * * Screen scopes adopt this session; they never own the socket. Replacement * and leave are serialized, and the previous job is joined before repository - * state can be reused by another room. + * state can be reused by another membership. Connections are keyed by + * membership generation, not room id, so an explicit rejoin of the same room + * replaces the obsolete connection instead of being mistaken for it. */ class RoomSession( private val repository: RoomSessionRepository, @@ -44,7 +51,7 @@ class RoomSession( ) { private val mutex = Mutex() private var connectionJob: Job? = null - private var connectedRoomId: String? = null + private var connectedGeneration: Long? = null val room: StateFlow = repository.roomSnapshot val closedReason: StateFlow = repository.roomClosedReason @@ -65,14 +72,14 @@ class RoomSession( scope.launch(start = CoroutineStart.UNDISPATCHED) { enter(roomId) } /** - * UI-facing durable departure. If requested, close is attempted while the - * current lease is still live; teardown then joins the socket and resets - * state even if the initiating screen disappears. + * UI-facing durable departure. If requested, End is attempted while the + * current membership is still live; teardown then joins the socket and + * resets state even if the initiating screen disappears. */ fun depart(closeRoom: Boolean = false): Job = scope.launch(start = CoroutineStart.UNDISPATCHED) { mutex.withLock { - if (closeRoom && connectedRoomId != null) { + if (closeRoom && connectedGeneration != null) { try { repository.closeRoom() } finally { @@ -87,11 +94,14 @@ class RoomSession( suspend fun enter(roomId: String) { if (roomId.isBlank()) return mutex.withLock { - if (connectedRoomId == roomId && connectionJob?.isActive == true) return + val generation = repository.membershipGeneration() ?: return + if (connectedGeneration == generation && connectionJob?.isActive == true) return + // Join the obsolete socket's teardown before the new membership + // can own shared state. connectionJob?.cancelAndJoin() - connectedRoomId = roomId + connectedGeneration = generation // Establish repository ownership before enter() returns. Without - // UNDISTPATCHED, a concurrent replacement can cancel a queued job + // UNDISPATCHED, a concurrent replacement can cancel a queued job // before it ever installs its connection owner. connectionJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { repository.connect(roomId) @@ -110,7 +120,7 @@ class RoomSession( private suspend fun leaveLocked() { connectionJob?.cancelAndJoin() connectionJob = null - connectedRoomId = null + connectedGeneration = null repository.reset() } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomVote.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomVote.kt index c0f1d4afec..cc01ec6d53 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomVote.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/RoomVote.kt @@ -3,15 +3,27 @@ package org.siloserver.silo.watchtogether import org.siloserver.silo.model.watchtogether.RoomSelectionMode import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.util.parseRfc3339ToEpochMillis /** - * Returns the winner from the server-ordered suggestion list. - * - * The server sorts by vote count and then age, so the first suggestion wins - * only after somebody has voted. An unvoted list intentionally has no winner. + * The server's vote order: most votes first, then the oldest suggestion. + * HTTP pages arrive in creation order, so display and winner selection must + * rank the merged list rather than trust the order it arrived in. + */ +fun rankSuggestions(suggestions: List): List = + suggestions.sortedWith( + compareByDescending { it.voteCount } + .thenBy { parseRfc3339ToEpochMillis(it.createdAt) ?: Long.MAX_VALUE } + .thenBy { it.id }, + ) + +/** + * The leading suggestion once somebody has voted. An unvoted list has no + * winner. The tally is advisory: a host with the override capability may + * promote any suggestion. */ fun roomVoteWinner(suggestions: List): Suggestion? = - suggestions.firstOrNull()?.takeIf { it.voteCount > 0 } + rankSuggestions(suggestions).firstOrNull()?.takeIf { it.voteCount > 0 } /** Whether this room decides its selection by vote. */ fun RoomSnapshot?.isVoteRoom(): Boolean = diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityRepository.kt new file mode 100644 index 0000000000..1b71f83a89 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityRepository.kt @@ -0,0 +1,103 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.apiv2.PlaybackCapabilitiesV2 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Discovers whether this server and profile can use Watch Party, from the room + * and playback capability documents, and caches the answer for the captured + * authority. A change of server, account, or profile discards the cache. A + * failed probe never downgrades a known answer: it keeps the last result for + * this authority and only reports [WatchPartyAvailability.ProbeFailed] when + * nothing is known yet. + */ +class WatchPartyAvailabilityRepository( + private val roomCapabilities: suspend (AuthScopeSnapshot) -> ApiResult, + private val playbackCapabilities: suspend (AuthScopeSnapshot) -> ApiResult, + private val authScopeProvider: suspend () -> AuthScopeSnapshot?, +) { + private val mutex = Mutex() + private var cachedOwner: AuthScopeSnapshot? = null + private val _availability = MutableStateFlow(null) + + /** The last known answer for the current authority, or null before the first probe. */ + val availability: StateFlow = _availability.asStateFlow() + + /** The features of the current authority, when Watch Party is available. */ + val features: WatchPartyFeatures? + get() = (_availability.value as? WatchPartyAvailability.Available)?.features + + /** + * Probe unless a definite answer for the current authority is cached. + * [force] probes again even then (the user pressed Retry, or the app + * returned to the foreground). + */ + suspend fun refresh(force: Boolean = false): WatchPartyAvailability = mutex.withLock { + val scope = authScopeProvider() + ?: return@withLock WatchPartyAvailability.ProbeFailed(NOT_SIGNED_IN).also { forgetLocked() } + if (!sameOwner(cachedOwner, scope)) { + cachedOwner = scope + _availability.value = null + } + val cached = _availability.value + if (!force && cached != null && cached !is WatchPartyAvailability.ProbeFailed) return@withLock cached + val probed = probe(scope) + if (!sameOwner(authScopeProvider(), scope)) { + return@withLock WatchPartyAvailability.ProbeFailed(IDENTITY_CHANGED) + } + val published = if (probed is WatchPartyAvailability.ProbeFailed && cached != null && + cached !is WatchPartyAvailability.ProbeFailed + ) { + cached + } else { + probed + } + _availability.value = published + published + } + + /** Drop the cached answer, for example on sign-out. */ + suspend fun forget() = mutex.withLock { forgetLocked() } + + private fun forgetLocked() { + cachedOwner = null + _availability.value = null + } + + private suspend fun probe(scope: AuthScopeSnapshot): WatchPartyAvailability { + val room = when (val r = roomCapabilities(scope)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> return WatchPartyAvailability.ProbeFailed(r.message.ifBlank { r.error }) + is ApiResult.NetworkError -> return WatchPartyAvailability.ProbeFailed(r.exception.message ?: NETWORK) + } + if (room.state != "available") return watchPartyAvailability(room, UNAVAILABLE_PLAYBACK) + val playback = when (val r = playbackCapabilities(scope)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> return WatchPartyAvailability.ProbeFailed(r.message.ifBlank { r.error }) + is ApiResult.NetworkError -> return WatchPartyAvailability.ProbeFailed(r.exception.message ?: NETWORK) + } + return watchPartyAvailability(room, playback) + } + + private fun sameOwner(a: AuthScopeSnapshot?, b: AuthScopeSnapshot?): Boolean = + a != null && b != null && a.isSameIdentityAs(b) && a.profileId == b.profileId + + private companion object { + const val NOT_SIGNED_IN = "not_signed_in" + const val IDENTITY_CHANGED = "identity_changed" + const val NETWORK = "network_error" + val UNAVAILABLE_PLAYBACK = PlaybackCapabilitiesV2( + state = "unavailable", + allowed = false, + protocolVersions = emptyList(), + features = emptyList(), + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyCapabilities.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyCapabilities.kt new file mode 100644 index 0000000000..d28de32e00 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyCapabilities.kt @@ -0,0 +1,79 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.siloserver.silo.network.apiv2.PlaybackCapabilitiesV2 + +/** Playback capability features Watch Party depends on. */ +object WatchPartyPlaybackFeatures { + const val Coordinator = "watch_party_coordinator_v1" + const val FixedMediaFile = "fixed_media_file_v1" + const val SourceFallback = "watch_party_source_fallback_v1" +} + +/** The v2 room socket protocol a server must serve for native Watch Party. */ +const val WATCH_PARTY_SOCKET_PROTOCOL = "silo.room.v2" + +/** + * Optional behaviour, each gated by its own server flag. A missing flag hides + * the matching control; it never substitutes a different operation. + */ +data class WatchPartyFeatures( + val stagedSelection: Boolean, + val stopPlayback: Boolean, + val selectionModeSwitch: Boolean, + val lobbyReady: Boolean, + val picker: Boolean, + val memberState: Boolean, + val maxMemberStateIds: Int, + val voteHostOverride: Boolean, + val sourceFallback: Boolean, +) + +sealed interface WatchPartyAvailability { + data class Available(val features: WatchPartyFeatures) : WatchPartyAvailability + + /** The server does not serve rooms, or lacks a capability native Watch Party requires. */ + data class Unsupported(val missing: List) : WatchPartyAvailability + + /** The server serves rooms, but this account may not use them. */ + data object NotAllowed : WatchPartyAvailability + + /** Discovery itself failed. Offer Retry; this is not evidence the server lacks support. */ + data class ProbeFailed(val message: String) : WatchPartyAvailability +} + +private const val STATE_AVAILABLE = "available" + +/** + * Combines the room and playback capability documents. Entry needs a served + * v2 room socket with the replacement signal, plus the shared coordinator and + * fixed-file playback. Everything else is an optional [WatchPartyFeatures] flag. + */ +fun watchPartyAvailability( + room: WatchTogetherCapabilitiesV2, + playback: PlaybackCapabilitiesV2, +): WatchPartyAvailability { + val missing = buildList { + if (room.state != STATE_AVAILABLE) add("rooms") + if (room.socketProtocol != WATCH_PARTY_SOCKET_PROTOCOL) add("room_socket") + if (!room.connectionReplaced) add("connection_replaced") + if (playback.state != STATE_AVAILABLE) add("playback") + if (WatchPartyPlaybackFeatures.Coordinator !in playback.features) add(WatchPartyPlaybackFeatures.Coordinator) + if (WatchPartyPlaybackFeatures.FixedMediaFile !in playback.features) add(WatchPartyPlaybackFeatures.FixedMediaFile) + } + if (missing.isNotEmpty()) return WatchPartyAvailability.Unsupported(missing) + if (room.allowed != true || !playback.allowed) return WatchPartyAvailability.NotAllowed + return WatchPartyAvailability.Available( + WatchPartyFeatures( + stagedSelection = room.stagedSelection, + stopPlayback = room.stopPlayback, + selectionModeSwitch = room.selectionModeSwitch, + lobbyReady = room.lobbyReady, + picker = room.picker, + memberState = room.memberState && room.maxMemberStateIds > 0, + maxMemberStateIds = if (room.memberState) room.maxMemberStateIds.coerceAtLeast(0) else 0, + voteHostOverride = room.voteHostOverride, + sourceFallback = WatchPartyPlaybackFeatures.SourceFallback in playback.features, + ), + ) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyEligibility.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyEligibility.kt new file mode 100644 index 0000000000..f7447d4cc9 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyEligibility.kt @@ -0,0 +1,132 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.WatchPartyEndReason + +/** Where the party screens should be for a room: its phase alone decides. */ +sealed interface WatchPartyDestination { + val roomId: String + + data class Lobby(override val roomId: String) : WatchPartyDestination + data class Player(override val roomId: String) : WatchPartyDestination +} + +/** + * The lobby while the room is in its lobby phase, the player while it plays. + * A staged item never opens the player, and a host alone still plays. + */ +fun watchPartyDestination(room: RoomSnapshot): WatchPartyDestination? = when (room.phase) { + RoomPhase.Lobby -> WatchPartyDestination.Lobby(room.roomId) + RoomPhase.Playing -> WatchPartyDestination.Player(room.roomId) + RoomPhase.Ended, RoomPhase.Unknown -> null +} + +/** + * What this member may do right now. Every flag depends on the phase, the + * member's role, the server's capabilities, and whether an action is already + * in flight; an unknown phase, role, or mode grants nothing. + */ +data class WatchPartyEligibility( + val isHost: Boolean = false, + val canStage: Boolean = false, + val canStart: Boolean = false, + val canStop: Boolean = false, + val canSwitchMode: Boolean = false, + val canSetPolicy: Boolean = false, + val canLobbyReady: Boolean = false, + val canSuggest: Boolean = false, + val canVote: Boolean = false, + val canPromote: Boolean = false, + val canQueue: Boolean = false, + val canEnd: Boolean = false, + val busy: Boolean = false, +) + +fun watchPartyEligibility( + room: RoomSnapshot?, + features: WatchPartyFeatures?, + busy: Boolean, + personalVotesKnown: Boolean, +): WatchPartyEligibility { + if (room == null || features == null) return WatchPartyEligibility(busy = busy) + val host = room.selfRole == MemberRole.Host && room.selfCanManageRoom + val known = room.selfRole != MemberRole.Unknown + val lobby = room.phase == RoomPhase.Lobby + val playing = room.phase == RoomPhase.Playing + val hostPick = room.selectionMode == RoomSelectionMode.HostPick + val vote = room.selectionMode == RoomSelectionMode.Vote + val staged = !room.selectedContentId.isNullOrBlank() + val free = !busy + return WatchPartyEligibility( + isHost = host, + canStage = free && host && lobby && hostPick && features.stagedSelection, + canStart = free && host && lobby && staged && features.stagedSelection, + canStop = free && host && playing && features.stopPlayback, + canSwitchMode = free && host && lobby && features.selectionModeSwitch && (hostPick || vote), + canSetPolicy = free && host && (lobby || playing), + // Advisory and allowed in any lobby, as on Apple: the server clears it + // whenever the selection changes. + canLobbyReady = known && !host && lobby && features.lobbyReady, + canSuggest = free && known && (lobby || playing) && (hostPick || vote), + canVote = free && known && vote && (lobby || playing) && personalVotesKnown, + canPromote = free && host && vote && (lobby || playing) && features.voteHostOverride, + canQueue = free && host && lobby && hostPick && features.stagedSelection, + canEnd = host && (lobby || playing), + busy = busy, + ) +} + +/** + * Whether a "host a party" entry point should show. Hidden once the server is + * known not to host (no staged selection, unsupported, or not allowed); shown + * while unknown, since the hub then explains what the probe finds. + */ +fun watchPartyHostingOffered(availability: WatchPartyAvailability?): Boolean = when (availability) { + is WatchPartyAvailability.Available -> availability.features.stagedSelection + is WatchPartyAvailability.Unsupported, WatchPartyAvailability.NotAllowed -> false + is WatchPartyAvailability.ProbeFailed, null -> true +} + +/** The host, or the suggester matched by both account and profile, may remove a suggestion. */ +fun canRemoveSuggestion(room: RoomSnapshot?, suggestion: Suggestion): Boolean { + room ?: return false + if (room.selfRole == MemberRole.Host && room.selfCanManageRoom) return true + val self = room.selfMember ?: return false + return self.userId.isNotBlank() && self.profileId.isNotBlank() && + suggestion.suggesterUserId == self.userId && suggestion.suggesterProfileId == self.profileId +} + +/** Advisory lobby readiness: connected guests who marked themselves ready, out of all connected guests. */ +data class LobbyReadiness(val ready: Int, val guests: Int) + +fun lobbyReadiness(room: RoomSnapshot?): LobbyReadiness { + val guests = room?.members.orEmpty().filter { it.connected && !it.isHost } + return LobbyReadiness(ready = guests.count { it.lobbyReady }, guests = guests.size) +} + +/** Plain text for a failed Watch Party request. [fallback] covers anything unrecognized. */ +fun watchPartyErrorMessage(result: ApiResult<*>, fallback: String): String = when (result) { + is ApiResult.Success -> fallback + is ApiResult.NetworkError -> "Couldn't reach the server. Check your connection and try again." + is ApiResult.Error -> when { + result.error == WatchPartyEndReason.Ended || result.error == "no_active_room" -> "This Watch Party has ended." + result.error == "obsolete_room_request" || result.error == "identity_changed" -> + "Your profile changed, so that didn't go through." + result.error == "invalid_response" || result.error == "invalid_room_response" -> + "The server sent an unexpected answer. Try again." + result.code == 401 -> "Sign in again to use Watch Party." + result.code == 403 && result.error == "profile_verification_required" -> "Unlock this profile to use Watch Party." + result.code == 403 -> "Only the host can do that." + result.code == 404 -> "That Watch Party wasn't found." + result.code == 409 -> "That didn't work because the party changed. Check the room and try again." + result.code == 422 -> "That item can't be played in a Watch Party." + result.code == 429 -> "Too many requests. Wait a moment and try again." + result.code == 503 -> "Watch Party is unavailable on this server right now." + else -> fallback + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyIds.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyIds.kt new file mode 100644 index 0000000000..c25038236f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyIds.kt @@ -0,0 +1,12 @@ +package org.siloserver.silo.watchtogether + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A caller-selected room or suggestion identity. Create it once per logical + * action and keep it for an explicit retry, so the server can replay instead + * of creating a duplicate. + */ +@OptIn(ExperimentalUuidApi::class) +fun newWatchPartyId(): String = Uuid.random().toString() diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyInvitation.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyInvitation.kt new file mode 100644 index 0000000000..143a0c7959 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyInvitation.kt @@ -0,0 +1,139 @@ +package org.siloserver.silo.watchtogether + +/** Something a user entered, pasted, or opened to join a Watch Party. */ +sealed interface WatchPartyInvite { + /** An 8-character room code for the current server. */ + data class Code(val code: String) : WatchPartyInvite + + /** + * A join token from an invitation link. [serverUrl] is the deployment the + * link names (scheme, host, port, and base path); the caller must match it + * to the current server before sending the token anywhere. + */ + data class Link(val serverUrl: String, val token: String) : WatchPartyInvite { + override fun toString(): String = "Link(serverUrl=, token=)" + } +} + +/** Room codes are 8 characters from this alphabet (no 0, 1, I, or O). */ +const val WATCH_PARTY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +const val WATCH_PARTY_CODE_LENGTH = 8 + +/** + * Normalizes typed input into a room code: trims, drops spaces and dashes, + * and uppercases. Returns null unless the result is a well-formed code. The + * server matches codes without regard to case but does not strip + * separators, so this must run before sending. + */ +fun normalizeWatchPartyCode(input: String): String? { + val code = input.filterNot { it.isWhitespace() || it == '-' }.uppercase() + return code.takeIf { it.length == WATCH_PARTY_CODE_LENGTH && it.all { c -> c in WATCH_PARTY_CODE_ALPHABET } } +} + +/** + * Parses a room code, a server invitation link + * (`https://host[:port][/base]/rooms/join?token=…`), or an app link + * (`silo://watch-party?server=&token=…`). The app link is + * validated as strictly as the web link: an http or https server with no + * credentials, query, or fragment, and exactly one non-empty `server` and + * `token`. Account invitation links (`silo://invite`) are not party links. + */ +fun parseWatchPartyInvite(input: String): WatchPartyInvite? { + val text = input.trim() + if (text.isEmpty()) return null + normalizeWatchPartyCode(text)?.let { return WatchPartyInvite.Code(it) } + val scheme = text.substringBefore("://", missingDelimiterValue = "").lowercase() + return when (scheme) { + "http", "https" -> parseWebInvite(text) + "silo" -> parseAppInvite(text) + else -> null + } +} + +private fun parseWebInvite(text: String): WatchPartyInvite? { + val withoutFragment = text.substringBefore('#') + val base = withoutFragment.substringBefore('?') + val query = withoutFragment.substringAfter('?', missingDelimiterValue = "") + if (!base.endsWith(JOIN_PATH)) return null + val server = base.removeSuffix(JOIN_PATH) + if (!validServerUrl(server)) return null + val token = singleParameter(query, "token") ?: return null + if (parameterNames(query).any { it != "token" }) return null + return WatchPartyInvite.Link(serverUrl = server, token = token) +} + +private fun parseAppInvite(text: String): WatchPartyInvite? { + if (text.contains('#')) return null + val afterScheme = text.substringAfter("://") + val host = afterScheme.substringBefore('?').removeSuffix("/") + if (!host.equals(APP_LINK_HOST, ignoreCase = true)) return null + val query = afterScheme.substringAfter('?', missingDelimiterValue = "") + val names = parameterNames(query) + if (names.any { it != "server" && it != "token" }) return null + val server = singleParameter(query, "server")?.removeSuffix("/") ?: return null + val token = singleParameter(query, "token") ?: return null + if (!validServerUrl(server)) return null + return WatchPartyInvite.Link(serverUrl = server, token = token) +} + +private fun validServerUrl(url: String): Boolean { + val scheme = url.substringBefore("://", missingDelimiterValue = "").lowercase() + if (scheme != "http" && scheme != "https") return false + val rest = url.substringAfter("://") + val authority = rest.substringBefore('/') + return authority.isNotEmpty() && '@' !in authority && '?' !in url && '#' !in url && + authority.none { it.isWhitespace() } +} + +private fun parameterNames(query: String): List = + query.split('&').filter { it.isNotEmpty() }.map { it.substringBefore('=') } + +/** The single non-empty value of [name], or null when missing, empty, or repeated. */ +private fun singleParameter(query: String, name: String): String? { + val values = query.split('&').filter { it.substringBefore('=') == name }.map { it.substringAfter('=', "") } + if (values.size != 1) return null + return percentDecode(values.single()).takeIf { it.isNotBlank() } +} + +private fun percentDecode(value: String): String { + if ('%' !in value && '+' !in value) return value + val bytes = ArrayList(value.length) + var i = 0 + while (i < value.length) { + val c = value[i] + when { + c == '%' && i + 2 <= value.lastIndex -> { + val hex = value.substring(i + 1, i + 3).toIntOrNull(16) + if (hex == null) { + bytes += c.code.toByte() + i++ + } else { + bytes += hex.toByte() + i += 3 + } + } + c == '+' -> { + bytes += ' '.code.toByte() + i++ + } + else -> { + c.toString().encodeToByteArray().forEach { bytes += it } + i++ + } + } + } + return bytes.toByteArray().decodeToString() +} + +private const val JOIN_PATH = "/rooms/join" +private const val APP_LINK_HOST = "watch-party" + +/** + * The web invitation link for a host's `invite_path`, resolved against the + * verified server URL including its base path. Null when the path is not a + * relative room invitation. + */ +fun watchPartyInviteUrl(serverUrl: String, invitePath: String?): String? { + val path = invitePath?.takeIf { it.startsWith("$JOIN_PATH?") } ?: return null + return serverUrl.trimEnd('/') + path +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentation.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentation.kt new file mode 100644 index 0000000000..7d615b26db --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentation.kt @@ -0,0 +1,260 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion + +/* + * How the lobby reads, shared by phone and TV so both say what the Apple + * clients say (`WatchPartyLobbyPolicy` in silo-apple). Layout stays in each + * app; the words and the one foregrounded action live here. + */ + +/** How a member's seat reads in the lobby and during playback. */ +enum class WatchPartySeatState { Ready, NotReady, Away, Watching, Syncing, Buffering, Joining } + +/** The host starts playback, so the host never has to mark ready. */ +fun watchPartySeatState(member: RoomMember, phase: RoomPhase): WatchPartySeatState = when { + !member.connected -> WatchPartySeatState.Away + phase == RoomPhase.Lobby -> + if (member.isHost || member.lobbyReady) WatchPartySeatState.Ready else WatchPartySeatState.NotReady + member.isBuffering -> WatchPartySeatState.Buffering + member.isSyncing -> WatchPartySeatState.Syncing + member.isReady -> WatchPartySeatState.Watching + else -> WatchPartySeatState.Joining +} + +/** The short word under a seat's name, or null when the ring already says it. */ +fun watchPartySeatStatus(state: WatchPartySeatState): String? = when (state) { + WatchPartySeatState.Ready, WatchPartySeatState.Watching -> null + WatchPartySeatState.NotReady -> "not ready" + WatchPartySeatState.Away -> "reconnecting" + WatchPartySeatState.Syncing -> "syncing" + WatchPartySeatState.Buffering -> "buffering" + WatchPartySeatState.Joining -> "joining" +} + +/** Host first, then this viewer, then everyone else by name. */ +fun watchPartySeatOrder(members: List): List = + members.sortedWith( + compareByDescending { it.isHost } + .thenByDescending { it.isSelf } + .thenBy { it.displayName.lowercase() }, + ) + +/** The trailing summary beside "Here now": "only you so far", "2 of 3 ready". */ +fun watchPartyPresenceSummary(members: List, phase: RoomPhase): String { + val here = members.filter { it.connected } + if (here.size <= 1) return if (here.size == 1) "only you so far" else "nobody connected" + return if (phase == RoomPhase.Lobby) { + val ready = here.count { watchPartySeatState(it, phase) == WatchPartySeatState.Ready } + "$ready of ${here.size} ready" + } else { + val watching = here.count { watchPartySeatState(it, phase) == WatchPartySeatState.Watching } + "$watching of ${here.size} watching" + } +} + +/** Connected guests who have not marked ready, for the host's advisory hint. */ +fun watchPartyLobbyWaitingNames(members: List): List = + members.filter { it.connected && !it.isHost && !it.lobbyReady }.map { it.displayName.ifBlank { "Someone" } } + +/** The one action the lobby foregrounds for this member right now. */ +sealed interface WatchPartyPrimaryAction { + /** Host in host-pick mode with nothing staged. */ + data object ChooseTitle : WatchPartyPrimaryAction + + /** Host may start now. [title] names the vote winner in a voting room. */ + data class Start(val title: String?) : WatchPartyPrimaryAction + + /** Host in a voting room before any suggestion has a vote. */ + data object WaitingForVotes : WatchPartyPrimaryAction + + /** A guest's lobby readiness toggle. */ + data class Ready(val isReady: Boolean) : WatchPartyPrimaryAction + + /** The room is playing; this device can go back to the player. */ + data object ReturnToPlayback : WatchPartyPrimaryAction + + data object None : WatchPartyPrimaryAction +} + +private val RoomSnapshot.managedBySelf: Boolean + get() = selfRole == MemberRole.Host && selfCanManageRoom + +/** + * Whether the host could start now, independent of any request in flight so + * the lobby's buttons keep their place while one runs. A voting room starts + * its leader; a host-pick room starts its staged title. + */ +fun watchPartyCanStart(room: RoomSnapshot?, features: WatchPartyFeatures?, suggestions: List): Boolean { + if (room == null || !room.managedBySelf || room.phase != RoomPhase.Lobby) return false + return when (room.selectionMode) { + // Starting a voting room promotes its leader, which needs the promote route. + RoomSelectionMode.Vote -> features?.voteHostOverride == true && roomVoteWinner(suggestions) != null + RoomSelectionMode.HostPick -> features?.stagedSelection == true && !room.selectedContentId.isNullOrBlank() + else -> false + } +} + +fun watchPartyPrimaryAction( + room: RoomSnapshot, + features: WatchPartyFeatures?, + suggestions: List, + selectionUnavailable: Boolean = false, +): WatchPartyPrimaryAction { + val manages = room.managedBySelf + when (room.phase) { + RoomPhase.Playing -> + return if (selectionUnavailable && !manages) WatchPartyPrimaryAction.None else WatchPartyPrimaryAction.ReturnToPlayback + RoomPhase.Lobby -> Unit + else -> return WatchPartyPrimaryAction.None + } + // A guest who cannot see the title has nothing to ready up for. + if (selectionUnavailable && !manages) return WatchPartyPrimaryAction.None + val canStart = watchPartyCanStart(room, features, suggestions) + if (manages) { + return when (room.selectionMode) { + RoomSelectionMode.Vote -> + if (canStart) WatchPartyPrimaryAction.Start(roomVoteWinner(suggestions)?.title) else WatchPartyPrimaryAction.WaitingForVotes + RoomSelectionMode.HostPick -> + if (canStart) WatchPartyPrimaryAction.Start(null) else WatchPartyPrimaryAction.ChooseTitle + else -> WatchPartyPrimaryAction.None + } + } + val self = room.selfMember + if (features?.lobbyReady == true && self != null) return WatchPartyPrimaryAction.Ready(self.lobbyReady) + return WatchPartyPrimaryAction.None +} + +/** The secondary lobby action beside the primary one, if any. */ +enum class WatchPartySecondaryAction { ChangeTitle, Suggest } + +fun watchPartySecondaryAction(room: RoomSnapshot, primary: WatchPartyPrimaryAction): WatchPartySecondaryAction? { + if (room.phase != RoomPhase.Lobby) return null + val manages = room.managedBySelf + return when { + manages && room.selectionMode == RoomSelectionMode.HostPick && !room.selectedContentId.isNullOrBlank() && + primary is WatchPartyPrimaryAction.Start -> WatchPartySecondaryAction.ChangeTitle + room.selectionMode == RoomSelectionMode.Vote && primary is WatchPartyPrimaryAction.Start -> + WatchPartySecondaryAction.Suggest + // Guests in either mode can put a title forward. + !manages && room.selfRole != MemberRole.Unknown && + (room.selectionMode == RoomSelectionMode.Vote || room.selectionMode == RoomSelectionMode.HostPick) -> + WatchPartySecondaryAction.Suggest + else -> null + } +} + +private fun hostName(room: RoomSnapshot): String = + room.members.firstOrNull { it.isHost }?.displayName?.ifBlank { null } ?: "The host" + +/** The quiet line under the lobby's buttons, or null. */ +fun watchPartyLobbyHint(room: RoomSnapshot, suggestions: List): String? { + if (room.phase != RoomPhase.Lobby) return null + val host = hostName(room) + if (room.managedBySelf) { + if (room.selectionMode == RoomSelectionMode.Vote && roomVoteWinner(suggestions) == null) { + return if (suggestions.isEmpty()) "Suggest a title to get the vote going." else "Nobody has voted yet." + } + if (room.selectionMode == RoomSelectionMode.HostPick && room.selectedContentId.isNullOrBlank()) return null + val waiting = watchPartyLobbyWaitingNames(room.members) + if (waiting.isEmpty()) return if (room.members.count { it.connected } > 1) "Everyone's ready." else null + val names = waiting.take(2).joinToString(" and ") + if (waiting.size > 2) " and others" else "" + return "$names ${if (waiting.size == 1) "hasn't" else "haven't"} marked ready. You can still start." + } + if (room.selectionMode == RoomSelectionMode.Vote) { + return "$host starts ${roomVoteWinner(suggestions)?.title ?: "the winner"} when voting settles." + } + return if (room.selectedContentId.isNullOrBlank()) null else "$host starts the movie when everyone's set." +} + +/** The hero's eyebrow, title, and supporting line for the lobby. */ +data class WatchPartyLobbyHero( + val eyebrow: String, + /** Null means "use the staged title's own name". */ + val title: String?, + val supporting: String?, + /** A voting lobby before playback: the question is the title and no poster shows. */ + val isVoteQuestion: Boolean = false, +) + +fun watchPartyLobbyHero(room: RoomSnapshot, suggestions: List, selectionUnavailable: Boolean = false): WatchPartyLobbyHero { + val host = hostName(room) + val manages = room.managedBySelf + if (room.selectionMode == RoomSelectionMode.Vote && room.phase == RoomPhase.Lobby) { + val count = suggestions.size + return WatchPartyLobbyHero( + eyebrow = "Voting · $count ${if (count == 1) "title" else "titles"}", + title = "What are we watching?", + supporting = if (manages) "Everyone votes. You start the leader." else "Tap a title to vote. $host starts the winner.", + isVoteQuestion = true, + ) + } + val eyebrow = if (room.phase == RoomPhase.Playing) "Now watching · together" else "Up next · together" + if (!room.selectedContentId.isNullOrBlank()) { + return WatchPartyLobbyHero( + eyebrow = eyebrow, + title = if (selectionUnavailable) "A title you can't see" else null, + supporting = if (room.selfRole == MemberRole.Host) "You chose this" else "$host chose this", + ) + } + return WatchPartyLobbyHero( + eyebrow = "Your party is open", + title = if (manages) "Pick something\nto watch" else "Waiting for\n$host", + supporting = if (manages) { + "Friends can join now with the code. They'll see your pick the moment you choose." + } else { + "$host is choosing what to watch. You'll see it here the moment they do." + }, + ) +} + +/** Connection state for the dot beside the code pill. */ +enum class WatchPartyConnectionTone { Good, Warning, Neutral, Bad } + +/** + * The dot's colour and word: "Open" or "2 here" when connected, "Host away" + * for a guest whose host dropped, else the connection state. + */ +fun watchPartyConnectionLabel( + room: RoomSnapshot, + connected: Boolean, + reconnecting: Boolean, +): Pair = when { + connected && !room.hostConnected && room.selfRole != MemberRole.Host -> WatchPartyConnectionTone.Warning to "Host away" + connected -> { + val here = room.members.count { it.connected } + WatchPartyConnectionTone.Good to if (here <= 1) "Open" else "$here here" + } + reconnecting -> WatchPartyConnectionTone.Warning to "Reconnecting" + else -> WatchPartyConnectionTone.Neutral to "Connecting" +} + +/** The lobby's single status banner, in Apple's order of importance, or null. */ +fun watchPartyLobbyBanner(room: RoomSnapshot, selectionUnavailable: Boolean, reconnecting: Boolean): String? { + val host = hostName(room) + return when { + selectionUnavailable && room.managedBySelf -> + "This title isn't available to your profile. Choose something else so everyone can watch." + selectionUnavailable -> "This title isn't available to your profile. $host will need to pick something else." + !room.hostConnected && room.selfRole != MemberRole.Host -> + "$host lost connection. The party holds for two minutes while they come back." + reconnecting -> "Reconnecting to the party…" + else -> null + } +} + +/** + * Which avatar colour a member gets, matching Apple's `avatarGradient(for:)`: + * djb2 over "userId:profileId" with 64-bit wrapping, then `abs % size`, so a + * person has the same colour on every client. + */ +fun watchPartyAvatarIndex(member: RoomMember, paletteSize: Int): Int { + if (paletteSize <= 0) return 0 + val hash = "${member.userId}:${member.profileId}".fold(5381L) { acc, c -> acc * 33 + c.code } + return (kotlin.math.abs(hash) % paletteSize).toInt().coerceAtLeast(0) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyPlaybackContext.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyPlaybackContext.kt new file mode 100644 index 0000000000..f0997a3af6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchPartyPlaybackContext.kt @@ -0,0 +1,41 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.RoomSnapshot + +/** + * What a player must prepare for one room playback epoch: the room's exact + * content, file, and library, the room position, and whether it starts + * paused. [positionSeconds] is the snapshot anchor as sent; the server has + * already projected it, and the command sent after attach corrects the rest. + */ +data class WatchPartyPlaybackContext( + val roomId: String, + val selectionRevision: Long, + val contentId: String, + val fileId: Int, + val libraryId: Int?, + val positionSeconds: Double, + val paused: Boolean, +) + +/** + * The playback context for a playing room, or null when the room is not + * playing or has not resolved its file yet. + */ +fun RoomSnapshot.playbackContext(): WatchPartyPlaybackContext? { + if (phase != RoomPhase.Playing) return null + val content = selectedContentId?.takeIf { it.isNotBlank() } ?: return null + val file = selectedFileId?.takeIf { it > 0 } ?: return null + return WatchPartyPlaybackContext( + roomId = roomId, + selectionRevision = selectionRevision, + contentId = content, + fileId = file, + libraryId = selectedLibraryId, + positionSeconds = anchorPositionSeconds.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0, + // Everyone starts paused; the room's commands decide when to play. + paused = isPaused || playbackState != RoomPlaybackState.Playing, + ) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryGateway.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryGateway.kt deleted file mode 100644 index 611fe188e0..0000000000 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryGateway.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.siloserver.silo.watchtogether - -import org.siloserver.silo.model.watchtogether.CreateRoomRequest -import org.siloserver.silo.model.watchtogether.JoinRoomRequest -import org.siloserver.silo.model.watchtogether.RoomResponse -import org.siloserver.silo.model.watchtogether.RoomSnapshot -import org.siloserver.silo.model.watchtogether.SetSelectionRequest -import org.siloserver.silo.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/siloserver/silo/watchtogether/WatchTogetherEntryPolicy.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryPolicy.kt deleted file mode 100644 index 89b23ae7c9..0000000000 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryPolicy.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.siloserver.silo.watchtogether - -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.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/siloserver/silo/model/feature/ClientSurfacePolicyTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/ClientSurfacePolicyTest.kt deleted file mode 100644 index b8901c6300..0000000000 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/ClientSurfacePolicyTest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.siloserver.silo.model.feature - -import kotlin.test.Test -import kotlin.test.assertFalse - -class ClientSurfacePolicyTest { - @Test - fun watchTogetherCodeStaysPresentButHiddenFromUserMenus() { - assertFalse(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) - } -} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3Test.kt index ec9e003a6e..b4c96b901e 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3Test.kt @@ -686,4 +686,22 @@ class PlaybackProtocolV3Test { playbackClientFeaturesV3(context.copy(output = PlaybackOutputContext())), ) } + + @Test + fun `watch party starts pin the file and solo starts leave the field out`() { + fun encode(allow: Boolean?) = SiloJson.encodeToString( + PlaybackStartRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, + fileId = 12, + profileId = "profile", + playbackAttemptId = "attempt", + subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "mobile", appVersion = "test"), + allowAlternateVersions = allow, + ), + ) + assertFalse(encode(null).contains("allow_alternate_versions")) + assertTrue(encode(false).contains("\"allow_alternate_versions\":false")) + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.kt new file mode 100644 index 0000000000..688f78432e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchPartyFixtures.kt @@ -0,0 +1,7 @@ +package org.siloserver.silo.model.watchtogether + +/** Reads one file under `watchtogether/v2/` from the test resources. See its SOURCE file. */ +expect fun readWatchPartyFixture(name: String): String + +/** Class-loader anchor for the Android actual. */ +internal object WatchPartyFixtureAnchor diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModelsSerializationTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModelsSerializationTest.kt index 5c41a10b28..967d031c02 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModelsSerializationTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/watchtogether/WatchTogetherModelsSerializationTest.kt @@ -231,12 +231,18 @@ class WatchTogetherModelsSerializationTest { // ---- Request models (encode → correct wire keys, omit nulls) -------------- @Test - fun `create request encodes selection_mode`() { + fun `create request encodes caller room_id and selection_mode`() { val out = json.encodeToString( CreateRoomRequest.serializer(), - CreateRoomRequest(selectionMode = "vote"), + CreateRoomRequest(roomId = "7b1b0a3c-0000-4000-8000-000000000001", selectionMode = "vote"), ) - assertEquals(setOf("selection_mode"), json.parseToJsonElement(out).jsonObject.keys) + val body = json.parseToJsonElement(out).jsonObject + assertEquals(setOf("room_id", "selection_mode"), body.keys) + } + + @Test + fun `create request defaults to host picks`() { + assertEquals("host_pick", CreateRoomRequest(roomId = "r").selectionMode) } @Test @@ -272,10 +278,10 @@ class WatchTogetherModelsSerializationTest { fun `add suggestion request encodes required fields and omits null optionals`() { val out = json.encodeToString( AddSuggestionRequest.serializer(), - AddSuggestionRequest(contentId = "c", contentType = "movie", title = "T"), + AddSuggestionRequest(suggestionId = "s-1", contentId = "c", contentType = "movie", title = "T"), ) assertEquals( - setOf("content_id", "content_type", "title"), + setOf("suggestion_id", "content_id", "content_type", "title"), json.parseToJsonElement(out).jsonObject.keys, ) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/RoomFrameDecoderTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/RoomFrameDecoderTest.kt index e5190c732d..ce22591697 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/RoomFrameDecoderTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/RoomFrameDecoderTest.kt @@ -97,13 +97,40 @@ class RoomFrameDecoderTest { } @Test - fun `snapshot with malformed room payload returns null`() { - // room present but missing required room_id → decode fails → null, no throw. - assertNull(decodeRoomFrame(json, """{"type":"snapshot","room":{"phase":"lobby"}}""")) + fun `snapshot with malformed room payload is reported, not thrown`() { + // room present but missing required room_id: the owner reconciles by reading the room. + assertEquals( + RoomRealtimeEvent.Malformed("snapshot"), + decodeRoomFrame(json, """{"type":"snapshot","room":{"phase":"lobby"}}"""), + ) } @Test - fun `transport_command missing command returns null`() { - assertNull(decodeRoomFrame(json, """{"type":"transport_command"}""")) + fun `transport_command with an unreadable execute_at is reported as malformed`() { + // Running it at once would skip the room's schedule; reconciling is safe. + val raw = """{"type":"transport_command","command":{"command_id":"cmd-1","action":"seek", + "position_seconds":42.0,"execute_at":"soon","playback_state":"playing"}}""" + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, raw)) + } + + @Test + fun `transport_command with an unknown action is reported as malformed`() { + // The binding would treat it as advancing playback; reconciling is safe. + val raw = """{"type":"transport_command","command":{"command_id":"cmd-1","action":"rewind", + "position_seconds":42.0,"execute_at":"2026-06-12T09:30:00Z","playback_state":"playing"}}""" + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, raw)) + } + + @Test + fun `transport_command without a playback_state is reported as malformed`() { + // Play and seek decide whether to run or hold from this state. + val raw = """{"type":"transport_command","command":{"command_id":"cmd-1","action":"seek", + "position_seconds":42.0,"execute_at":"2026-06-12T09:30:00Z"}}""" + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, raw)) + } + + @Test + fun `transport_command missing command is reported as malformed`() { + assertEquals(RoomRealtimeEvent.Malformed("transport_command"), decodeRoomFrame(json, """{"type":"transport_command"}""")) } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClientTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClientTest.kt index fa31346488..3ae4ab18c2 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClientTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/WatchTogetherRealtimeClientTest.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.network import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.isActive import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -178,6 +179,12 @@ class WatchTogetherRealtimeClientTest { connection.sendFailure = IllegalStateException("closed") assertFalse(client.ping("2026-07-27T10:00:01Z")) + // A socket that dies under a pending send cancels its outgoing + // channel. That is a failed send, not the caller's cancellation. + connection.sendFailure = CancellationException("outgoing channel cancelled") + assertFalse(client.stateReport("session-1", 16.5, false)) + assertFalse(client.transportRequestOnConnection(client.currentConnectionId() ?: -1L, "play", null, false)) + assertTrue(coroutineContext.isActive) job.cancelAndJoin() assertFalse(client.attachSession("session-after-close")) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/WatchTogetherApiTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/WatchTogetherApiTest.kt index 135fc3ba16..0243305b74 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/WatchTogetherApiTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/WatchTogetherApiTest.kt @@ -3,28 +3,38 @@ package org.siloserver.silo.network.api import org.siloserver.silo.model.watchtogether.AddSuggestionRequest import org.siloserver.silo.model.watchtogether.CreateRoomRequest import org.siloserver.silo.model.watchtogether.JoinRoomRequest +import org.siloserver.silo.model.watchtogether.MemberStateRequest import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.SelectionModeRequest import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.model.watchtogether.SourceFallbackRequest import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest +import org.siloserver.silo.model.watchtogether.readWatchPartyFixture import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeAttributeKey import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.SingleAttemptAttributeKey +import org.siloserver.silo.network.apiv2.ApiV2Gate 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.serialization.kotlinx.json.json 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.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue class WatchTogetherApiTest { @@ -34,271 +44,292 @@ class WatchTogetherApiTest { serverUrl = "https://silo.example", profileToken = "profile-token", ) - - private class Captured { - val calls = mutableListOf() - val last: Call get() = calls.last() - val first: Call get() = calls.first() - } + private val roomId = "9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10" + private val roomBody = readWatchPartyFixture("room_http.json") private data class Call( val method: HttpMethod, val path: String, val query: Map, val roomToken: String?, + val singleAttempt: Boolean, + val pinnedScope: AuthScopeSnapshot?, val body: String, ) - private val roomJson = """ - {"room_id":"room-1","phase":"lobby","playback_state":"idle","selection_mode":"host_pick", - "selection_revision":0,"code":"ABCD1234","guest_control_policy":"host_only", - "is_paused":false,"anchor_position_seconds":0.0,"anchor_updated_at":"2026-06-12T09:00:00Z", - "generation":1,"member_count":1,"host_connected":true,"self_role":"host", - "self_can_control_transport":true,"self_can_manage_room":true,"self_ignore_wait":false} - """.trimIndent() - - private val suggestionsJson = """ - {"items":[{"id":"sug-5","room_id":"room-1","suggester_user_id":"7","suggester_profile_id":"p", - "content_id":"c","content_type":"movie","title":"T","subtitle":"","poster_url":"","note":"", - "vote_count":2,"voted_by_me":true,"created_at":"2026-06-12T08:00:00Z"}], - "page":{"has_more":false}} - """.trimIndent() - - private val problem = """{"type":"https://silo.example/problems/conflict","title":"Conflict","status":409,"detail":"Already voted"}""" - - /** Routes by method: the first response answers the mutation, a suggestions GET always answers with the list. */ - private fun api( - status: HttpStatusCode = HttpStatusCode.OK, - responseBody: String = "{}", - captured: Captured = Captured(), - ): Pair { + private class Server(var status: HttpStatusCode = HttpStatusCode.OK, var body: String = "{}") { + val calls = mutableListOf() + val last: Call get() = calls.last() + } + + private fun api(server: Server): WatchTogetherApi { val client = HttpClient( MockEngine { request -> - captured.calls += Call( + server.calls += Call( method = request.method, path = request.url.encodedPath, query = request.url.parameters.names().associateWith { request.url.parameters[it] }, roomToken = request.headers["X-Room-Token"], + singleAttempt = request.attributes.getOrNull(SingleAttemptAttributeKey) == true, + pinnedScope = request.attributes.getOrNull(AuthScopeAttributeKey), body = request.body.toByteArray().decodeToString(), ) - val followUpList = captured.calls.size > 1 && - request.method == HttpMethod.Get && request.url.encodedPath.endsWith("/suggestions") - if (followUpList) { - respond(suggestionsJson, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) - } else { - respond( - content = responseBody, - status = status, - headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), - ) - } + val type = if (server.status.value >= 400) "application/problem+json" else "application/json" + respond(server.body, server.status, headersOf(HttpHeaders.ContentType, type)) }, ) { install(ContentNegotiation) { json(SiloJson) } } - return DefaultWatchTogetherApi(client, org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted) to captured + return DefaultWatchTogetherApi(client, ApiV2Gate.Unrestricted) + } + + private fun sent(call: Call) = SiloJson.parseToJsonElement(call.body).jsonObject + + private fun assertInvalidResponse(result: ApiResult<*>) { + assertIs(result) + assertEquals("invalid_response", result.error) } + // ---- create / join / read -------------------------------------------------- + @Test - fun `createRoom posts client room_id and selection_mode and decodes room + token`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Created, - responseBody = """{"room":$roomJson,"room_access_token":"jwt-1"}""", - ) - val r = api.createRoom(CreateRoomRequest(selectionMode = "vote"), scope) - assertEquals(HttpMethod.Post, captured.last.method) - assertEquals("/api/v2/watch-together/rooms", captured.last.path) - val sent = SiloJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals(setOf("room_id", "selection_mode"), sent.keys) - assertEquals("vote", sent.getValue("selection_mode").jsonPrimitive.content) - assertTrue(sent.getValue("room_id").jsonPrimitive.content.length == 36) - assertIs>(r) - assertEquals("jwt-1", (r as ApiResult.Success).data.roomAccessToken) + fun `create sends the caller room identity and requires 201`() = runTest { + val server = Server(HttpStatusCode.Created, roomBody) + val result = api(server).createRoom(CreateRoomRequest(roomId = roomId, selectionMode = "vote"), scope) + + assertIs>(result) + assertEquals(HttpMethod.Post, server.last.method) + assertEquals("/api/v2/watch-together/rooms", server.last.path) + assertEquals(setOf("room_id", "selection_mode"), sent(server.last).keys) + assertEquals(roomId, sent(server.last).getValue("room_id").jsonPrimitive.content) + assertEquals(scope, server.last.pinnedScope) + assertNull(server.last.roomToken) + assertFalse(server.last.singleAttempt) } @Test - fun `createRoom defaults selection_mode to host_pick`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Created, - responseBody = """{"room":$roomJson,"room_access_token":"jwt-1"}""", - ) - api.createRoom(CreateRoomRequest(), scope) - val sent = SiloJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals("host_pick", sent.getValue("selection_mode").jsonPrimitive.content) + fun `create answered with the wrong success status is an invalid response`() = runTest { + val result = api(Server(HttpStatusCode.OK, roomBody)).createRoom(CreateRoomRequest(roomId = roomId), scope) + assertInvalidResponse(result) } @Test - fun `joinRoom posts code and decodes room`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-2"}""") - val r = api.joinRoom(JoinRoomRequest(code = "ABCD1234"), scope) - assertEquals(HttpMethod.Post, captured.last.method) - assertEquals("/api/v2/watch-together/join", captured.last.path) - assertIs>(r) - assertEquals("room-1", (r as ApiResult.Success).data.room.roomId) + fun `create response naming another room is an invalid response`() = runTest { + val result = api(Server(HttpStatusCode.Created, roomBody)).createRoom(CreateRoomRequest(roomId = "other"), scope) + assertInvalidResponse(result) } @Test - fun `getRoom passes X-Room-Token header and no query`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.getRoom("room-1", "jwt-room", scope) - assertEquals(HttpMethod.Get, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - assertTrue(captured.last.query.isEmpty()) + fun `join decodes the v2 snapshot with string ids and members`() = runTest { + val server = Server(body = roomBody) + val result = api(server).joinRoom(JoinRoomRequest(code = "K7PQ2M4X"), scope) + + assertIs>(result) + val room = result.data.room + assertEquals(RoomPhase.Playing, room.phase) + assertEquals(42, room.selectedFileId) + assertEquals(7, room.selectedLibraryId) + assertEquals(2, room.members.size) + assertEquals("12", room.selfMember?.userId) + assertTrue(room.members[1].isSyncing) + assertEquals("/rooms/join?token=Zq8vN3kR5tW1yB6cD9fG2hJ4", room.invitePath) + assertEquals(mapOf("code" to "K7PQ2M4X"), sent(server.last).mapValues { it.value.jsonPrimitive.content }) } @Test - fun `setSelection puts content_id with string file_id`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.setSelection("room-1", "jwt-room", SetSelectionRequest(contentId = "tt-9", fileId = 3), scope) - assertEquals(HttpMethod.Put, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/selection", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - val sent = SiloJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals("tt-9", sent.getValue("content_id").jsonPrimitive.content) - assertEquals("3", sent.getValue("file_id").jsonPrimitive.content) - assertTrue(sent.getValue("file_id").jsonPrimitive.isString) - assertTrue("library_id" !in sent) + fun `a room response without proof is an invalid response`() = runTest { + val body = roomBody.replace(Regex("\"room_access_token\": \"[^\"]+\""), "\"room_access_token\": \"\"") + assertInvalidResponse(api(Server(body = body)).joinRoom(JoinRoomRequest(code = "K7PQ2M4X"), scope)) } @Test - fun `updatePolicy patches policy with X-Room-Token`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.updatePolicy( - "room-1", - "jwt-room", - UpdatePolicyRequest(guestControlPolicy = "guest_play_pause"), - scope, + fun `room read sends the proof and rejects another room`() = runTest { + val server = Server(body = roomBody) + assertIs>(api(server).getRoom(roomId, "proof-1", scope)) + assertEquals("proof-1", server.last.roomToken) + assertEquals("/api/v2/watch-together/rooms/$roomId", server.last.path) + + assertInvalidResponse(api(server).getRoom("another-room", "proof-1", scope)) + } + + @Test + fun `malformed success body is an invalid response, not a network error`() = runTest { + assertInvalidResponse(api(Server(body = """{"room":{}}""")).getRoom(roomId, "proof-1", scope)) + } + + // ---- host operations -------------------------------------------------------- + + @Test + fun `host operations authorize by identity and send no proof`() = runTest { + val server = Server(body = roomBody) + val api = api(server) + api.updatePolicy(roomId, UpdatePolicyRequest("guest_play_pause"), scope) + api.stageSelection(roomId, SetSelectionRequest("movie:heat-1995", fileId = 42, libraryId = 7), scope) + api.startPlayback(roomId, scope) + api.stopPlayback(roomId, scope) + api.setSelectionMode(roomId, SelectionModeRequest("vote"), scope) + api.setSelection(roomId, SetSelectionRequest("movie:heat-1995"), scope) + + assertEquals( + listOf( + HttpMethod.Patch to "/policy", + HttpMethod.Put to "/staged-selection", + HttpMethod.Post to "/playback/start", + HttpMethod.Post to "/playback/stop", + HttpMethod.Patch to "/selection-mode", + HttpMethod.Put to "/selection", + ), + server.calls.map { it.method to it.path.removePrefix("/api/v2/watch-together/rooms/$roomId") }, ) - assertEquals(HttpMethod.Patch, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/policy", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) + assertTrue(server.calls.all { it.roomToken == null && it.pinnedScope == scope }) } @Test - fun `closeRoom deletes and maps 204 to Unit`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - val r = api.closeRoom("room-1", "jwt-room", scope) - assertEquals(HttpMethod.Delete, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - assertEquals(ApiResult.Success(Unit), r) + fun `stage sends string file and library ids`() = runTest { + val server = Server(body = roomBody) + api(server).stageSelection(roomId, SetSelectionRequest("movie:heat-1995", fileId = 42, libraryId = 7), scope) + val body = sent(server.last) + assertEquals("42", body.getValue("file_id").jsonPrimitive.content) + assertTrue(body.getValue("file_id").jsonPrimitive.isString) + assertEquals("7", body.getValue("library_id").jsonPrimitive.content) } @Test - fun `listSuggestions decodes v2 items envelope`() = runTest { - val (api, captured) = api(responseBody = suggestionsJson) - val r = api.listSuggestions("room-1", "jwt-room", scope) - assertEquals(HttpMethod.Get, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - val list = assertIs>(r).data as org.siloserver.silo.model.watchtogether.SuggestionsResponse - assertEquals("sug-5", list.suggestions.single().id) - assertEquals("7", list.suggestions.single().suggesterUserId) + fun `only non-retryable operations skip the authentication replay`() = runTest { + val server = Server(body = roomBody) + val api = api(server) + api.startPlayback(roomId, scope) + api.setSelection(roomId, SetSelectionRequest("movie:heat-1995"), scope) + api.promoteSuggestion(roomId, "proof-1", PromoteSuggestionRequest("b2a1"), scope) + api.stopPlayback(roomId, scope) + api.stageSelection(roomId, SetSelectionRequest("movie:heat-1995"), scope) + api.getRoom(roomId, "proof-1", scope) + + assertEquals(listOf(true, true, true, false, false, false), server.calls.map { it.singleAttempt }) } @Test - fun `addSuggestion posts client suggestion_id then reads the list`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Created, - responseBody = """{"suggestion_id":"generated"}""", - ) - val r = api.addSuggestion( - "room-1", "jwt-room", - AddSuggestionRequest(contentId = "c", contentType = "movie", title = "T"), + fun `end requires 204`() = runTest { + val server = Server(HttpStatusCode.NoContent, "") + assertIs>(api(server).closeRoom(roomId, scope)) + assertEquals(HttpMethod.Delete, server.last.method) + assertNull(server.last.roomToken) + } + + @Test + fun `source fallback sends the fence and reason with proof`() = runTest { + val server = Server(body = roomBody) + api(server).sourceFallback( + roomId, + "proof-1", + SourceFallbackRequest(selectionRevision = 3, failedFileId = "42", reason = "hdr_transcode_unsupported"), scope, ) - assertEquals(2, captured.calls.size) - assertEquals(HttpMethod.Post, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - val sent = SiloJson.parseToJsonElement(captured.first.body).jsonObject - assertEquals(setOf("content_id", "content_type", "title", "suggestion_id"), sent.keys) - assertEquals(HttpMethod.Get, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions", captured.last.path) - assertEquals(1, assertIs>(r).let { (it.data as org.siloserver.silo.model.watchtogether.SuggestionsResponse).suggestions.size }) + assertEquals("proof-1", server.last.roomToken) + assertEquals("/api/v2/watch-together/rooms/$roomId/source-fallback", server.last.path) + val body = sent(server.last) + assertEquals("3", body.getValue("selection_revision").jsonPrimitive.content) + assertEquals("42", body.getValue("failed_file_id").jsonPrimitive.content) + assertEquals("hdr_transcode_unsupported", body.getValue("reason").jsonPrimitive.content) } @Test - fun `deleteSuggestion deletes then reads the list`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - val r = api.deleteSuggestion("room-1", "jwt-room", "sug-5", scope) - assertEquals(HttpMethod.Delete, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/sug-5", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - assertEquals(HttpMethod.Get, captured.last.method) - assertIs>(r) + fun `problem responses keep status and code`() = runTest { + val problem = """{"type":"https://silo.example/problems/conflict","title":"Conflict","status":409,"detail":"The room is closed."}""" + val result = api(Server(HttpStatusCode.Conflict, problem)).startPlayback(roomId, scope) + assertIs(result) + assertEquals(409, result.code) + assertEquals("conflict", result.error) } - @Test - fun `room and suggestion identifiers are encoded as path segments`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") + // ---- suggestions ------------------------------------------------------------- - api.deleteSuggestion("room/with ?#", "jwt-room", "suggestion/with ?#", scope) + @Test + fun `suggestion page passes limit and cursor and decodes page metadata`() = runTest { + val server = Server(body = readWatchPartyFixture("suggestions_page.json")) + val result = api(server).listSuggestions(roomId, "proof-1", scope, cursor = "abc") - assertEquals( - "/api/v2/watch-together/rooms/room%2Fwith%20%3F%23/suggestions/" + - "suggestion%2Fwith%20%3F%23", - captured.first.path, - ) + assertIs>(result) + assertEquals(mapOf("limit" to "50", "cursor" to "abc"), server.last.query) + assertEquals("proof-1", server.last.roomToken) + assertEquals(true, result.data.page?.hasMore) + assertEquals("c2VlZDoy", result.data.page?.nextCursor) + assertTrue(result.data.suggestions.single().votedByMe) } @Test - fun `vote posts vote path then reads the list`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - api.vote("room-1", "jwt-room", "sug-5", scope) - assertEquals(HttpMethod.Post, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/sug-5/vote", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - assertEquals(HttpMethod.Get, captured.last.method) + fun `a page with more items but no cursor is an invalid response`() = runTest { + val body = """{"items":[],"page":{"has_more":true}}""" + assertInvalidResponse(api(Server(body = body)).listSuggestions(roomId, "proof-1", scope)) } @Test - fun `unvote deletes vote path then reads the list`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - api.unvote("room-1", "jwt-room", "sug-5", scope) - assertEquals(HttpMethod.Delete, captured.first.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/sug-5/vote", captured.first.path) - assertEquals("jwt-room", captured.first.roomToken) - assertEquals(HttpMethod.Get, captured.last.method) + fun `add suggestion sends the caller id and checks the receipt`() = runTest { + val request = AddSuggestionRequest(suggestionId = "s-1", contentId = "movie:heat-1995", contentType = "movie", title = "Heat") + val server = Server(HttpStatusCode.Created, """{"suggestion_id":"s-1"}""") + assertIs>(api(server).addSuggestion(roomId, "proof-1", request, scope)) + assertEquals("s-1", sent(server.last).getValue("suggestion_id").jsonPrimitive.content) + assertEquals(1, server.calls.size, "a mutation must not read the list itself") + + server.body = """{"suggestion_id":"s-2"}""" + assertInvalidResponse(api(server).addSuggestion(roomId, "proof-1", request, scope)) } @Test - fun `promote posts suggestion_id with X-Room-Token and decodes room`() = runTest { - val (api, captured) = api(responseBody = """{"room":$roomJson,"room_access_token":"jwt-room"}""") - api.promoteSuggestion( - "room-1", - "jwt-room", - PromoteSuggestionRequest(suggestionId = "sug-5"), - scope, + fun `votes return an empty receipt and never read the list`() = runTest { + val server = Server(HttpStatusCode.NoContent, "") + val api = api(server) + assertIs>(api.vote(roomId, "proof-1", "b2a1", scope)) + assertIs>(api.unvote(roomId, "proof-1", "b2a1", scope)) + assertIs>(api.deleteSuggestion(roomId, "proof-1", "b2a1", scope)) + assertEquals( + listOf(HttpMethod.Post, HttpMethod.Delete, HttpMethod.Delete), + server.calls.map { it.method }, ) - assertEquals(HttpMethod.Post, captured.last.method) - assertEquals("/api/v2/watch-together/rooms/room-1/suggestions/promote", captured.last.path) - assertEquals("jwt-room", captured.last.roomToken) - val sent = SiloJson.parseToJsonElement(captured.last.body).jsonObject - assertEquals("sug-5", sent.getValue("suggestion_id").jsonPrimitive.content) + assertEquals("/api/v2/watch-together/rooms/$roomId/suggestions/b2a1/vote", server.calls[0].path) } + // ---- shared browsing --------------------------------------------------------- + @Test - fun `vote problem surfaces as ApiResult Error without a follow-up read`() = runTest { - val (api, captured) = api(status = HttpStatusCode.Conflict, responseBody = problem) - val r = api.vote("room-1", "jwt-room", "sug-5", scope) - assertIs(r) - assertEquals(409, r.code) - assertEquals("conflict", r.error) - assertEquals("Already voted", r.message) - assertEquals(1, captured.calls.size) + fun `member state rejects an empty id list before sending`() = runTest { + val server = Server(body = readWatchPartyFixture("member_state.json")) + val empty = api(server).memberState(roomId, "proof-1", MemberStateRequest(emptyList()), scope) + assertIs(empty) + assertEquals(422, empty.code) + assertTrue(server.calls.isEmpty()) } @Test - fun `join 410 problem surfaces as ApiResult Error`() = runTest { - val (api, _) = api( - status = HttpStatusCode.Gone, - responseBody = """{"type":"https://silo.example/problems/gone","title":"Gone","status":410,"detail":"Room is no longer active"}""", - ) - val r = api.joinRoom(JoinRoomRequest(code = "DEAD0000"), scope) - assertIs(r) - assertEquals(410, r.code) - assertEquals("Room is no longer active", r.message) + fun `member state decodes and rejects unrequested items`() = runTest { + val server = Server(body = readWatchPartyFixture("member_state.json")) + val result = api(server).memberState(roomId, "proof-1", MemberStateRequest(listOf("movie:heat-1995")), scope) + assertIs>(result) + assertEquals("in_progress", result.data.items.single().members.single().state) + + assertInvalidResponse(api(server).memberState(roomId, "proof-1", MemberStateRequest(listOf("movie:other")), scope)) + } + + @Test + fun `picker decodes shared rows with next up`() = runTest { + val server = Server(body = readWatchPartyFixture("picker.json")) + val result = api(server).picker(roomId, "proof-1", scope) + assertIs>(result) + assertTrue(result.data.continueTogether.isEmpty()) + val row = result.data.watchlistUnion.single() + assertEquals("series:the-bear", row.item.contentId) + assertEquals("episode:the-bear-s02e06", row.nextUp?.contentId) + assertEquals("proof-1", server.last.roomToken) + } + + @Test + fun `capabilities decode the vendored server fixture`() = runTest { + val server = Server(body = readWatchPartyFixture("capabilities.json")) + val result = api(server).capabilities(scope) + assertIs>(result) + assertEquals("silo.room.v2", result.data.socketProtocol) + assertEquals(200, result.data.maxMemberStateIds) + assertEquals(false, result.data.allowed) + assertEquals("/api/v2/watch-together/capabilities", server.last.path) } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/WatchTogetherRepositoryTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/WatchTogetherRepositoryTest.kt index 44a066cda1..359b005675 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/WatchTogetherRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/WatchTogetherRepositoryTest.kt @@ -4,35 +4,51 @@ import org.siloserver.silo.model.watchtogether.AddSuggestionRequest import org.siloserver.silo.model.watchtogether.CreateRoomRequest import org.siloserver.silo.model.watchtogether.JoinRoomRequest import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.MemberStateRequest +import org.siloserver.silo.model.watchtogether.MemberStateResponse +import org.siloserver.silo.model.watchtogether.PickerResponse import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest +import org.siloserver.silo.model.watchtogether.RoomPhase import org.siloserver.silo.model.watchtogether.RoomResponse import org.siloserver.silo.model.watchtogether.RoomSelectionMode import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.SelectionModeRequest import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.model.watchtogether.SourceFallbackRequest import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.model.watchtogether.SuggestionPageInfo +import org.siloserver.silo.model.watchtogether.SuggestionReceipt import org.siloserver.silo.model.watchtogether.SuggestionsResponse +import org.siloserver.silo.model.watchtogether.TransportAction +import org.siloserver.silo.model.watchtogether.TransportCommand import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest +import org.siloserver.silo.model.watchtogether.WatchTogetherCapabilitiesV2 import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.DefaultIdentityTransitionBarrier import org.siloserver.silo.network.IdentityTransitionKind import org.siloserver.silo.network.RoomRealtimeEvent +import org.siloserver.silo.network.RoomTicketExpiringException +import org.siloserver.silo.network.RoomTicketRefusedException import org.siloserver.silo.network.WatchTogetherRealtimeClient import org.siloserver.silo.network.api.WatchTogetherApi import org.siloserver.silo.watchtogether.RoomSession import org.siloserver.silo.watchtogether.RoomTransportIntent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -47,12 +63,28 @@ class WatchTogetherRepositoryTest { private fun snapshot( roomId: String = "room-1", revision: Long = 1L, + generation: Long = 1L, memberCount: Int = 1, - ) = RoomSnapshot(roomId = roomId, selectionRevision = revision, memberCount = memberCount, code = "ABCD1234") + phase: RoomPhase = RoomPhase.Lobby, + attachedSessionId: String? = null, + ) = RoomSnapshot( + roomId = roomId, + selectionRevision = revision, + generation = generation, + memberCount = memberCount, + code = "ABCD1234", + phase = phase, + attachedSessionId = attachedSessionId, + ) - private fun suggestion(id: String, voteCount: Int = 0, votedByMe: Boolean = false) = Suggestion( + private fun suggestion( + id: String, + voteCount: Int = 0, + votedByMe: Boolean = false, + createdAt: String = "2026-06-12T08:00:00Z", + ) = Suggestion( id = id, roomId = "room-1", contentId = "c-$id", contentType = "movie", - title = "T-$id", voteCount = voteCount, votedByMe = votedByMe, createdAt = "2026-06-12T08:00:00Z", + title = "T-$id", voteCount = voteCount, votedByMe = votedByMe, createdAt = createdAt, ) private class FakeApi( @@ -60,109 +92,118 @@ 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 + val calls = mutableListOf() var lastRoomToken: String? = null var lastRoomId: String? = null var lastSelection: SetSelectionRequest? = null var lastAuthScope: AuthScopeSnapshot? = null var createResult: CompletableDeferred>? = null var selectionResult: CompletableDeferred>? = null - var listSuggestionsResponse: ApiResult = - ApiResult.Success(SuggestionsResponse()) + var roomReadResult: CompletableDeferred>? = null + var roomReadResponse: ApiResult? = null + var mutationResponse: ApiResult? = null + var voteResponse: ApiResult = ApiResult.Success(Unit) + var listSuggestionPages: MutableList> = mutableListOf() + var listSuggestionsResponse: ApiResult = ApiResult.Success(SuggestionsResponse()) var listSuggestionsResult: CompletableDeferred>? = null - var listSuggestionsCalls = 0 - override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult { - createCalls++ + val listCursors = mutableListOf() + + fun count(name: String) = calls.count { it == name } + + private fun record(name: String, roomId: String?, token: String?, scope: AuthScopeSnapshot) { + calls += name + roomId?.let { lastRoomId = it } + lastRoomToken = token lastAuthScope = scope + } + + private fun mutation(): ApiResult = mutationResponse ?: createResponse + + override suspend fun capabilities(scope: AuthScopeSnapshot) = + ApiResult.Success(WatchTogetherCapabilitiesV2()) + + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult { + record("create", null, null, scope) return createResult?.await() ?: createResponse } - override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = - createResponse.also { lastAuthScope = scope } - override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = - createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult { + record("join", null, null, scope) + return createResponse + } + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot): ApiResult { + record("get", roomId, roomToken, scope) + return roomReadResult?.await() ?: roomReadResponse ?: createResponse + } + override suspend fun updatePolicy(roomId: String, request: UpdatePolicyRequest, scope: AuthScopeSnapshot) = + mutation().also { record("policy", roomId, null, scope) } + override suspend fun stageSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot) = + mutation().also { record("stage", roomId, null, scope) } + override suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot) = + mutation().also { record("start", roomId, null, scope) } + override suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot) = + mutation().also { record("stop", roomId, null, scope) } + override suspend fun setSelectionMode(roomId: String, request: SelectionModeRequest, scope: AuthScopeSnapshot) = + mutation().also { record("mode", roomId, null, scope) } override suspend fun setSelection( roomId: String, - roomToken: String, request: SetSelectionRequest, scope: AuthScopeSnapshot, ): ApiResult { - lastRoomId = roomId - lastRoomToken = roomToken + record("select", roomId, null, scope) lastSelection = request - lastAuthScope = scope - return selectionResult?.await() ?: createResponse + return selectionResult?.await() ?: mutation() + } + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult { + record("close", roomId, null, scope) + return ApiResult.Success(Unit) } - override suspend fun updatePolicy( + override suspend fun sourceFallback( roomId: String, roomToken: String, - request: UpdatePolicyRequest, + request: SourceFallbackRequest, scope: AuthScopeSnapshot, - ) = createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } - override suspend fun closeRoom( + ) = mutation().also { record("fallback", roomId, roomToken, scope) } + override suspend fun listSuggestions( roomId: String, roomToken: String, scope: AuthScopeSnapshot, - ): ApiResult { - closeCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - return ApiResult.Success(Unit) + cursor: String?, + limit: Int, + ): ApiResult { + record("list", roomId, roomToken, scope) + listCursors += cursor + listSuggestionsResult?.let { return it.await() } + return if (listSuggestionPages.isNotEmpty()) listSuggestionPages.removeAt(0) else listSuggestionsResponse } - override suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = - (listSuggestionsResult?.await() ?: listSuggestionsResponse).also { - listSuggestionsCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - } 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 deleteSuggestion( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } - override suspend fun vote( - roomId: String, - roomToken: String, - suggestionId: String, - scope: AuthScopeSnapshot, - ): ApiResult { - voteCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - return ApiResult.Success(SuggestionsResponse()) + ): ApiResult { + record("suggest", roomId, roomToken, scope) + return ApiResult.Success(SuggestionReceipt(request.suggestionId)) } - override suspend fun unvote( + override suspend fun deleteSuggestion(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = + ApiResult.Success(Unit).also { record("delete", roomId, roomToken, scope) } + override suspend fun vote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = + voteResponse.also { record("vote", roomId, roomToken, scope) } + override suspend fun unvote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = + voteResponse.also { record("unvote", roomId, roomToken, scope) } + override suspend fun promoteSuggestion( roomId: String, roomToken: String, - suggestionId: String, + request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } - override suspend fun promoteSuggestion( + ) = mutation().also { record("promote", roomId, roomToken, scope) } + override suspend fun memberState( roomId: String, roomToken: String, - request: PromoteSuggestionRequest, + request: MemberStateRequest, scope: AuthScopeSnapshot, - ): ApiResult { - promoteCalls++ - lastRoomToken = roomToken - lastAuthScope = scope - return createResponse - } + ) = ApiResult.Success(MemberStateResponse()).also { record("memberState", roomId, roomToken, scope) } + override suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + ApiResult.Success(PickerResponse()).also { record("picker", roomId, roomToken, scope) } } private class FakeRealtime( @@ -174,17 +215,18 @@ class WatchTogetherRepositoryTest { var terminateImmediately = false var attachCount = 0 val transportActions = mutableListOf() - val readySessions = mutableListOf() + val readyCommands = mutableListOf() val bufferingSessions = mutableListOf() + val lobbyReady = mutableListOf() + var pings = 0 var transportResultGate: CompletableDeferred? = null var connectBehavior: ((Int) -> Flow)? = null - /** - * When non-null, each connect attempt emits this one event and then throws. - * Models a flapping server: healthy traffic is observed but the connection - * always drops — the classic scenario that could bypass the failure cap if - * failures were reset per-event rather than per-clean-completion. - */ + /** Each attempt emits this one event and then throws: a flapping server. */ var flappingEvent: RoomRealtimeEvent? = null + val connectedRoomIds = mutableListOf() + val connectedRoomTokens = mutableListOf() + val connectedAuthScopes = mutableListOf() + override fun connect( roomId: String, roomToken: String, @@ -202,14 +244,12 @@ class WatchTogetherRepositoryTest { } val flapEvent = flappingEvent if (flapEvent != null) return flow { + emit(RoomRealtimeEvent.Opened) emit(flapEvent) throw IllegalStateException("connection dropped after event") } return events.asSharedFlow() } - val connectedRoomIds = mutableListOf() - val connectedRoomTokens = mutableListOf() - val connectedAuthScopes = mutableListOf() override suspend fun attachSession(sessionId: String): Boolean { attachCount++ return true @@ -229,16 +269,29 @@ class WatchTogetherRepositoryTest { transportActions += action return true } - override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean) = true - override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean { - readySessions += sessionId + override suspend fun stateReport( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + commandId: String?, + isReady: Boolean, + ) = true + override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?): Boolean { + readyCommands += commandId return true } override suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean): Boolean { bufferingSessions += sessionId return true } - override suspend fun ping(clientSentAt: String) = true + override suspend fun lobbyReady(ready: Boolean): Boolean { + lobbyReady += ready + return true + } + override suspend fun ping(clientSentAt: String): Boolean { + pings++ + return true + } } private val scopeA = AuthScopeSnapshot( @@ -249,256 +302,83 @@ class WatchTogetherRepositoryTest { identityGeneration = 1L, ) - private fun repo( + /** Jitter-free, background-work-free timing so tests control every delay. */ + private val testTiming = WatchPartyTiming(backgroundWork = false) + private object NoJitter : Random() { + override fun nextBits(bitCount: Int): Int = 0 + } + + private fun TestScope.repo( api: FakeApi = FakeApi(), realtime: FakeRealtime = FakeRealtime(), authScopeProvider: suspend () -> AuthScopeSnapshot? = { scopeA }, + now: () -> Long = { testScheduler.currentTime }, + timing: WatchPartyTiming = testTiming, + realtimeFactory: (() -> WatchTogetherRealtimeClient?)? = null, ) = WatchTogetherRepository( api = api, - realtimeFactory = { realtime }, + realtimeFactory = realtimeFactory ?: { realtime }, + monotonicNowMs = now, authScopeProvider = authScopeProvider, + wallClockMs = { 1_000_000L + testScheduler.currentTime }, + timing = timing, + random = NoJitter, ) - // ---- create stores room token ------------------------------------------- + private fun create() = CreateRoomRequest(roomId = "room-1") + + private fun room(snapshot: RoomSnapshot, token: String = "jwt-room") = ApiResult.Success(RoomResponse(snapshot, token)) + + private val firstBackoff = testTiming.backoffMs.first() + + // ---- create / join / proof ---------------------------------------------- @Test - fun `create stores room token used by subsequent room-scoped calls`() = runTest { + fun `create stores the proof used by member calls and keeps host calls proof-free`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest(selectionMode = "vote")) - r.setSelection(SetSelectionRequest(contentId = "tt-9")) + r.createRoom(create()) + r.promoteSuggestion("s-1") assertEquals("jwt-room", api.lastRoomToken) + r.setSelection(SetSelectionRequest(contentId = "tt-9")) + assertNull(api.lastRoomToken) 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 { - listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse(suggestions = listOf(suggestion("existing"))), - ) - } - val repository = repo(api = api) - - val result = repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) - - assertIs>(result) - assertEquals(1, api.listSuggestionsCalls) - assertEquals(listOf("existing"), repository.suggestions.value.map { it.id }) - } - - @Test - fun `join returns obsolete when its hydration lease is replaced by another room`() = runTest { - val hydration = CompletableDeferred>() - val api = FakeApi().apply { listSuggestionsResult = hydration } - val repository = repo(api = api) - val joiningA = async { repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) } - runCurrent() - - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - assertIs>(repository.createRoom(CreateRoomRequest())) - hydration.complete(ApiResult.Success(SuggestionsResponse(suggestions = listOf(suggestion("stale"))))) - - val staleResult = assertIs(joiningA.await()) - assertEquals("obsolete_room_request", staleResult.error) - assertEquals("room-2", repository.roomSnapshot.value?.roomId) - assertTrue(repository.suggestions.value.isEmpty()) - } - - @Test - fun `join returns obsolete when a newer room request starts during hydration`() = runTest { - val hydration = CompletableDeferred>() - val newerCreate = CompletableDeferred>() - val api = FakeApi().apply { - listSuggestionsResult = hydration - createResult = newerCreate - } - val repository = repo(api = api) - val joiningA = async { repository.joinRoom(JoinRoomRequest(code = "ABCD1234")) } - runCurrent() - val creatingB = async { repository.createRoom(CreateRoomRequest()) } - runCurrent() - - hydration.complete(ApiResult.Success(SuggestionsResponse(suggestions = listOf(suggestion("stale"))))) - - val staleResult = assertIs(joiningA.await()) - assertEquals("obsolete_room_request", staleResult.error) - assertTrue(repository.suggestions.value.isEmpty()) - newerCreate.complete( - ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ), - ) - assertIs>(creatingB.await()) - } - - @Test - fun `transport lease cannot send into a replacement room`() = runTest { + fun `every successful room response renews the proof without changing membership`() = runTest { val api = FakeApi() - 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() - val authorizationA = requireNotNull(repository.currentTransportAuthorization()) + val r = repo(api = api) + r.createRoom(create()) + val membership = r.membershipGeneration() - api.createResponse = ApiResult.Success( - RoomResponse( - RoomSnapshot( - roomId = "room-2", - code = "EFGH5678", - phase = org.siloserver.silo.model.watchtogether.RoomPhase.Playing, - selfCanControlTransport = true, - ), - "jwt-room-2", - ), - ) - repository.createRoom(CreateRoomRequest()) + api.mutationResponse = room(snapshot(generation = 2), token = "jwt-renewed") + r.stageSelection(SetSelectionRequest(contentId = "movie:a")) + r.promoteSuggestion("s-1") - assertFalse( - repository.transportRequestForAuthorization( - authorization = authorizationA, - intent = RoomTransportIntent.PlayPause, - action = "pause", - positionSeconds = 42.0, - isPaused = true, - ), - ) - assertTrue(realtime.transportActions.isEmpty()) - connection.cancel() + assertEquals("jwt-renewed", api.lastRoomToken) + assertEquals(membership, r.membershipGeneration()) } @Test - fun `transport lease revalidates authority after policy changes in the same room`() = runTest { - val api = FakeApi().apply { - createResponse = ApiResult.Success( - RoomResponse( - RoomSnapshot( - roomId = "room-1", - code = "ABCD1234", - phase = org.siloserver.silo.model.watchtogether.RoomPhase.Playing, - selfCanControlTransport = true, - ), - "jwt-room", - ), - ) - } + fun `room read renews proof even when its snapshot is stale`() = runTest { + val api = FakeApi() 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() - val authorizationBeforePolicyChange = requireNotNull(repository.currentTransportAuthorization()) - realtime.events.emit( - RoomRealtimeEvent.SnapshotEvent( - api.createResponse.let { (it as ApiResult.Success).data.room.copy(selfCanControlTransport = false) }, - ), - ) - runCurrent() - - assertFalse( - repository.transportRequestForAuthorization( - authorization = authorizationBeforePolicyChange, - intent = RoomTransportIntent.PlayPause, - action = "pause", - positionSeconds = 42.0, - isPaused = true, - ), - ) - assertTrue(realtime.transportActions.isEmpty()) - connection.cancel() - } - - @Test - fun `blocked physical send does not hold room replacement or reset mutex`() = runTest { - val api = FakeApi().apply { - createResponse = ApiResult.Success( - RoomResponse( - RoomSnapshot( - roomId = "room-1", - code = "ABCD1234", - phase = org.siloserver.silo.model.watchtogether.RoomPhase.Playing, - selfCanControlTransport = true, - ), - "jwt-room", - ), - ) - } - val realtime = FakeRealtime().apply { - transportResultGate = CompletableDeferred() - } - val repository = repo(api = api, realtime = realtime) - repository.createRoom(CreateRoomRequest()) - val connection = launch { repository.connect("room-1") } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 5))) runCurrent() - val authorization = requireNotNull(repository.currentTransportAuthorization()) - val blockedSend = async { - repository.transportRequestForAuthorization( - authorization = authorization, - intent = RoomTransportIntent.PlayPause, - action = "pause", - positionSeconds = 42.0, - isPaused = true, - ) - } - runCurrent() - val reset = async { repository.reset() } - runCurrent() + api.roomReadResponse = room(snapshot(generation = 3), token = "jwt-fresh") + r.refreshRoom() - assertTrue(reset.isCompleted) - assertFalse(blockedSend.isCompleted) - realtime.transportResultGate?.complete(false) - assertFalse(blockedSend.await()) - connection.cancel() + assertEquals(5L, r.roomSnapshot.value?.generation) + r.promoteSuggestion("s-1") + assertEquals("jwt-fresh", api.lastRoomToken) + job.cancel() } @Test @@ -506,57 +386,14 @@ class WatchTogetherRepositoryTest { val api = FakeApi() var currentScope = scopeA val r = repo(api = api, authScopeProvider = { currentScope }) - r.createRoom(CreateRoomRequest()) - currentScope = scopeA.copy( - serverId = "server-b", - serverUrl = "https://b.example", - identityGeneration = 2L, - ) + r.createRoom(create()) + currentScope = scopeA.copy(serverId = "server-b", serverUrl = "https://b.example", identityGeneration = 2L) r.setSelection(SetSelectionRequest(contentId = "tt-9")) assertEquals(scopeA, api.lastAuthScope) } - @Test - fun `realtime reconnect uses the auth scope captured by the room binding`() = runTest { - val realtime = FakeRealtime() - var currentScope = scopeA - val r = repo(realtime = realtime, authScopeProvider = { currentScope }) - r.createRoom(CreateRoomRequest()) - currentScope = scopeA.copy( - serverId = "server-b", - serverUrl = "https://b.example", - identityGeneration = 2L, - ) - - val connection = launch { r.connect("room-1") } - runCurrent() - - assertEquals(1, realtime.connectedAuthScopes.size) - assertEquals(scopeA, realtime.connectedAuthScopes.single()) - connection.cancel() - } - - @Test - fun `every reconnect attempt retains the room auth scope`() = runTest { - val realtime = FakeRealtime().apply { - connectBehavior = { attempt -> - if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() - } - } - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val connection = launch { r.connect("room-1") } - runCurrent() - advanceTimeBy(WatchTogetherRepository.BACKOFF_MS.first()) - runCurrent() - - assertEquals(2, realtime.connectedAuthScopes.size) - assertTrue(realtime.connectedAuthScopes.all { it == scopeA }) - connection.cancel() - } - @Test fun `create response cannot mint a room after the auth identity changes`() = runTest { val api = FakeApi() @@ -564,7 +401,7 @@ class WatchTogetherRepositoryTest { api.createResult = pending var currentScope = scopeA val r = repo(api = api, authScopeProvider = { currentScope }) - val create = launch { r.createRoom(CreateRoomRequest()) } + val create = launch { r.createRoom(create()) } runCurrent() currentScope = scopeA.copy(identityGeneration = 2L) @@ -583,9 +420,7 @@ class WatchTogetherRepositoryTest { val barrier = DefaultIdentityTransitionBarrier() RoomSession(repository, backgroundScope, barrier) var createResult: ApiResult? = null - val create = launch { - createResult = repository.createRoom(CreateRoomRequest()) - } + val create = launch { createResult = repository.createRoom(create()) } runCurrent() barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { @@ -602,496 +437,688 @@ class WatchTogetherRepositoryTest { fun `blank room token fails closed without replacing the active room`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), ""), - ) - - val result = r.createRoom(CreateRoomRequest()) + r.createRoom(create()) + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), token = "") - assertIs(result) + assertIs(r.createRoom(create())) assertEquals("room-1", r.roomSnapshot.value?.roomId) - r.setSelection(SetSelectionRequest(contentId = "still-a")) + r.promoteSuggestion("still-a") assertEquals("room-1", api.lastRoomId) assertEquals("jwt-room", api.lastRoomToken) } - @Test - fun `mismatched room response fails closed without replacing the active room`() = runTest { - val api = FakeApi() - val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "", code = "EFGH5678"), "jwt-room-2"), - ) - - val result = r.createRoom(CreateRoomRequest()) - - assertIs(result) - assertEquals("room-1", r.roomSnapshot.value?.roomId) - } - @Test fun `room scoped response for another room is rejected`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) + r.createRoom(create()) + api.mutationResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") - val result = r.setSelection(SetSelectionRequest(contentId = "wrong-room")) - - assertIs(result) + assertIs(r.setSelection(SetSelectionRequest(contentId = "wrong-room"))) assertEquals("room-1", r.roomSnapshot.value?.roomId) } @Test fun `create fails closed when there is no authenticated scope`() = runTest { val r = repo(authScopeProvider = { null }) - - val result = r.createRoom(CreateRoomRequest()) - - assertIs(result) + assertIs(r.createRoom(create())) assertNull(r.roomSnapshot.value) } @Test - fun `connect rejects a room id that does not match the active binding`() = runTest { + fun `a new membership clears the previous ended state`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - - val job = launch { r.connect("room-2") } + r.createRoom(create()) + val job = launch { r.connect("room-1") } runCurrent() + realtime.events.emit(RoomRealtimeEvent.ConnectionReplaced("elsewhere")) + job.join() + assertEquals(WatchPartyEndReason.Replaced, r.ended.value?.reason) - assertEquals(0, realtime.connectCount) - job.cancel() + r.joinRoom(JoinRoomRequest(code = "ABCD1234")) + + assertNull(r.ended.value) + assertNull(r.roomClosedReason.value) + assertEquals("room-1", r.roomSnapshot.value?.roomId) } + // ---- actions: serialization, uncertainty, reconciliation ------------------------ + @Test - fun `local delivery failure is surfaced as a non terminal room error`() = runTest { - val repository = repo() - val error = async { repository.errors.first() } - runCurrent() + fun `an uncertain start reconciles by reading the room and is never replayed`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = ApiResult.NetworkError(RuntimeException("reset")) + api.roomReadResponse = room(snapshot(generation = 4, phase = RoomPhase.Playing)) - repository.reportDeliveryFailure("room_transport_unavailable") + assertIs(r.startPlayback()) - assertEquals("room_transport_unavailable", error.await()) - assertNull(repository.roomClosedReason.value) + assertEquals(1, api.count("start")) + assertEquals(1, api.count("get")) + assertEquals(RoomPhase.Playing, r.roomSnapshot.value?.phase) } @Test - fun `controller recreation shares successful delivery state for the same epoch`() = runTest { - val repository = repo() - val key = repository.roomDeliveryLatch.keyOrNull( - WatchTogetherConnectionState(generation = 7, epoch = 2, writable = true), - "playback-1", - )!! - - repository.roomDeliveryLatch.recordAttach(key, delivered = true) - - assertTrue(repository.roomDeliveryLatch.isAttached(key)) - assertTrue(!repository.roomDeliveryLatch.needsAttach(key)) - } - - @Test - fun `two participants independently receive room state commands and terminal close`() = runTest { - val sharedSnapshot = snapshot(memberCount = 2) - val response = ApiResult.Success(RoomResponse(sharedSnapshot, "jwt-room")) - val hostRealtime = FakeRealtime() - val guestRealtime = FakeRealtime() - val host = repo(api = FakeApi(response), realtime = hostRealtime) - val guest = repo(api = FakeApi(response), realtime = guestRealtime) - host.createRoom(CreateRoomRequest()) - guest.createRoom(CreateRoomRequest()) - val hostCommand = async { host.transportCommands.first() } - val guestCommand = async { guest.transportCommands.first() } - val hostConnection = launch { host.connect("room-1") } - val guestConnection = launch { guest.connect("room-1") } - runCurrent() - val command = org.siloserver.silo.model.watchtogether.TransportCommand( - commandId = "cmd-1", - sessionId = "playback-1", - selectionRevision = sharedSnapshot.selectionRevision, - action = org.siloserver.silo.model.watchtogether.TransportAction.Seek, - positionSeconds = 42.0, - executeAt = "2026-07-27T12:00:00Z", - ) - - hostRealtime.events.emit(RoomRealtimeEvent.Opened) - guestRealtime.events.emit(RoomRealtimeEvent.Opened) - hostRealtime.events.emit(RoomRealtimeEvent.SnapshotEvent(sharedSnapshot)) - guestRealtime.events.emit(RoomRealtimeEvent.SnapshotEvent(sharedSnapshot)) - hostRealtime.events.emit(RoomRealtimeEvent.TransportCommandEvent(command)) - guestRealtime.events.emit(RoomRealtimeEvent.TransportCommandEvent(command)) - runCurrent() - - assertEquals(sharedSnapshot, host.roomSnapshot.value) - assertEquals(sharedSnapshot, guest.roomSnapshot.value) - val hostScheduled = hostCommand.await() - val guestScheduled = guestCommand.await() - assertEquals(command, hostScheduled.command) - assertEquals(command, guestScheduled.command) - assertEquals(host.connectionState.value, hostScheduled.connection) - assertEquals(guest.connectionState.value, guestScheduled.connection) - assertTrue(host.attachSession("host-playback")) - assertTrue(guest.attachSession("guest-playback")) - assertTrue(host.transportRequest("pause", 42.0, true)) - assertTrue(guest.transportRequest("play", 42.0, false)) - assertTrue(host.buffering("host-playback", 42.0, true)) - assertTrue(guest.ready("guest-playback", 42.0, false)) - assertEquals(listOf("pause"), hostRealtime.transportActions) - assertEquals(listOf("play"), guestRealtime.transportActions) - - hostRealtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - guestRealtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - hostConnection.join() - guestConnection.join() - - assertEquals("host_left", host.roomClosedReason.value) - assertEquals("host_left", guest.roomClosedReason.value) - assertEquals(1, hostRealtime.connectCount) - assertEquals(1, guestRealtime.connectCount) - } - - @Test - fun `late room A REST completion cannot overwrite replacement room B`() = runTest { + fun `a conflict on a host action is action-level when the room is still readable`() = runTest { val api = FakeApi() val r = repo(api = api) - r.createRoom(CreateRoomRequest()) - val pending = CompletableDeferred>() - api.selectionResult = pending - var staleResult: ApiResult? = null - val stale = launch { - staleResult = r.setSelection(SetSelectionRequest(contentId = "late-a")) - } - runCurrent() + r.createRoom(create()) + api.mutationResponse = ApiResult.Error(409, "conflict", "Nothing is staged.") - api.selectionResult = null - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - r.createRoom(CreateRoomRequest()) - pending.complete( - ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-1", selectionRevision = 99), "jwt-room"), - ), - ) - stale.join() + assertIs(r.startPlayback()) - assertEquals("room-2", r.roomSnapshot.value?.roomId) - assertIs(staleResult) + assertEquals(1, api.count("get")) + assertNull(r.roomClosedReason.value) + assertEquals("room-1", r.roomSnapshot.value?.roomId) } @Test - fun `late room A socket event cannot overwrite replacement room B`() = runTest { + fun `a conflict confirmed by the room read ends the engagement`() = runTest { val api = FakeApi() - val realtime = FakeRealtime() - val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) - val oldConnection = launch { r.connect("room-1") } - runCurrent() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = ApiResult.Error(409, "conflict", "The room is closed.") + api.roomReadResponse = ApiResult.Error(409, "conflict", "The room is closed.") - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - r.createRoom(CreateRoomRequest()) - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-1", revision = 99))) - advanceUntilIdle() + r.stageSelection(SetSelectionRequest(contentId = "movie:a")) - assertEquals("room-2", r.roomSnapshot.value?.roomId) - assertTrue(oldConnection.isCompleted) - assertTrue(!r.attachSession("session-b")) - assertEquals(0, realtime.attachCount) + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) + assertNull(r.roomSnapshot.value) + assertNull(r.membershipGeneration()) } - // ---- snapshot fold -------------------------------------------------------- + @Test + fun `an ended snapshot in a response is terminal`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = room(snapshot(generation = 9, phase = RoomPhase.Ended)) + + assertIs(r.stopPlayback()) + + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) + assertNull(r.roomSnapshot.value) + } @Test - fun `snapshot event publishes room snapshot`() = runTest { - val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(revision = 5, memberCount = 3))) - advanceUntilIdle() - assertEquals(5L, r.roomSnapshot.value?.selectionRevision) - assertEquals(3, r.roomSnapshot.value?.memberCount) - job.cancel() + fun `ending an already ended room converges on the ended state`() = runTest { + val api = object : WatchTogetherApi by FakeApi() { + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot): ApiResult = + ApiResult.Error(409, "conflict", "The room is closed.") + } + val r = WatchTogetherRepository(api = api, authScopeProvider = { scopeA }, timing = testTiming) + r.createRoom(create()) + + assertIs>(r.closeRoom()) + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) } @Test - fun `snapshot for another room cannot replace the active room`() = runTest { - val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } + fun `actions run one at a time and expose the pending action`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + val gate = CompletableDeferred>() + api.selectionResult = gate + + val first = async { r.setSelection(SetSelectionRequest(contentId = "movie:a")) } + runCurrent() + assertEquals(WatchPartyPendingAction.Select, r.pendingAction.value) + val second = async { r.stopPlayback() } runCurrent() + assertEquals(0, api.count("stop")) - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-2", revision = 9))) + gate.complete(api.createResponse) + first.await() + second.await() + assertEquals(listOf("select", "stop"), api.calls.filter { it == "select" || it == "stop" }) + assertNull(r.pendingAction.value) + } + + @Test + fun `a late REST completion cannot publish after leaving`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + val pending = CompletableDeferred>() + api.selectionResult = pending + var staleResult: ApiResult? = null + val stale = launch { staleResult = r.setSelection(SetSelectionRequest(contentId = "late-a")) } runCurrent() - assertEquals("room-1", r.roomSnapshot.value?.roomId) - job.cancel() + r.reset() + pending.complete(room(RoomSnapshot(roomId = "room-1", selectionRevision = 99, generation = 9))) + stale.join() + + assertEquals("obsolete_room_request", assertIs(staleResult).error) + assertNull(r.roomSnapshot.value) + + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) + assertEquals("room-2", r.roomSnapshot.value?.roomId) } - // ---- suggestions fold + voted_by_me re-merge ------------------------------ + // ---- snapshot ordering ------------------------------------------------------- @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"))), - ) - } + fun `an equal-generation HTTP response cannot erase a newer socket attachment`() = runTest { + val api = FakeApi() val realtime = FakeRealtime() - val repository = repo(api = api, realtime = realtime) - repository.createRoom(CreateRoomRequest()) - val connection = launch { repository.connect("room-1") } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } runCurrent() - realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 3))) runCurrent() - assertEquals(1, api.listSuggestionsCalls) - assertEquals(listOf("before-drop"), repository.suggestions.value.map { it.id }) - realtime.events.emit(RoomRealtimeEvent.TransportTerminated()) + val gate = CompletableDeferred>() + api.roomReadResult = gate + val read = async { r.refreshRoom() } runCurrent() - api.listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse( - suggestions = listOf( - suggestion("before-drop"), - suggestion("missed-during-drop"), - ), - ), - ) - advanceTimeBy(WatchTogetherRepository.BACKOFF_MS.first()) + // The socket confirms the attachment while the read is in flight. + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 3, attachedSessionId = "ps-1"))) runCurrent() - assertEquals(2, realtime.connectCount) + gate.complete(room(snapshot(generation = 3))) + read.await() + + assertEquals("ps-1", r.roomSnapshot.value?.attachedSessionId) + assertEquals("ps-1", r.roomDeliveryEcho.value?.playbackSessionId) + job.cancel() + } + @Test + fun `an older-generation HTTP response cannot roll the room back`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 6, revision = 3, phase = RoomPhase.Playing))) 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) + api.mutationResponse = room(snapshot(generation = 4, revision = 2)) + r.stageSelection(SetSelectionRequest(contentId = "movie:a")) + + assertEquals(6L, r.roomSnapshot.value?.generation) + assertEquals(RoomPhase.Playing, r.roomSnapshot.value?.phase) + job.cancel() } @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()) + fun `a newer-generation HTTP response advances the room`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + api.mutationResponse = room(snapshot(generation = 7, revision = 2, phase = RoomPhase.Playing)) - repository.refreshSuggestions() - api.listSuggestionsResponse = ApiResult.Success( - SuggestionsResponse( - suggestions = listOf( - suggestion("removed-vote", votedByMe = false), - suggestion("preserved-vote", votedByMe = true), - ), - ), - ) - repository.refreshSuggestions() + r.startPlayback() - val byId = repository.suggestions.value.associateBy { it.id } - assertFalse(byId.getValue("removed-vote").votedByMe) - assertTrue(byId.getValue("preserved-vote").votedByMe) + assertEquals(RoomPhase.Playing, r.roomSnapshot.value?.phase) } @Test - fun `suggestions event re-merges voted_by_me from local vote set`() = runTest { + fun `a delayed lower-generation socket snapshot is ignored`() = runTest { val api = FakeApi() val realtime = FakeRealtime() val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() - - // Local user voted for s2 (REST round-trip records it in the local set). - r.vote("s2") - advanceUntilIdle() + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + api.mutationResponse = room(snapshot(generation = 5, revision = 2, phase = RoomPhase.Playing)) + r.startPlayback() - // Broadcast forces voted_by_me=false for all — repository must re-merge. - realtime.events.emit( - RoomRealtimeEvent.SuggestionsEvent( - listOf( - suggestion("s1", voteCount = 1, votedByMe = false), - suggestion("s2", voteCount = 2, votedByMe = false), - ), - ), - ) - advanceUntilIdle() + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 4))) + runCurrent() - val byId = r.suggestions.value.associateBy { it.id } - assertTrue(byId.getValue("s2").votedByMe) // re-merged from local set - assertTrue(!byId.getValue("s1").votedByMe) + assertEquals(5L, r.roomSnapshot.value?.generation) job.cancel() } @Test - fun `unvote removes from local vote set so re-merge keeps false`() = runTest { + fun `an ended socket snapshot without room_closed is terminal and stops reconnecting`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 8, phase = RoomPhase.Ended))) + realtime.events.emit(RoomRealtimeEvent.TransportTerminated()) advanceUntilIdle() - r.vote("s2"); advanceUntilIdle() - r.unvote("s2"); advanceUntilIdle() - realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s2", votedByMe = false)))) - advanceUntilIdle() - assertTrue(!r.suggestions.value.first().votedByMe) - job.cancel() + + assertEquals(WatchPartyEndReason.Ended, r.roomClosedReason.value) + assertEquals(1, realtime.connectCount) + assertTrue(job.isCompleted) + + // A stale Playing snapshot afterward cannot resurrect it. + assertNull(r.roomSnapshot.value) + assertNull(r.membershipGeneration()) } - // ---- reset --------------------------------------------------------------- + @Test + fun `snapshot for another room cannot replace the active room`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-2", revision = 9))) + runCurrent() + + assertEquals("room-1", r.roomSnapshot.value?.roomId) + job.cancel() + } @Test - fun `reset clears snapshot suggestions and room token`() = runTest { + fun `late room A socket event cannot overwrite replacement room B`() = runTest { val api = FakeApi() val realtime = FakeRealtime() val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) - realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1")))) + r.createRoom(create()) + val oldConnection = launch { r.connect("room-1") } + runCurrent() + + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "room-1", revision = 99))) advanceUntilIdle() - r.reset() - assertNull(r.roomSnapshot.value) - assertTrue(r.suggestions.value.isEmpty()) - // After reset the room token is gone; a room-scoped call must not reuse it. - api.lastRoomToken = null - val result = r.setSelection(SetSelectionRequest(contentId = "x")) - assertIs(result) - assertNull(api.lastRoomToken) // fail fast: no request is sent with an empty token - job.cancel() + assertEquals("room-2", r.roomSnapshot.value?.roomId) + assertTrue(oldConnection.isCompleted) + assertTrue(!r.attachSession("session-b")) + assertEquals(0, realtime.attachCount) } - // ---- closed/error surface ------------------------------------------------ - @Test - fun `room_closed populates roomClosedReason and reset clears it`() = runTest { + fun `a malformed known frame reconciles by reading the room`() = runTest { + val api = FakeApi() val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertNull(r.roomClosedReason.value) + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() - realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - advanceUntilIdle() - assertEquals("host_left", r.roomClosedReason.value) + realtime.events.emit(RoomRealtimeEvent.Malformed("snapshot")) + runCurrent() - r.reset() + assertEquals(1, api.count("get")) assertNull(r.roomClosedReason.value) job.cancel() } + // ---- transport leases ---------------------------------------------------------- + @Test - fun `error frame surfaces on errors and does not populate roomClosedReason`() = runTest { + fun `transport lease cannot send into a replacement room`() = runTest { + val api = FakeApi() val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertNull(r.roomClosedReason.value) + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + val authorizationA = requireNotNull(repository.currentTransportAuthorization()) - // Collect the transient errors stream; it's a hot SharedFlow so we must - // be subscribed before the event is emitted. - val seen = mutableListOf() - val errorJob = launch { r.errors.collect { seen.add(it) } } - advanceUntilIdle() + api.createResponse = room( + RoomSnapshot(roomId = "room-2", code = "EFGH5678", phase = RoomPhase.Playing, selfCanControlTransport = true), + "jwt-room-2", + ) + repository.createRoom(CreateRoomRequest(roomId = "room-2")) - // A transient server `error` frame (e.g. a rejected transport_request) - // must NOT eject the user: roomClosedReason stays null and the message - // surfaces on the errors stream instead. - realtime.events.emit(RoomRealtimeEvent.Error(code = "rejected", message = "transport rejected")) - advanceUntilIdle() + assertFalse( + repository.transportRequestForAuthorization(authorizationA, RoomTransportIntent.PlayPause, "pause", 42.0, true), + ) + assertTrue(realtime.transportActions.isEmpty()) + connection.cancel() + } - assertNull(r.roomClosedReason.value) - assertEquals(listOf("transport rejected"), seen) + @Test + fun `transport lease revalidates authority after policy changes in the same room`() = runTest { + val playing = RoomSnapshot(roomId = "room-1", code = "ABCD1234", phase = RoomPhase.Playing, selfCanControlTransport = true) + val api = FakeApi(room(playing)) + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + val authorizationBeforePolicyChange = requireNotNull(repository.currentTransportAuthorization()) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(playing.copy(selfCanControlTransport = false))) + runCurrent() - errorJob.cancel() - job.cancel() + assertFalse( + repository.transportRequestForAuthorization( + authorizationBeforePolicyChange, RoomTransportIntent.PlayPause, "pause", 42.0, true, + ), + ) + assertTrue(realtime.transportActions.isEmpty()) + connection.cancel() } - // ---- reconnect stops on room_closed -------------------------------------- - @Test - fun `reconnect loop stops after room_closed`() = runTest { - val realtime = FakeRealtime() - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertEquals(1, realtime.connectCount) + fun `blocked physical send does not hold room replacement or reset mutex`() = runTest { + val playing = RoomSnapshot(roomId = "room-1", code = "ABCD1234", phase = RoomPhase.Playing, selfCanControlTransport = true) + val api = FakeApi(room(playing)) + val realtime = FakeRealtime().apply { transportResultGate = CompletableDeferred() } + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + val authorization = requireNotNull(repository.currentTransportAuthorization()) - // Server-initiated close: repository must NOT reconnect. - realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) - advanceUntilIdle() - assertEquals(1, realtime.connectCount) - assertTrue(job.isCompleted || job.isCancelled) + val blockedSend = async { + repository.transportRequestForAuthorization(authorization, RoomTransportIntent.PlayPause, "pause", 42.0, true) + } + runCurrent() + val reset = async { repository.reset() } + runCurrent() - // A terminal binding is tombstoned. Reusing the old room id cannot - // create a fresh connection without a new successful create/join. - r.connect("room-1") - assertEquals(1, realtime.connectCount) + assertTrue(reset.isCompleted) + assertFalse(blockedSend.isCompleted) + realtime.transportResultGate?.complete(false) + assertFalse(blockedSend.await()) + connection.cancel() } @Test - fun `reconnect loop stops after room_closed with no reason`() = runTest { + fun `readiness and lobby frames reach the writable socket with their command`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() - assertEquals(1, realtime.connectCount) + runCurrent() + assertFalse(r.setLobbyReady(true)) + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() - // Server-initiated close with no reason: repository must NOT reconnect. - realtime.events.emit(RoomRealtimeEvent.Closed(null)) - advanceUntilIdle() - assertEquals(1, realtime.connectCount) - assertEquals("room_closed", r.roomClosedReason.value) - assertTrue(job.isCompleted || job.isCancelled) + assertTrue(r.ready("ps-1", 12.0, false, commandId = "cmd-7")) + assertTrue(r.setLobbyReady(true)) + assertEquals(listOf("cmd-7"), realtime.readyCommands) + assertEquals(listOf(true), realtime.lobbyReady) + job.cancel() } + // ---- connection lifecycle ----------------------------------------------------- + @Test - fun `replacement during reconnect backoff cannot open another stale socket`() = runTest { - val api = FakeApi() + fun `realtime reconnect uses the auth scope and proof captured by the membership`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() + } + } + var currentScope = scopeA + val r = repo(realtime = realtime, authScopeProvider = { currentScope }) + r.createRoom(create()) + currentScope = scopeA.copy(serverId = "server-b", serverUrl = "https://b.example", identityGeneration = 2L) + val connection = launch { r.connect("room-1") } + runCurrent() + advanceTimeBy(firstBackoff + 1) + runCurrent() + + assertEquals(2, realtime.connectCount) + assertTrue(realtime.connectedAuthScopes.all { it == scopeA }) + assertTrue(realtime.connectedRoomTokens.all { it == "jwt-room" }) + connection.cancel() + } + + @Test + fun `connect rejects a room id that does not match the active membership`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + + val job = launch { r.connect("room-2") } + runCurrent() + + assertEquals(0, realtime.connectCount) + job.cancel() + } + + @Test + fun `routine socket rotation reconnects at once and keeps the epoch advancing`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { + emit(RoomRealtimeEvent.Opened) + emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) + kotlinx.coroutines.delay(5 * 60_000L) + emit(RoomRealtimeEvent.TransportTerminated()) + } + } else { + events.asSharedFlow() + } + } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + advanceTimeBy(5 * 60_000L) + runCurrent() + + // No backoff after a socket that stayed up. + assertEquals(2, realtime.connectCount) + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(2L, r.connectionState.value.epoch) + assertNull(r.connectionState.value.disconnectedAtMs) + job.cancel() + } + + @Test + fun `connection replaced is terminal for this device and never reclaims`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot().copy(selfRole = MemberRole.Guest))) + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.ConnectionReplaced("This profile joined the Watch Party on another device.")) + advanceUntilIdle() + + assertTrue(job.isCompleted) + assertEquals(1, realtime.connectCount) + assertEquals(WatchPartyEndReason.Replaced, r.roomClosedReason.value) + assertEquals(WatchPartyEnded("room-1", "ABCD1234", WatchPartyEndReason.Replaced, wasHost = false), r.ended.value) + } + + @Test + fun `room_closed populates the reason and reset clears it`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + assertNull(r.roomClosedReason.value) + + realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + advanceUntilIdle() + assertEquals("host_left", r.roomClosedReason.value) + assertEquals(1, realtime.connectCount) + assertTrue(job.isCompleted) + + // A terminal membership cannot open a fresh connection without a new join. + r.connect("room-1") + assertEquals(1, realtime.connectCount) + + r.reset() + assertNull(r.roomClosedReason.value) + assertNull(r.ended.value) + } + + @Test + fun `room_closed without a reason is reported as the host leaving`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Closed(null)) + advanceUntilIdle() + + assertEquals(WatchPartyEndReason.HostLeft, r.roomClosedReason.value) + assertTrue(job.isCompleted) + } + + @Test + fun `error frame surfaces on errors and does not end the party`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + val seen = mutableListOf() + val errorJob = launch { r.errors.collect { seen.add(it) } } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Error(code = "bad_request", message = "transport rejected")) + runCurrent() + + assertNull(r.roomClosedReason.value) + assertEquals(listOf("transport rejected"), seen) + errorJob.cancel() + job.cancel() + } + + @Test + fun `terminal ticket refusals end the engagement with a matching reason`() = runTest { + for ((status, reason) in listOf( + 404 to WatchPartyEndReason.NotFound, + 409 to WatchPartyEndReason.Ended, + 422 to WatchPartyEndReason.Unauthorized, + )) { + val realtime = FakeRealtime().apply { + connectBehavior = { flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketRefusedException(status, "x"))) } } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + r.connect("room-1") + assertEquals(reason, r.roomClosedReason.value, "status $status") + assertEquals(1, realtime.connectCount) + } + } + + @Test + fun `a 403 ticket refusal renews the proof once and reconnects when the room is readable`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketRefusedException(403, "permission_denied"))) } + } else { + events.asSharedFlow() + } + } + } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + api.roomReadResponse = room(snapshot(generation = 2), token = "jwt-renewed") + val job = launch { r.connect("room-1") } + runCurrent() + + assertEquals(2, realtime.connectCount) + assertEquals(listOf("jwt-room", "jwt-renewed"), realtime.connectedRoomTokens) + assertNull(r.roomClosedReason.value) + job.cancel() + } + + @Test + fun `an expiring ticket waits briefly and mints again`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketExpiringException())) } + } else { + events.asSharedFlow() + } + } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + assertEquals(1, realtime.connectCount) + + advanceTimeBy(testTiming.expiringTicketRetryMs + 1) + runCurrent() + + assertEquals(2, realtime.connectCount) + assertNull(r.roomClosedReason.value) + job.cancel() + } + + @Test + fun `a 401 ticket refusal reconnects instead of ending the party`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketRefusedException(401, "unauthorized"))) } + } else { + events.asSharedFlow() + } + } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + advanceTimeBy(testTiming.backoffMs.first() + 1) + runCurrent() + + assertEquals(2, realtime.connectCount) + assertNull(r.roomClosedReason.value) + job.cancel() + } + + @Test + fun `expiring tickets that never stop exhaust the reconnect budget`() = runTest { + val realtime = FakeRealtime().apply { + connectBehavior = { flow { emit(RoomRealtimeEvent.TransportTerminated(RoomTicketExpiringException())) } } + } + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + advanceTimeBy(testTiming.reconnectBudgetMs + testTiming.expiringTicketRetryMs + 1) + runCurrent() + + assertTrue(job.isCompleted) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) + } + + @Test + fun `replacement during reconnect backoff cannot open another stale socket`() = runTest { + val api = FakeApi() val realtime = FakeRealtime().apply { connectBehavior = { attempt -> if (attempt == 1) flow { throw IllegalStateException("drop") } else events.asSharedFlow() } } val r = repo(api = api, realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val old = launch { r.connect("room-1") } - runCurrent() // first attempt failed and is waiting in its 500 ms backoff + runCurrent() - api.createResponse = ApiResult.Success( - RoomResponse(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2"), - ) - r.createRoom(CreateRoomRequest()) + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) advanceUntilIdle() assertEquals(1, realtime.connectCount) @@ -1103,12 +1130,8 @@ class WatchTogetherRepositoryTest { val first = FakeRealtime() val second = FakeRealtime() var factoryCall = 0 - val r = WatchTogetherRepository( - api = FakeApi(), - realtimeFactory = { if (factoryCall++ == 0) first else second }, - authScopeProvider = { scopeA }, - ) - r.createRoom(CreateRoomRequest()) + val r = repo(realtimeFactory = { if (factoryCall++ == 0) first else second }) + r.createRoom(create()) val old = launch { r.connect("room-1") } runCurrent() first.events.emit(RoomRealtimeEvent.Opened) @@ -1132,7 +1155,7 @@ class WatchTogetherRepositoryTest { } @Test - fun `throw after opened marks the connection unwritable during backoff`() = runTest { + fun `throw after opened marks the connection unwritable and records when`() = runTest { val realtime = FakeRealtime().apply { connectBehavior = { attempt -> if (attempt == 1) flow { @@ -1142,19 +1165,20 @@ class WatchTogetherRepositoryTest { } } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } runCurrent() assertTrue(!r.connectionState.value.writable) + assertEquals(0L, r.connectionState.value.disconnectedAtMs) assertTrue(!r.attachSession("during-backoff")) assertEquals(0, realtime.attachCount) job.cancel() } @Test - fun `queued transport command retains the exact epoch that emitted it across reconnect`() = runTest { - val command = org.siloserver.silo.model.watchtogether.TransportCommand( + fun `the latest transport command keeps the exact epoch that emitted it`() = runTest { + val command = TransportCommand( commandId = "epoch-1-command", sessionId = "playback-1", selectionRevision = 1, @@ -1174,15 +1198,15 @@ class WatchTogetherRepositoryTest { } } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) - val received = async { r.transportCommands.first() } + r.createRoom(create()) + val received = async { r.latestTransportCommand.filterNotNull().first() } val job = launch { r.connect("room-1") } runCurrent() val scheduled = received.await() assertEquals(1L, scheduled.connection.epoch) - assertTrue(!r.connectionState.value.writable) - advanceTimeBy(500) + assertEquals(parseMs("2026-07-27T12:00:00Z"), scheduled.executeAtMs) + advanceTimeBy(firstBackoff + 1) runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) runCurrent() @@ -1192,9 +1216,31 @@ class WatchTogetherRepositoryTest { job.cancel() } + @Test + fun `a burst of commands leaves the newest one for a slow collector`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + repeat(40) { index -> + realtime.events.emit( + RoomRealtimeEvent.TransportCommandEvent( + TransportCommand(commandId = "cmd-$index", selectionRevision = 1, action = TransportAction.Seek, executeAt = ""), + ), + ) + } + runCurrent() + + assertEquals("cmd-39", r.latestTransportCommand.value?.command?.commandId) + assertNull(r.latestTransportCommand.value?.executeAtMs) + 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 attachedSnapshot = snapshot(attachedSessionId = "playback-1") val realtime = FakeRealtime().apply { connectBehavior = { attempt -> if (attempt == 1) { @@ -1209,12 +1255,12 @@ class WatchTogetherRepositoryTest { } } val repository = repo(realtime = realtime) - repository.createRoom(CreateRoomRequest()) + repository.createRoom(create()) val job = launch { repository.connect("room-1") } runCurrent() assertEquals(1L, repository.roomDeliveryEcho.value?.connectionEpoch) - advanceTimeBy(500) + advanceTimeBy(firstBackoff + 1) runCurrent() realtime.events.emit(RoomRealtimeEvent.Opened) runCurrent() @@ -1233,7 +1279,7 @@ class WatchTogetherRepositoryTest { fun `send fails before the active connection reports writable`() = runTest { val realtime = FakeRealtime() val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } runCurrent() @@ -1242,137 +1288,451 @@ class WatchTogetherRepositoryTest { job.cancel() } - // ---- reconnect gives up after max consecutive failures ---------------------- - @Test - fun `reconnect loop gives up after the max consecutive failures`() = runTest { + fun `quick failures keep reconnecting through the host grace before giving up`() = runTest { val realtime = FakeRealtime().apply { failConnect = true } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } + + advanceTimeBy(testTiming.reconnectBudgetMs - 1) + runCurrent() + assertTrue(job.isActive, "gave up inside the reconnect budget") + assertTrue(realtime.connectCount > testTiming.maxReconnectFailures) + advanceUntilIdle() - // The loop must have stopped on its own (job completed, not still running). - assertTrue(job.isCompleted || job.isCancelled) - // And the closed reason must signal connection_lost. - assertEquals("connection_lost", r.roomClosedReason.value) - // Verify the exact number of attempts so an off-by-one or wrong-constant - // regression is caught immediately. Expected: MAX_RECONNECT_ATTEMPTS = 6. - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - // Stale room state must not be observable after giving up. + assertTrue(job.isCompleted) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) + assertTrue(testScheduler.currentTime >= testTiming.reconnectBudgetMs) assertNull(r.roomSnapshot.value) } @Test - fun `physical transport termination reconnects and counts toward the cap`() = runTest { + fun `physical terminations without a snapshot count as failures`() = runTest { val realtime = FakeRealtime().apply { terminateImmediately = true } val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + r.createRoom(create()) val job = launch { r.connect("room-1") } advanceUntilIdle() assertTrue(job.isCompleted) - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - assertEquals("connection_lost", r.roomClosedReason.value) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) assertTrue(!r.connectionState.value.writable) - assertEquals( - WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS.toLong(), - r.connectionState.value.epoch, - ) + assertEquals(realtime.connectCount.toLong(), r.connectionState.value.epoch) } @Test - fun `snapshot plus thirty stable seconds resets the reconnect failure window`() = runTest { - var nowMs = 0L + fun `a flapping server that emits then drops still exhausts the budget`() = runTest { val realtime = FakeRealtime().apply { - connectBehavior = { attempt -> - when { - attempt <= 5 -> flow { throw IllegalStateException("early failure") } - attempt == 6 -> flow { - emit(RoomRealtimeEvent.Opened) - nowMs = WatchTogetherRepository.STABLE_CONNECTION_MS - emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) - emit(RoomRealtimeEvent.TransportTerminated()) - } - attempt <= 10 -> flow { throw IllegalStateException("later failure") } - else -> events.asSharedFlow() - } - } + flappingEvent = RoomRealtimeEvent.SnapshotEvent(RoomSnapshot(roomId = "room-1", code = "ABCD1234")) } - val r = WatchTogetherRepository( - api = FakeApi(), - realtimeFactory = { realtime }, - monotonicNowMs = { nowMs }, - authScopeProvider = { scopeA }, - ) - r.createRoom(CreateRoomRequest()) - + val r = repo(realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() + advanceTimeBy(testTiming.reconnectBudgetMs + testTiming.backoffMs.last() + 1) + runCurrent() - assertEquals(11, realtime.connectCount) - assertTrue(job.isActive) - assertNull(r.roomClosedReason.value) - job.cancel() + assertTrue(job.isCompleted) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) + assertNull(r.roomSnapshot.value) } @Test - fun `wrong room snapshot cannot reset the reconnect failure window`() = runTest { - var nowMs = 0L + fun `a wrong-room snapshot cannot count as a healthy connection`() = runTest { val realtime = FakeRealtime().apply { - connectBehavior = { attempt -> - if (attempt < WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS) { - flow { throw IllegalStateException("early failure") } - } else { - flow { - emit(RoomRealtimeEvent.Opened) - nowMs = WatchTogetherRepository.STABLE_CONNECTION_MS - emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "wrong-room"))) - emit(RoomRealtimeEvent.TransportTerminated()) - } + connectBehavior = { + flow { + emit(RoomRealtimeEvent.Opened) + kotlinx.coroutines.delay(testTiming.stableConnectionMs) + emit(RoomRealtimeEvent.SnapshotEvent(snapshot(roomId = "wrong-room"))) + emit(RoomRealtimeEvent.TransportTerminated()) } } } - val r = WatchTogetherRepository( - api = FakeApi(), - realtimeFactory = { realtime }, - monotonicNowMs = { nowMs }, - authScopeProvider = { scopeA }, - ) - r.createRoom(CreateRoomRequest()) + val r = repo(realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } advanceUntilIdle() assertTrue(job.isCompleted) - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - assertEquals("connection_lost", r.roomClosedReason.value) + assertEquals(WatchPartyEndReason.ConnectionLost, r.roomClosedReason.value) } - // ---- flapping server hits the cap (regression for Issue 1) ----------------- + @Test + fun `a new membership starts without the previous room's clock samples`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime, timing = testTiming.copy(backgroundWork = true)) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + realtime.events.emit( + RoomRealtimeEvent.Pong( + clientSentAt = "2026-09-24T18:00:00.000Z", + serverReceivedAt = "2026-09-24T18:00:00.240Z", + serverSentAt = "2026-09-24T18:00:00.241Z", + clientReceivedMs = parseMs("2026-09-24T18:00:00.081Z"), + ), + ) + runCurrent() + assertEquals(200L, r.clock.value.offsetMs) + job.cancel() + + r.reset() + assertNull(r.clock.value.offsetMs) + r.createRoom(create()) + assertNull(r.clock.value.offsetMs) + } @Test - fun `flapping server that emits then drops still hits the reconnect cap`() = runTest { - // Each connect attempt emits one healthy SnapshotEvent, then throws. - // Under the buggy code (failures reset per healthy event), this would loop - // forever because failures never accumulates to MAX_RECONNECT_ATTEMPTS. - // Under the correct code (failures reset only on clean completion), each - // throwing attempt still increments failures and the loop terminates. - val realtime = FakeRealtime().apply { - flappingEvent = RoomRealtimeEvent.SnapshotEvent( - RoomSnapshot(roomId = "room-1", code = "ABCD1234"), + fun `pings start when the socket opens and pongs update the clock`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime, timing = testTiming.copy(backgroundWork = true)) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(1, realtime.pings) + + realtime.events.emit( + RoomRealtimeEvent.Pong( + clientSentAt = "2026-09-24T18:00:00.000Z", + serverReceivedAt = "2026-09-24T18:00:00.240Z", + serverSentAt = "2026-09-24T18:00:00.241Z", + clientReceivedMs = parseMs("2026-09-24T18:00:00.081Z"), + ), + ) + runCurrent() + assertEquals(200L, r.clock.value.offsetMs) + assertEquals(80L, r.clock.value.rttMs) + + advanceTimeBy(testTiming.initialPingSpacingMs * 2 + 1) + runCurrent() + assertEquals(3, realtime.pings) + job.cancel() + } + + @Test + fun `a pong folded after a wait keeps the clock estimate`() = runTest { + val realtime = FakeRealtime() + val r = repo(realtime = realtime, timing = testTiming.copy(backgroundWork = true)) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + r.checkClockContinuity() + + // A 200 ms offset and an 80 ms round trip, received at [receivedWall]. + fun pong(receivedWall: Long) = RoomRealtimeEvent.Pong( + clientSentAt = org.siloserver.silo.util.formatEpochMillisRfc3339(receivedWall - 81), + serverReceivedAt = org.siloserver.silo.util.formatEpochMillisRfc3339(receivedWall + 159), + serverSentAt = org.siloserver.silo.util.formatEpochMillisRfc3339(receivedWall + 160), + clientReceivedMs = receivedWall, + ) + realtime.events.emit(pong(1_000_000L + testScheduler.currentTime)) + runCurrent() + assertEquals(200L, r.clock.value.offsetMs) + val generation = r.clock.value.generation + + // Decoded 1.5 s before it is folded: a wait, not a wall-clock jump. + realtime.events.emit(pong(1_000_000L + testScheduler.currentTime - 1_500)) + runCurrent() + r.checkClockContinuity() + + assertEquals(200L, r.clock.value.offsetMs) + assertEquals(generation, r.clock.value.generation) + job.cancel() + } + + // ---- suggestions ------------------------------------------------------------- + + @Test + fun `a coalesced suggestion read reports the shared read's failure`() = runTest { + val api = FakeApi().apply { listSuggestionsResult = CompletableDeferred() } + val r = repo(api = api) + r.createRoom(create()) + val first = async { r.refreshSuggestions() } + runCurrent() + val second = async { r.refreshSuggestions() } + val third = async { r.refreshSuggestions() } + runCurrent() + + api.listSuggestionsResult!!.complete(ApiResult.NetworkError(IllegalStateException("offline"))) + runCurrent() + + assertIs(first.await()) + assertIs(second.await()) + assertIs(third.await()) + assertEquals(2, api.count("list")) + } + + @Test + fun `opening the socket hydrates suggestions off the receive path`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success(SuggestionsResponse(listOf(suggestion("existing")))) + } + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + assertEquals(1, api.count("list")) + assertEquals(listOf("existing"), repository.suggestions.value.map { it.id }) + assertTrue(repository.personalVotesKnown.value) + connection.cancel() + } + + @Test + fun `slow suggestion hydration does not block socket events`() = runTest { + val api = FakeApi().apply { listSuggestionsResult = CompletableDeferred() } + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(create()) + val connection = launch { repository.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot(generation = 4, phase = RoomPhase.Playing))) + realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + runCurrent() + + assertEquals(WatchPartyEndReason.HostLeft, repository.roomClosedReason.value) + connection.cancel() + } + + @Test + fun `suggestion reads drain every page and rank by votes then age`() = runTest { + val api = FakeApi().apply { + listSuggestionPages = mutableListOf( + ApiResult.Success( + SuggestionsResponse( + listOf(suggestion("old", voteCount = 1, createdAt = "2026-06-12T08:00:00Z")), + SuggestionPageInfo(hasMore = true, nextCursor = "c1"), + ), + ), + ApiResult.Success( + SuggestionsResponse( + listOf( + suggestion("new", voteCount = 1, createdAt = "2026-06-12T09:00:00Z", votedByMe = true), + suggestion("top", voteCount = 3, createdAt = "2026-06-12T10:00:00Z"), + ), + ), + ), ) } - val r = repo(realtime = realtime) - r.createRoom(CreateRoomRequest()) + val r = repo(api = api) + r.createRoom(create()) + + assertIs>(r.refreshSuggestions()) + + assertEquals(listOf(null, "c1"), api.listCursors) + assertEquals(listOf("top", "old", "new"), r.suggestions.value.map { it.id }) + assertTrue(r.suggestions.value.last().votedByMe) + } + + @Test + fun `personal votes are unknown until an authenticated read`() = runTest { + val realtime = FakeRealtime() + val api = FakeApi().apply { listSuggestionsResult = CompletableDeferred() } + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) val job = launch { r.connect("room-1") } - advanceUntilIdle() + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1", voteCount = 2)))) + runCurrent() + + assertFalse(r.personalVotesKnown.value) + assertEquals(listOf("s1"), r.suggestions.value.map { it.id }) + job.cancel() + } + + @Test + fun `a socket broadcast during a read keeps the newer tallies`() = runTest { + val realtime = FakeRealtime() + val gate = CompletableDeferred>() + val api = FakeApi() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + api.listSuggestionsResult = gate + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1", voteCount = 5)))) + runCurrent() + gate.complete(ApiResult.Success(SuggestionsResponse(listOf(suggestion("s1", voteCount = 4, votedByMe = true))))) + runCurrent() + + val s1 = r.suggestions.value.single() + assertEquals(5, s1.voteCount) + assertTrue(s1.votedByMe) + job.cancel() + } + + @Test + fun `suggestions broadcasts keep known personal votes`() = runTest { + val realtime = FakeRealtime() + val api = FakeApi() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + // The authenticated read after the vote reports it. + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(listOf(suggestion("s1", voteCount = 1), suggestion("s2", voteCount = 2, votedByMe = true))), + ) + r.vote("s2") + realtime.events.emit( + RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1", voteCount = 1), suggestion("s2", voteCount = 2))), + ) + runCurrent() + + val byId = r.suggestions.value.associateBy { it.id } + assertTrue(byId.getValue("s2").votedByMe) + assertFalse(byId.getValue("s1").votedByMe) + + api.listSuggestionsResponse = ApiResult.Success(SuggestionsResponse(listOf(suggestion("s2", voteCount = 1)))) + r.unvote("s2") + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s2", voteCount = 1)))) + runCurrent() + assertFalse(r.suggestions.value.single().votedByMe) + job.cancel() + } + + @Test + fun `a successful vote stays successful when the list read fails`() = runTest { + val api = FakeApi().apply { listSuggestionsResponse = ApiResult.NetworkError(RuntimeException("offline")) } + val r = repo(api = api) + r.createRoom(create()) + + val result = r.vote("s1") + + assertIs>(result) + assertEquals(1, api.count("vote")) + assertEquals(1, api.count("list")) + } + + @Test + fun `adding a suggestion keeps the caller id and reads the list afterward`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + val request = AddSuggestionRequest(suggestionId = "s-new", contentId = "movie:a", contentType = "movie", title = "A") + + val result = r.addSuggestion(request) + + assertEquals("s-new", assertIs>(result).data.suggestionId) + assertEquals(listOf("suggest", "list"), api.calls.filter { it == "suggest" || it == "list" }) + } + + @Test + fun `rest refresh replaces the authoritative local vote set`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(listOf(suggestion("removed-vote", votedByMe = true), suggestion("kept-vote", votedByMe = true))), + ) + } + val repository = repo(api = api) + repository.createRoom(create()) + + repository.refreshSuggestions() + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(listOf(suggestion("removed-vote", votedByMe = false), suggestion("kept-vote", votedByMe = true))), + ) + repository.refreshSuggestions() + + val byId = repository.suggestions.value.associateBy { it.id } + assertFalse(byId.getValue("removed-vote").votedByMe) + assertTrue(byId.getValue("kept-vote").votedByMe) + } + + @Test + fun `a stale hydration cannot publish into a replacement room`() = runTest { + val gate = CompletableDeferred>() + val api = FakeApi().apply { listSuggestionsResult = gate } + val r = repo(api = api) + r.createRoom(create()) + val stale = async { r.refreshSuggestions() } + runCurrent() - assertTrue(job.isCompleted || job.isCancelled) - assertEquals("connection_lost", r.roomClosedReason.value) - // Must have stopped after exactly MAX_RECONNECT_ATTEMPTS attempts. - assertEquals(WatchTogetherRepository.MAX_RECONNECT_ATTEMPTS, realtime.connectCount) - // Stale room state must not be observable after the cap is hit. + api.createResponse = room(RoomSnapshot(roomId = "room-2", code = "EFGH5678"), "jwt-room-2") + r.createRoom(CreateRoomRequest(roomId = "room-2")) + gate.complete(ApiResult.Success(SuggestionsResponse(listOf(suggestion("stale"))))) + + assertEquals("obsolete_room_request", assertIs(stale.await()).error) + assertTrue(r.suggestions.value.isEmpty()) + } + + @Test + fun `member state and picker reads use the current proof`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + + r.memberState(listOf("movie:a")) + assertEquals("jwt-room", api.lastRoomToken) + r.picker() + assertEquals(listOf("memberState", "picker"), api.calls.takeLast(2)) + } + + // ---- reset ------------------------------------------------------------------- + + @Test + fun `reset clears room state and refuses member calls`() = runTest { + val api = FakeApi() + val realtime = FakeRealtime() + val r = repo(api = api, realtime = realtime) + r.createRoom(create()) + val job = launch { r.connect("room-1") } + runCurrent() + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(snapshot())) + realtime.events.emit(RoomRealtimeEvent.SuggestionsEvent(listOf(suggestion("s1")))) + runCurrent() + + r.reset() assertNull(r.roomSnapshot.value) + assertTrue(r.suggestions.value.isEmpty()) + assertNull(r.latestTransportCommand.value) + api.calls.clear() + assertIs(r.promoteSuggestion("s1")) + assertTrue(api.calls.isEmpty()) + job.cancel() + } + + @Test + fun `local delivery failure is surfaced as a non terminal notice`() = runTest { + val repository = repo() + val error = async { repository.errors.first() } + runCurrent() + + repository.reportDeliveryFailure("room_transport_unavailable") + + assertEquals("room_transport_unavailable", error.await()) + assertNull(repository.roomClosedReason.value) } + + @Test + fun `selection mode switch is a room action`() = runTest { + val api = FakeApi() + val r = repo(api = api) + r.createRoom(create()) + r.setSelectionMode(RoomSelectionMode.Vote) + assertEquals(1, api.count("mode")) + } + + private fun parseMs(value: String): Long = org.siloserver.silo.util.parseRfc3339ToEpochMillis(value)!! } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModelsTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModelsTest.kt new file mode 100644 index 0000000000..7c6f591e20 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModelsTest.kt @@ -0,0 +1,405 @@ +package org.siloserver.silo.viewmodel + +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.watchtogether.AddSuggestionRequest +import org.siloserver.silo.model.watchtogether.CreateRoomRequest +import org.siloserver.silo.model.watchtogether.JoinRoomRequest +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.MemberStateRequest +import org.siloserver.silo.model.watchtogether.MemberStateResponse +import org.siloserver.silo.model.watchtogether.PickerEntry +import org.siloserver.silo.model.watchtogether.PickerResponse +import org.siloserver.silo.model.watchtogether.PromoteSuggestionRequest +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomResponse +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.SelectionModeRequest +import org.siloserver.silo.model.watchtogether.SetSelectionRequest +import org.siloserver.silo.model.watchtogether.SourceFallbackRequest +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.model.watchtogether.SuggestionReceipt +import org.siloserver.silo.model.watchtogether.SuggestionsResponse +import org.siloserver.silo.model.watchtogether.UpdatePolicyRequest +import org.siloserver.silo.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.api.WatchTogetherApi +import org.siloserver.silo.network.apiv2.PlaybackCapabilitiesV2 +import org.siloserver.silo.repository.WatchPartyTiming +import org.siloserver.silo.repository.WatchTogetherRepository +import org.siloserver.silo.watchtogether.RecentPartyOwner +import org.siloserver.silo.watchtogether.RecentWatchParties +import org.siloserver.silo.watchtogether.RecentWatchPartyStorage +import org.siloserver.silo.watchtogether.RoomSession +import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository +import org.siloserver.silo.watchtogether.WatchPartyDestination +import org.siloserver.silo.watchtogether.WatchPartyInvite +import org.siloserver.silo.watchtogether.WatchPartyPlaybackFeatures +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withContext +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +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.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchPartyViewModelsTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private val scope = AuthScopeSnapshot("server", "profile", "https://silo.example", profileToken = null, identityGeneration = 1) + + private class Api : WatchTogetherApi { + val creates = mutableListOf() + val joins = mutableListOf() + val calls = mutableListOf() + var createResults = ArrayDeque>() + var joinResult: ApiResult? = null + var room = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", phase = RoomPhase.Lobby, selectionMode = RoomSelectionMode.HostPick, selfRole = MemberRole.Host, selfCanManageRoom = true) + var createGate: CompletableDeferred? = null + var pickerResponse = PickerResponse() + + private fun ok(snapshot: RoomSnapshot = room) = ApiResult.Success(RoomResponse(snapshot, "proof")) + + override suspend fun capabilities(scope: AuthScopeSnapshot) = ApiResult.Success(WatchTogetherCapabilitiesV2()) + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult { + creates += request + createGate?.await() + return createResults.removeFirstOrNull() ?: ok(room.copy(roomId = request.roomId)) + } + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot): ApiResult { + joins += request + return joinResult ?: ok(room.copy(selfRole = MemberRole.Guest, selfCanManageRoom = false)) + } + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = ok() + override suspend fun updatePolicy(roomId: String, request: UpdatePolicyRequest, scope: AuthScopeSnapshot) = ok() + override suspend fun stageSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot) = + ok(room.copy(roomId = roomId, selectedContentId = request.contentId, generation = room.generation + 1)).also { calls += "stage:${request.contentId}" } + override suspend fun startPlayback(roomId: String, scope: AuthScopeSnapshot) = ok().also { calls += "start" } + override suspend fun stopPlayback(roomId: String, scope: AuthScopeSnapshot) = ok() + override suspend fun setSelectionMode(roomId: String, request: SelectionModeRequest, scope: AuthScopeSnapshot) = ok() + override suspend fun setSelection(roomId: String, request: SetSelectionRequest, scope: AuthScopeSnapshot) = + ok().also { calls += "select" } + override suspend fun closeRoom(roomId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun sourceFallback(roomId: String, roomToken: String, request: SourceFallbackRequest, scope: AuthScopeSnapshot) = ok() + override suspend fun listSuggestions(roomId: String, roomToken: String, scope: AuthScopeSnapshot, cursor: String?, limit: Int) = + ApiResult.Success(SuggestionsResponse()) + override suspend fun addSuggestion(roomId: String, roomToken: String, request: AddSuggestionRequest, scope: AuthScopeSnapshot) = + ApiResult.Success(SuggestionReceipt(request.suggestionId)) + override suspend fun deleteSuggestion(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun vote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun unvote(roomId: String, roomToken: String, suggestionId: String, scope: AuthScopeSnapshot) = ApiResult.Success(Unit) + override suspend fun promoteSuggestion(roomId: String, roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot) = + ok().also { calls += "promote" } + override suspend fun memberState(roomId: String, roomToken: String, request: MemberStateRequest, scope: AuthScopeSnapshot) = + ApiResult.Success(MemberStateResponse()) + override suspend fun picker(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = ApiResult.Success(pickerResponse) + } + + private class Storage : RecentWatchPartyStorage { + var value: String? = null + override fun read() = value + override fun write(value: String?) { this.value = value } + } + + private inner class Fixture(testScope: TestScope) { + val api = Api() + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { scope }, + timing = WatchPartyTiming(backgroundWork = false), + ) + val session = RoomSession(repository, testScope.backgroundScope, DefaultIdentityTransitionBarrier()) + val availability = WatchPartyAvailabilityRepository( + roomCapabilities = { + ApiResult.Success( + WatchTogetherCapabilitiesV2( + state = "available", allowed = true, stagedSelection = true, connectionReplaced = true, + socketProtocol = "silo.room.v2", stopPlayback = true, selectionModeSwitch = true, + voteHostOverride = true, picker = true, + ), + ) + }, + playbackCapabilities = { + ApiResult.Success( + PlaybackCapabilitiesV2( + state = "available", allowed = true, protocolVersions = listOf(3), + features = listOf(WatchPartyPlaybackFeatures.Coordinator, WatchPartyPlaybackFeatures.FixedMediaFile), + ), + ) + }, + authScopeProvider = { scope }, + ) + val recents = RecentWatchParties(Storage(), owner = { RecentPartyOwner("server", "login", "profile") }, nowEpochMs = { 1L }) + + fun hub() = WatchPartyHubViewModel(repository, session, availability, recents) { it == "https://silo.example" } + } + + @Test + fun `hosting creates a host-picks room and opens its lobby`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + + hub.host() + runCurrent() + + assertEquals("host_pick", f.api.creates.single().selectionMode) + assertIs(hub.uiState.value.destination) + } + + @Test + fun `hosting from a title stages it without starting playback`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + + hub.hostWithItem(WatchPartyItem("movie:heat", "movie", "Heat", fileId = 42, libraryId = 7)) + runCurrent() + + assertEquals(listOf("stage:movie:heat"), f.api.calls) + assertIs(hub.uiState.value.destination) + } + + @Test + fun `an uncertain create is retried with the same room identity`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.createResults.addLast(ApiResult.NetworkError(RuntimeException("reset"))) + val hub = f.hub() + runCurrent() + + hub.host() + runCurrent() + assertTrue(hub.uiState.value.createRetryable) + hub.retryCreate() + runCurrent() + + assertEquals(2, f.api.creates.size) + assertEquals(f.api.creates[0].roomId, f.api.creates[1].roomId) + assertNotNull(hub.uiState.value.destination) + } + + @Test + fun `codes are normalized and foreign-server links never send the token`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + + hub.join("k7pq-2m4x") + runCurrent() + assertEquals(JoinRoomRequest(code = "K7PQ2M4X"), f.api.joins.single()) + + f.repository.reset() + hub.joinInvite(WatchPartyInvite.Link("https://other.example", "secret-token")) + runCurrent() + assertEquals(1, f.api.joins.size) + assertTrue(hub.uiState.value.error!!.contains("different Silo server")) + } + + @Test + fun `joining while already in a party asks to leave first`() = runTest(dispatcher) { + val f = Fixture(this) + val hub = f.hub() + runCurrent() + hub.join("K7PQ2M4X") + runCurrent() + + hub.join("ABCD2345") + runCurrent() + + assertEquals(1, f.api.joins.size) + assertTrue(hub.uiState.value.error!!.contains("Leave it first")) + } + + @Test + fun `an unknown code says so`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.joinResult = ApiResult.Error(404, "not_found", "The room or suggestion was not found.") + val hub = f.hub() + runCurrent() + + hub.join("K7PQ2M4X") + runCurrent() + + assertEquals("No Watch Party uses that code.", hub.uiState.value.error) + assertNull(hub.uiState.value.destination) + } + + @Test + fun `the lobby opens the player only when the room plays`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.room = f.api.room.copy(selectedContentId = "movie:heat") + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val lobby = WatchPartyLobbyViewModel("room-1", f.repository, f.session, f.availability) + runCurrent() + assertEquals(WatchPartyDestination.Lobby("room-1"), lobby.destination.value) + + f.api.room = f.api.room.copy(phase = RoomPhase.Playing, generation = 5) + lobby.start() + runCurrent() + assertEquals(WatchPartyDestination.Player("room-1"), lobby.destination.value) + } + + @Test + fun `queueing a suggestion stages it and never promotes`() = runTest(dispatcher) { + val f = Fixture(this) + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val lobby = WatchPartyLobbyViewModel("room-1", f.repository, f.session, f.availability) + runCurrent() + + lobby.queue(Suggestion(id = "s", roomId = "room-1", contentId = "movie:a", contentType = "movie", title = "A", createdAt = "")) + runCurrent() + + assertEquals(listOf("stage:movie:a"), f.api.calls) + } + + @Test + fun `lobby eligibility follows role and phase`() = runTest(dispatcher) { + val f = Fixture(this) + f.api.room = f.api.room.copy( + selectedContentId = "movie:a", + members = listOf(RoomMember(userId = "1", profileId = "profile", isHost = true, isSelf = true, connected = true)), + ) + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val lobby = WatchPartyLobbyViewModel("room-1", f.repository, f.session, f.availability) + runCurrent() + + val host = lobby.state.value.eligibility + assertTrue(host.canStart && host.canStage && host.canSwitchMode && host.canEnd) + assertTrue(!host.canLobbyReady && !host.canStop) + } + + @Test + fun `shared picker rows exclude nonvideo titles`() = runTest(dispatcher) { + val f = Fixture(this) + f.repository.createRoom(CreateRoomRequest(roomId = "room-1")) + val movie = PickerEntry(BrowseItem("movie:a", "movie", "Movie")) + val series = PickerEntry(BrowseItem("series:a", "series", "Series")) + val episode = PickerEntry(BrowseItem("episode:a", "episode", "Episode")) + val ebook = PickerEntry(BrowseItem("ebook:a", "ebook", "Book")) + val audiobook = PickerEntry(BrowseItem("audiobook:a", "audiobook", "Audiobook")) + f.api.pickerResponse = PickerResponse( + continueTogether = listOf(ebook, series, movie), + watchlistUnion = listOf(audiobook, movie, ebook, episode), + ) + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { ApiResult.Success(emptyList()) } + + picker.loadRows() + runCurrent() + + val rows = assertNotNull(picker.state.value.rows) + assertEquals(listOf(series, movie), rows.continueTogether) + assertEquals(listOf(movie, episode), rows.watchlistUnion) + } + + @Test + fun `new picker input rejects the previous result before debounce expires`() = runTest(dispatcher) { + val f = Fixture(this) + val oldResult = CompletableDeferred>>() + val newItem = BrowseItem("movie:new", "movie", "New") + val calls = mutableListOf() + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { query -> + calls += query + if (query == "old") withContext(NonCancellable) { oldResult.await() } + else ApiResult.Success(listOf(newItem)) + } + picker.onQuery("old") + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old"), calls) + + picker.onQuery("new") + oldResult.complete(ApiResult.Success(listOf(BrowseItem("movie:old", "movie", "Old")))) + runCurrent() + assertTrue(picker.state.value.results.isEmpty()) + assertTrue(picker.state.value.searching) + + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old", "new"), calls) + assertEquals(listOf(newItem), picker.state.value.results) + assertFalse(picker.state.value.searching) + } + + @Test + fun `new picker input cancels an unfinished search`() = runTest(dispatcher) { + val f = Fixture(this) + var cancelled = false + val calls = mutableListOf() + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { query -> + calls += query + if (query == "old") { + try { + awaitCancellation() + } finally { + cancelled = true + } + } + ApiResult.Success(emptyList()) + } + picker.onQuery("old") + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old"), calls) + + picker.onQuery("new") + runCurrent() + assertTrue(cancelled) + advanceTimeBy(301) + runCurrent() + assertEquals(listOf("old", "new"), calls) + assertFalse(picker.state.value.searching) + } + + @Test + fun `clearing picker input removes results and ignores a pending failure`() = runTest(dispatcher) { + val f = Fixture(this) + val lateResult = CompletableDeferred>>() + val picker = WatchPartyPickerViewModel(f.repository, f.availability) { query -> + if (query == "first") ApiResult.Success(listOf(BrowseItem("movie:first", "movie", "First"))) + else withContext(NonCancellable) { lateResult.await() } + } + picker.onQuery("first") + advanceTimeBy(301) + runCurrent() + assertEquals(1, picker.state.value.results.size) + + picker.onQuery("pending") + advanceTimeBy(301) + runCurrent() + assertTrue(picker.state.value.searching) + + picker.onQuery("") + assertTrue(picker.state.value.results.isEmpty()) + assertFalse(picker.state.value.searching) + lateResult.complete(ApiResult.NetworkError(RuntimeException("late failure"))) + runCurrent() + assertNull(picker.state.value.error) + assertTrue(picker.state.value.results.isEmpty()) + assertFalse(picker.state.value.searching) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomCatchupTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomCatchupTest.kt new file mode 100644 index 0000000000..25f1f0c262 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomCatchupTest.kt @@ -0,0 +1,79 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.TransportAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RoomCatchupTest { + private fun decide(action: TransportAction, target: Double, local: Double, reachable: Boolean) = + RoomCatchup.decide(action, target, local, reachable) + + @Test + fun `explicit seeks always seek`() { + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Seek, 10.0, 10.0, reachable = false)) + } + + @Test + fun `drift inside the deadband needs nothing`() { + assertEquals(RoomCatchup.Decision.None, decide(TransportAction.Play, 10.3, 10.0, reachable = false)) + } + + @Test + fun `a locally reachable target is a seek`() { + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Play, 11.5, 10.0, reachable = true)) + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Pause, 11.5, 10.0, reachable = true)) + } + + @Test + fun `an unreachable pause target is left for the next play`() { + assertEquals(RoomCatchup.Decision.None, decide(TransportAction.Pause, 15.0, 10.0, reachable = false)) + } + + @Test + fun `small unreachable drift converges by a bounded rate and large drift seeks`() { + assertEquals(RoomCatchup.Decision.Rate(1.125), decide(TransportAction.Play, 11.0, 10.0, reachable = false)) + assertEquals(RoomCatchup.Decision.Rate(0.9), decide(TransportAction.Play, 8.5, 10.0, reachable = false)) + assertEquals(RoomCatchup.Decision.Seek, decide(TransportAction.Play, 12.5, 10.0, reachable = false)) + assertEquals(RoomCatchup.MAX_RATE, RoomCatchup.rateFor(10.0)) + } + + @Test + fun `convergence tracks the advancing room position`() { + assertEquals(12.0, RoomCatchup.expectedPosition(10.0, executeAtMs = 1_000, nowMs = 3_000)) + assertTrue(RoomCatchup.converged(10.0, 1_000, localSeconds = 11.8, nowMs = 3_000)) + assertFalse(RoomCatchup.converged(10.0, 1_000, localSeconds = 11.0, nowMs = 3_000)) + } + + @Test + fun `one reload at a time with doubling backoff and a capped lead`() { + val budget = RoomReloadBudget() + assertTrue(budget.allowed(0)) + assertEquals(100.0, budget.begin(100.0, nowMs = 0, durationSeconds = 5_000.0)) + assertFalse(budget.allowed(1_000)) + budget.noteLoading() + assertTrue(budget.landed(100.5)) + budget.land(nowMs = 4_000) + assertFalse(budget.allowed(13_999)) + assertTrue(budget.allowed(14_000)) + + // The next reload aims ahead by the last load time, capped at the duration. + assertEquals(204.0, budget.begin(200.0, nowMs = 14_000, durationSeconds = 5_000.0)) + budget.abandon(nowMs = 15_000) + assertFalse(budget.allowed(34_999)) + assertTrue(budget.allowed(35_000)) + assertEquals(203.0, budget.begin(203.0 - 4.0 + 4.0, nowMs = 35_000, durationSeconds = 203.0)) + } + + @Test + fun `an unlanded reload stops blocking after thirty seconds and settling resets backoff`() { + val budget = RoomReloadBudget() + budget.begin(10.0, nowMs = 0) + assertFalse(budget.landed(10.0)) + assertTrue(budget.allowed(30_000)) + budget.abandon(30_000) + budget.settle() + assertTrue(budget.allowed(30_000)) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBindingTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBindingTest.kt new file mode 100644 index 0000000000..b9a8d6ece4 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomPlaybackBindingTest.kt @@ -0,0 +1,694 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.TransportAction +import org.siloserver.silo.model.watchtogether.TransportCommand +import org.siloserver.silo.repository.RoomTransportAuthorization +import org.siloserver.silo.repository.ScheduledTransportCommand +import org.siloserver.silo.repository.WatchTogetherConnectionState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +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 RoomPlaybackBindingTest { + + private val connection = WatchTogetherConnectionState(generation = 1, epoch = 1, writable = true) + + private fun playing( + state: RoomPlaybackState = RoomPlaybackState.Playing, + revision: Long = 3, + role: MemberRole = MemberRole.Guest, + selfReady: Boolean = false, + selfBuffering: Boolean = false, + ignoreWait: Boolean = false, + paused: Boolean = false, + anchor: Double = 100.0, + canControl: Boolean = false, + ) = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Playing, + playbackState = state, + selectionRevision = revision, + selfRole = role, + isPaused = paused, + anchorPositionSeconds = anchor, + selfIgnoreWait = ignoreWait, + selfCanControlTransport = canControl || role == MemberRole.Host, + members = listOf(RoomMember(userId = "1", isSelf = true, isReady = selfReady, isBuffering = selfBuffering)), + ) + + private class Sent(val kind: String, val position: Double, val paused: Boolean, val commandId: String? = null, val ready: Boolean = false) + + private inner class FakeRoom : RoomPlaybackRoom { + override val roomSnapshot = MutableStateFlow(playing()) + override val connectionState = MutableStateFlow(connection) + override val roomDeliveryEcho = MutableStateFlow(null) + override val latestTransportCommand = MutableStateFlow(null) + override val clock = MutableStateFlow(RoomClockEstimate(offsetMs = 0L, rttMs = 20L, generation = 0)) + val attaches = mutableListOf() + val sent = mutableListOf() + val requests = mutableListOf>() + var echoAttaches = true + + override suspend fun attachSession(sessionId: String): Boolean { + attaches += sessionId + if (echoAttaches) { + val c = connectionState.value + roomDeliveryEcho.value = RoomDeliveryEcho(c.generation, c.epoch, sessionId) + } + return true + } + override suspend fun stateReport(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?, isReady: Boolean) = + true.also { sent += Sent(if (isReady) "report_ready" else "report", positionSeconds, isPaused, commandId, isReady) } + override suspend fun ready(sessionId: String, positionSeconds: Double, isPaused: Boolean, commandId: String?) = + true.also { sent += Sent("ready", positionSeconds, isPaused, commandId) } + override suspend fun buffering(sessionId: String, positionSeconds: Double, isPaused: Boolean) = + true.also { sent += Sent("buffering", positionSeconds, isPaused) } + override suspend fun checkClockContinuity() = clock.value + override fun currentTransportAuthorization(): RoomTransportAuthorization? = + roomSnapshot.value?.let { RoomTransportAuthorization("room-1", 1, 1, 1, it) } + override suspend fun transportRequestForAuthorization( + authorization: RoomTransportAuthorization, + intent: RoomTransportIntent, + action: String, + positionSeconds: Double?, + isPaused: Boolean, + ) = true.also { requests += action to positionSeconds } + + fun kinds() = sent.map { it.kind } + } + + private class FakePlayer : RoomPlayerPort { + override val observations = MutableStateFlow( + RoomPlayerObservation( + sessionId = "ps-1", + selectionRevision = 3, + sourcePositionSeconds = 100.0, + durationSeconds = 3_000.0, + playWhenReady = true, + isPlaying = true, + state = RoomPlayerState.Ready, + ), + ) + val seeks = mutableListOf() + val playing = mutableListOf() + val rates = mutableListOf() + var buffered: (Double) -> Boolean = { true } + + override fun seekTo(sourceSeconds: Double) { + seeks += sourceSeconds + } + override fun setPlaying(playing: Boolean) { + this.playing += playing + } + override fun setCorrectionRate(rate: Double?) { + rates += rate + } + override fun isBuffered(sourceSeconds: Double) = buffered(sourceSeconds) + + fun update(block: RoomPlayerObservation.() -> RoomPlayerObservation) { + observations.value = observations.value.block() + } + } + + private fun TestScope.bind(room: FakeRoom, player: FakePlayer): RoomPlaybackBinding = + RoomPlaybackBinding( + room = room, + player = player, + parentScope = backgroundScope, + monotonicNowMs = { testScheduler.currentTime }, + wallNowMs = { WALL_ORIGIN + testScheduler.currentTime }, + ).also { + it.start() + runCurrent() + } + + private fun TestScope.command( + room: FakeRoom, + id: String, + action: TransportAction, + position: Double, + state: RoomPlaybackState, + executeInMs: Long = 0L, + revision: Long = 3, + sessionId: String = "", + ) { + room.latestTransportCommand.value = ScheduledTransportCommand( + command = TransportCommand( + commandId = id, + sessionId = sessionId, + selectionRevision = revision, + action = action, + positionSeconds = position, + executeAt = "", + playbackState = state, + ), + executeAtMs = WALL_ORIGIN + testScheduler.currentTime + executeInMs, + connection = room.connectionState.value, + ) + } + + // ---- attach and reports ------------------------------------------------------ + + @Test + fun `reports start only after the server echoes the attached session`() = runTest { + val room = FakeRoom().apply { echoAttaches = false } + val player = FakePlayer() + bind(room, player) + advanceTimeBy(3_000) + assertEquals(listOf("ps-1"), room.attaches.distinct()) + assertTrue(room.sent.isEmpty()) + + room.roomDeliveryEcho.value = RoomDeliveryEcho(1, 1, "ps-1") + advanceTimeBy(300) + assertEquals("report", room.sent.single().kind) + assertEquals(100.0, room.sent.single().position) + } + + @Test + fun `a missing echo resends the attach`() = runTest { + val room = FakeRoom().apply { echoAttaches = false } + bind(room, FakePlayer()) + advanceTimeBy(4_600) + assertTrue(room.attaches.size >= 2) + } + + @Test + fun `a new socket epoch attaches again`() = runTest { + val room = FakeRoom() + bind(room, FakePlayer()) + room.connectionState.value = connection.copy(epoch = 2) + advanceTimeBy(600) + assertEquals(2, room.attaches.size) + } + + @Test + fun `reports observed state every one and a half seconds`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + advanceTimeBy(1_000) + player.update { copy(sourcePositionSeconds = 101.0, playWhenReady = false) } + advanceTimeBy(1_000) + + val reports = room.sent.filter { it.kind == "report" } + assertEquals(2, reports.size) + assertEquals(101.0, reports.last().position) + assertTrue(reports.last().paused) + } + + @Test + fun `no reports while stalled, seeking, or suspended`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.sent.clear() + for (change in listOf RoomPlayerObservation>( + { copy(state = RoomPlayerState.Buffering) }, + { copy(state = RoomPlayerState.Ready, seekPending = true) }, + { copy(seekPending = false, suspended = true) }, + )) { + player.update(change) + advanceTimeBy(1_600) + assertTrue(room.sent.none { it.kind == "report" }, "reported during $change") + room.sent.clear() + } + } + + // ---- commands and readiness -------------------------------------------------------- + + @Test + fun `a waiting seek is applied at its execute time and acknowledged once landed`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "cmd-1", TransportAction.Seek, 500.0, RoomPlaybackState.Waiting, executeInMs = 400) + advanceTimeBy(300) + assertTrue(player.seeks.isEmpty()) + + advanceTimeBy(200) + assertEquals(listOf(500.0), player.seeks) + assertEquals(false, player.playing.last()) + + // Still landing: no acknowledgement for the old position. + player.update { copy(seekPending = true) } + advanceTimeBy(600) + assertTrue(room.sent.none { it.kind == "ready" }) + + player.update { copy(seekPending = false, sourcePositionSeconds = 499.6, playWhenReady = false) } + advanceTimeBy(300) + val ready = room.sent.first { it.kind == "ready" } + assertEquals("cmd-1", ready.commandId) + assertEquals(499.6, ready.position) + assertTrue(room.sent.any { it.kind == "report_ready" && it.commandId == "cmd-1" }) + } + + @Test + fun `readiness retries until a fresh snapshot confirms it`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + // The snapshot current when the command arrives still says ready from an earlier cycle. + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, selfReady = true) + command(room, "cmd-2", TransportAction.Pause, 100.0, RoomPlaybackState.Waiting) + player.update { copy(playWhenReady = false) } + advanceTimeBy(1_600) + assertTrue(room.sent.count { it.kind == "ready" } >= 3) + + // The server's acknowledgement arrives as a new snapshot. + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, selfReady = true, anchor = 100.5) + advanceTimeBy(300) + room.sent.clear() + advanceTimeBy(1_000) + assertTrue(room.sent.none { it.kind == "ready" }) + } + + @Test + fun `a second seek on the same session re-arms readiness`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "cmd-a", TransportAction.Seek, 200.0, RoomPlaybackState.Waiting) + player.update { copy(sourcePositionSeconds = 200.0) } + advanceTimeBy(300) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, selfReady = true) + advanceTimeBy(300) + + command(room, "cmd-b", TransportAction.Seek, 200.0, RoomPlaybackState.Waiting) + advanceTimeBy(300) + + assertEquals(listOf("cmd-a", "cmd-b"), room.sent.filter { it.kind == "ready" }.map { it.commandId }.distinct()) + } + + @Test + fun `a guest outside the seek tolerance does not acknowledge but a host within fifteen seconds does`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "cmd-g", TransportAction.Seek, 300.0, RoomPlaybackState.Waiting) + player.update { copy(sourcePositionSeconds = 294.0) } + advanceTimeBy(1_000) + assertTrue(room.sent.none { it.kind == "ready" }) + + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting, role = MemberRole.Host) + advanceTimeBy(300) + assertEquals("cmd-g", room.sent.first { it.kind == "ready" }.commandId) + } + + @Test + fun `a superseded delayed command never executes`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + command(room, "old", TransportAction.Seek, 50.0, RoomPlaybackState.Playing, executeInMs = 2_000) + advanceTimeBy(100) + command(room, "new", TransportAction.Seek, 70.0, RoomPlaybackState.Playing, executeInMs = 100) + advanceTimeBy(3_000) + assertEquals(listOf(70.0), player.seeks) + } + + @Test + fun `commands for another session, revision, or epoch are ignored`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + command(room, "c1", TransportAction.Seek, 10.0, RoomPlaybackState.Playing, sessionId = "someone-else") + advanceTimeBy(100) + command(room, "c2", TransportAction.Seek, 20.0, RoomPlaybackState.Playing, revision = 2) + advanceTimeBy(100) + room.latestTransportCommand.value = room.latestTransportCommand.value!!.copy( + command = room.latestTransportCommand.value!!.command.copy(commandId = "c3", selectionRevision = 3), + connection = connection.copy(epoch = 9), + ) + advanceTimeBy(100) + assertTrue(player.seeks.isEmpty()) + } + + @Test + fun `a waiting command replayed after reconnect executes on the new socket`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.roomSnapshot.value = playing(state = RoomPlaybackState.Waiting) + command(room, "replayed", TransportAction.Seek, 250.0, RoomPlaybackState.Waiting, executeInMs = 400) + advanceTimeBy(100) + room.connectionState.value = connection.copy(epoch = 2) + advanceTimeBy(500) + assertTrue(player.seeks.isEmpty()) + + command(room, "replayed", TransportAction.Seek, 250.0, RoomPlaybackState.Waiting) + advanceTimeBy(100) + assertEquals(listOf(250.0), player.seeks) + player.update { copy(sourcePositionSeconds = 250.0, playWhenReady = false) } + advanceTimeBy(600) + assertTrue(room.sent.any { it.kind == "ready" && it.commandId == "replayed" }) + } + + @Test + fun `a command waits for clock synchronization without using device clock skew`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + val notices = mutableListOf() + backgroundScope.launch { binding.notices.toList(notices) } + runCurrent() + room.clock.value = RoomClockEstimate(offsetMs = null, rttMs = null, generation = 1) + command(room, "no-clock", TransportAction.Play, 200.0, RoomPlaybackState.Playing, executeInMs = -60_000) + advanceTimeBy(4_000) + assertTrue(player.seeks.isEmpty(), "a command without a clock sample must not seek using the device time") + assertTrue(player.playing.none { it }, "playback must wait for a clock sample") + assertEquals("no-clock", binding.debug.value.pendingCommandId) + assertEquals(listOf(RoomPlaybackNotice.ClockUnavailable), notices) + + room.clock.value = RoomClockEstimate(offsetMs = -60_000, rttMs = 20, generation = 1) + advanceTimeBy(300) + assertEquals(204.0, player.seeks.single(), 0.4) + assertEquals(true, player.playing.last()) + } + + @Test + fun `a stale socket command cannot suppress the current socket replay`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + room.connectionState.value = connection.copy(epoch = 2) + command(room, "same-id", TransportAction.Seek, 250.0, RoomPlaybackState.Playing) + room.latestTransportCommand.value = room.latestTransportCommand.value!!.copy(connection = connection) + advanceTimeBy(100) + assertTrue(player.seeks.isEmpty()) + + command(room, "same-id", TransportAction.Seek, 250.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(listOf(250.0), player.seeks) + } + + @Test + fun `clock invalidation during a scheduled delay holds the command until synchronization returns`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + command(room, "clock-reset", TransportAction.Play, 200.0, RoomPlaybackState.Playing, executeInMs = 400) + advanceTimeBy(100) + room.clock.value = RoomClockEstimate(offsetMs = null, rttMs = null, generation = 1) + advanceTimeBy(4_000) + assertTrue(player.seeks.isEmpty()) + assertTrue(player.playing.none { it }) + + room.clock.value = RoomClockEstimate(offsetMs = 0, rttMs = 20, generation = 1) + advanceTimeBy(300) + assertEquals(204.0, player.seeks.single(), 0.4) + assertEquals(true, player.playing.last()) + } + + @Test + fun `a late play advances its target by the elapsed server time`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(sourcePositionSeconds = 90.0) } + command(room, "late", TransportAction.Play, 100.0, RoomPlaybackState.Playing, executeInMs = -4_000) + advanceTimeBy(100) + assertEquals(104.0, player.seeks.single(), 0.2) + assertEquals(true, player.playing.last()) + } + + // ---- corrections ----------------------------------------------------------------- + + @Test + fun `small unbuffered drift converges by rate and returns to exactly 1x`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 99.0) } + command(room, "drift", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(50) + assertEquals(1.125, player.rates.last()!!, 0.001) + assertTrue(player.seeks.isEmpty()) + + // 300 ms later the room is at about 100.3. + player.update { copy(sourcePositionSeconds = 100.3) } + advanceTimeBy(300) + assertNull(player.rates.last()) + } + + @Test + fun `an unbuffered pause correction never reloads media`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + command(room, "p", TransportAction.Pause, 140.0, RoomPlaybackState.Paused) + advanceTimeBy(100) + assertTrue(player.seeks.isEmpty()) + assertEquals(false, player.playing.last()) + } + + @Test + fun `unbuffered play corrections are paced by the reload budget`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 50.0) } + command(room, "r1", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(1, player.seeks.size) + + command(room, "r2", TransportAction.Play, 110.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(1, player.seeks.size, "a second reload started before the first could land") + + // An explicit seek is never delayed by the budget. + command(room, "s", TransportAction.Seek, 400.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertEquals(400.0, player.seeks.last()) + } + + @Test + fun `a play correction held by the reload budget runs once the budget allows`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 50.0) } + command(room, "r1", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + command(room, "r2", TransportAction.Play, 110.0, RoomPlaybackState.Playing) + advanceTimeBy(9_000) + assertEquals(1, player.seeks.size) + + // The superseded reload backs off for ten seconds; then the held + // correction reloads toward where the room is now. + advanceTimeBy(1_500) + assertEquals(2, player.seeks.size) + assertTrue(player.seeks.last() >= 119.0, "aimed at ${player.seeks.last()}") + } + + @Test + fun `a reconnect returns a correction rate to exactly 1x`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 99.0) } + command(room, "drift", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(50) + assertEquals(1.125, player.rates.last()!!, 0.001) + + room.connectionState.value = connection.copy(writable = false) + advanceTimeBy(300) + assertNull(player.rates.last()) + } + + // ---- buffering and catching up ------------------------------------------------------ + + @Test + fun `a stall under two seconds stays local and a longer one is reported once`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(1_500) + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(300) + assertTrue(room.sent.none { it.kind == "buffering" }) + + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(5_000) + assertEquals(1, room.sent.count { it.kind == "buffering" }) + } + + @Test + fun `two sustained stalls within five minutes offer a lower quality`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + repeat(2) { + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(2_300) + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(300) + } + assertTrue(binding.offerLowerQuality.value) + binding.onQualityChanged() + assertFalse(binding.offerLowerQuality.value) + } + + @Test + fun `a new title starts with no stall history`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + repeat(2) { + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(2_300) + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(300) + } + assertTrue(binding.offerLowerQuality.value) + room.roomSnapshot.value = playing(revision = 4) + advanceTimeBy(300) + assertFalse(binding.offerLowerQuality.value) + } + + @Test + fun `a viewer the room left behind acknowledges recovery until it is ready`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + room.roomSnapshot.value = playing(ignoreWait = true) + player.update { copy(state = RoomPlayerState.Buffering) } + advanceTimeBy(600) + assertTrue(binding.catchingUp.value) + assertTrue(room.sent.none { it.kind == "ready" }) + + player.update { copy(state = RoomPlayerState.Ready) } + advanceTimeBy(1_100) + assertTrue(room.sent.count { it.kind == "ready" } >= 2) + + room.roomSnapshot.value = playing(selfReady = true) + advanceTimeBy(300) + room.sent.clear() + advanceTimeBy(1_000) + assertTrue(room.sent.none { it.kind == "ready" }) + assertFalse(binding.catchingUp.value) + } + + @Test + fun `ending a local suspension re-attaches so the room resends its position`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(suspended = true, playWhenReady = false) } + advanceTimeBy(600) + player.update { copy(suspended = false) } + advanceTimeBy(600) + assertEquals(2, room.attaches.size) + } + + // ---- user intents ----------------------------------------------------------------- + + @Test + fun `a guest seek is denied locally and never sent`() = runTest { + val room = FakeRoom() + val binding = bind(room, FakePlayer()) + val notices = mutableListOf() + backgroundScope.launch { binding.notices.toList(notices) } + runCurrent() + + assertEquals(RoomTransportResult.Denied, binding.requestSeek(20.0)) + runCurrent() + assertTrue(room.requests.isEmpty()) + assertEquals(listOf(RoomPlaybackNotice.Denied(RoomTransportIntent.Seek)), notices) + } + + @Test + fun `a tap during a reconnect says so instead of dropping silently`() = runTest { + val room = FakeRoom() + val binding = bind(room, FakePlayer()) + room.roomSnapshot.value = playing(canControl = true) + room.connectionState.value = connection.copy(writable = false) + assertEquals(RoomTransportResult.Reconnecting, binding.requestPlayPause(pause = true)) + assertTrue(room.requests.isEmpty()) + } + + @Test + fun `permitted requests go to the room and never touch the player`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + room.roomSnapshot.value = playing(role = MemberRole.Host) + assertEquals(RoomTransportResult.Sent, binding.requestSeek(250.0)) + assertEquals(RoomTransportResult.Sent, binding.requestPlayPause(pause = true)) + runCurrent() + assertEquals(listOf>("seek" to 250.0, "pause" to 100.0), room.requests) + assertTrue(player.seeks.isEmpty()) + assertTrue(player.playing.isEmpty()) + } + + @Test + fun `play from the end is not sent to the room`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + val binding = bind(room, player) + room.roomSnapshot.value = playing(role = MemberRole.Host) + player.update { copy(state = RoomPlayerState.Ended, playWhenReady = false) } + assertEquals(RoomTransportResult.Ignored, binding.requestPlayPause(pause = false)) + } + + @Test + fun `leaving the playing phase stops corrections at 1x`() = runTest { + val room = FakeRoom() + val player = FakePlayer().apply { buffered = { false } } + bind(room, player) + player.update { copy(sourcePositionSeconds = 99.0) } + command(room, "d", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(50) + room.roomSnapshot.value = playing().copy(phase = RoomPhase.Lobby) + advanceTimeBy(300) + assertNull(player.rates.last()) + } + + @Test + fun `a room play never resumes a locally suspended player`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(suspended = true, playWhenReady = false) } + command(room, "play", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(100) + assertTrue(player.playing.none { it }) + } + + @Test + fun `a command during a mount waits for it and still advances a late play`() = runTest { + val room = FakeRoom() + val player = FakePlayer() + bind(room, player) + player.update { copy(seekPending = true, sourcePositionSeconds = 20.0) } + command(room, "after-mount", TransportAction.Play, 100.0, RoomPlaybackState.Playing) + advanceTimeBy(1_000) + assertTrue(player.seeks.isEmpty()) + + player.update { copy(seekPending = false) } + advanceTimeBy(300) + assertEquals(101.0, player.seeks.single(), 0.4) + assertEquals(true, player.playing.last()) + } + + private companion object { + const val WALL_ORIGIN = 1_790_000_000_000L + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomSessionTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomSessionTest.kt index fbb3aa38fe..6df495d965 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomSessionTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/RoomSessionTest.kt @@ -45,6 +45,7 @@ class RoomSessionTest { session.enter("room-a") runCurrent() + repository.membership = 2L val replacement = launch { session.enter("room-b") } runCurrent() assertEquals(listOf("room-a"), repository.started) @@ -64,6 +65,8 @@ class RoomSessionTest { val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) val first = launch { session.enter("room-a") } + runCurrent() + repository.membership = 2L val second = launch { session.enter("room-b") } first.join() second.join() @@ -118,6 +121,36 @@ class RoomSessionTest { assertTrue(!session.isActive()) } + @Test + fun `explicit rejoin of the same room replaces the obsolete connection`() = runTest { + val repository = FakeRoomSessionRepository() + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + session.adopt("room-a").join() + runCurrent() + + // A fresh join of the same room installs a new membership. + repository.membership = 2L + session.adopt("room-a").join() + runCurrent() + + assertEquals(listOf("room-a", "room-a"), repository.started) + assertEquals(listOf("room-a"), repository.canceling) + assertTrue(session.isActive()) + session.leave() + } + + @Test + fun `enter without a live membership opens no socket`() = runTest { + val repository = FakeRoomSessionRepository().apply { membership = null } + val session = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + + session.enter("room-a") + runCurrent() + + assertTrue(repository.started.isEmpty()) + assertTrue(!session.isActive()) + } + @Test fun `identity WILL_CHANGE cancels joins and resets before mutation`() = runTest { val repository = FakeRoomSessionRepository() @@ -220,6 +253,10 @@ class RoomSessionTest { val canceling = mutableListOf() val finalizerGates = mutableMapOf>() var resetCount = 0 + /** The live membership; a join bumps it. Null means no membership. */ + var membership: Long? = 1L + + override suspend fun membershipGeneration(): Long? = membership override suspend fun connect(roomId: String) { started += roomId diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityAndRecentsTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityAndRecentsTest.kt new file mode 100644 index 0000000000..d6daf71202 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyAvailabilityAndRecentsTest.kt @@ -0,0 +1,195 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionKind +import org.siloserver.silo.network.apiv2.PlaybackCapabilitiesV2 +import org.siloserver.silo.repository.WatchPartyEndReason +import org.siloserver.silo.repository.WatchPartyEnded +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyAvailabilityAndRecentsTest { + private val scopeA = AuthScopeSnapshot(serverId = "s", profileId = "p1", serverUrl = "https://a", profileToken = null, identityGeneration = 1) + private val roomCaps = WatchTogetherCapabilitiesV2( + state = "available", allowed = true, stagedSelection = true, lobbyReady = true, connectionReplaced = true, + socketProtocol = "silo.room.v2", + ) + private val playbackCaps = PlaybackCapabilitiesV2( + state = "available", allowed = true, protocolVersions = listOf(3), + features = listOf(WatchPartyPlaybackFeatures.Coordinator, WatchPartyPlaybackFeatures.FixedMediaFile), + ) + + private class Probe(var room: ApiResult, var playback: ApiResult) { + var calls = 0 + } + + private fun availability(probe: Probe, scope: () -> AuthScopeSnapshot?) = WatchPartyAvailabilityRepository( + roomCapabilities = { probe.calls++; probe.room }, + playbackCapabilities = { probe.playback }, + authScopeProvider = { scope() }, + ) + + @Test + fun `a definite answer is cached for the authority`() = runTest { + val probe = Probe(ApiResult.Success(roomCaps), ApiResult.Success(playbackCaps)) + val repo = availability(probe) { scopeA } + + assertIs(repo.refresh()) + assertIs(repo.refresh()) + assertEquals(1, probe.calls) + assertTrue(repo.features!!.stagedSelection) + } + + @Test + fun `a failed probe keeps a known answer and reports failure only when nothing is known`() = runTest { + val probe = Probe(ApiResult.NetworkError(RuntimeException("offline")), ApiResult.Success(playbackCaps)) + val repo = availability(probe) { scopeA } + + assertIs(repo.refresh()) + probe.room = ApiResult.Success(roomCaps) + assertIs(repo.refresh()) + probe.room = ApiResult.NetworkError(RuntimeException("offline")) + assertIs(repo.refresh(force = true)) + } + + @Test + fun `a profile change discards the cached answer`() = runTest { + var scope = scopeA + val probe = Probe(ApiResult.Success(roomCaps), ApiResult.Success(playbackCaps)) + val repo = availability(probe) { scope } + repo.refresh() + + scope = scopeA.copy(profileId = "p2") + probe.playback = ApiResult.Success(playbackCaps.copy(allowed = false)) + + assertEquals(WatchPartyAvailability.NotAllowed, repo.refresh()) + assertEquals(2, probe.calls) + } + + @Test + fun `a server without rooms is unsupported without reading playback`() = runTest { + val probe = Probe( + ApiResult.Success(WatchTogetherCapabilitiesV2(state = "not_configured")), + ApiResult.NetworkError(RuntimeException("must not be read")), + ) + assertIs(availability(probe) { scopeA }.refresh()) + } + + // ---- recent party ------------------------------------------------------------ + + private class MemoryStorage : RecentWatchPartyStorage { + var value: String? = null + override fun read() = value + override fun write(value: String?) { this.value = value } + } + + private val ownerA = RecentPartyOwner("server", "login-1", "profile-1") + + @Test + fun `recent party is shown only to its owner and only for a day`() = runTest { + var owner: RecentPartyOwner? = ownerA + var now = 1_000L + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { owner }, nowEpochMs = { now }) + + recents.remember("room-1", "K7PQ2M4X", wasHost = true) + assertEquals("K7PQ2M4X", recents.current()?.code) + assertFalse(storage.value!!.contains("token")) + + owner = ownerA.copy(profileId = "profile-2") + assertNull(recents.current()) + owner = ownerA + now += 24 * 60 * 60_000L + 1 + assertNull(recents.current()) + assertNull(storage.value) + } + + @Test + fun `recorder keeps a replaced party and forgets an ended one`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val room = MutableStateFlow(null) + val ended = MutableStateFlow(null) + val barrier = DefaultIdentityTransitionBarrier() + WatchPartyRecentsRecorder(recents, room, ended, backgroundScope, barrier) + + room.value = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", selfRole = MemberRole.Guest) + runCurrent() + assertEquals("room-1", recents.current()?.roomId) + + room.value = null + ended.value = WatchPartyEnded("room-1", "K7PQ2M4X", WatchPartyEndReason.Replaced, wasHost = false) + runCurrent() + assertEquals("room-1", recents.current()?.roomId) + + ended.value = WatchPartyEnded("room-1", "K7PQ2M4X", WatchPartyEndReason.HostLeft, wasHost = false) + runCurrent() + assertNull(recents.current()) + } + + @Test + fun `recorder forgets a room that ends while its snapshot is still live`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val room = MutableStateFlow(null) + val ended = MutableStateFlow(null) + WatchPartyRecentsRecorder(recents, room, ended, backgroundScope, DefaultIdentityTransitionBarrier()) + + room.value = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", selfRole = MemberRole.Host) + runCurrent() + // The repository's order: the end first, then the snapshot and end clear. + ended.value = WatchPartyEnded("room-1", "K7PQ2M4X", WatchPartyEndReason.Ended, wasHost = true) + runCurrent() + room.value = null + ended.value = null + runCurrent() + assertNull(recents.current()) + } + + @Test + fun `recorder forgets a party this device ended but keeps one it left`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val room = MutableStateFlow(null) + val closed = MutableSharedFlow(extraBufferCapacity = 1) + val barrier = DefaultIdentityTransitionBarrier() + WatchPartyRecentsRecorder(recents, room, MutableStateFlow(null), backgroundScope, barrier, closed) + + room.value = RoomSnapshot(roomId = "room-1", code = "K7PQ2M4X", selfRole = MemberRole.Host) + runCurrent() + // Leave clears the engagement without an end reason; Rejoin stays. + room.value = null + runCurrent() + assertEquals("room-1", recents.current()?.roomId) + + closed.emit("room-1") + runCurrent() + assertNull(recents.current()) + } + + @Test + fun `sign out clears the recent party before the identity changes`() = runTest { + val storage = MemoryStorage() + val recents = RecentWatchParties(storage, owner = { ownerA }, nowEpochMs = { 5L }) + val barrier = DefaultIdentityTransitionBarrier() + WatchPartyRecentsRecorder(recents, MutableStateFlow(null), MutableStateFlow(null), backgroundScope, barrier) + recents.remember("room-1", "K7PQ2M4X", wasHost = false) + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) {} + assertEquals("room-1", recents.current()?.roomId) + barrier.changing(IdentityTransitionKind.SIGN_OUT) { assertNull(storage.value) } + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyEntryRulesTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyEntryRulesTest.kt new file mode 100644 index 0000000000..b591928545 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyEntryRulesTest.kt @@ -0,0 +1,184 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.network.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyEntryRulesTest { + private val features = WatchPartyFeatures( + stagedSelection = true, stopPlayback = true, selectionModeSwitch = true, lobbyReady = true, picker = true, + memberState = true, maxMemberStateIds = 200, voteHostOverride = true, sourceFallback = true, + ) + + private fun room( + phase: RoomPhase = RoomPhase.Lobby, + role: MemberRole = MemberRole.Guest, + mode: RoomSelectionMode = RoomSelectionMode.HostPick, + staged: Boolean = false, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + selfRole = role, + selfCanManageRoom = role == MemberRole.Host, + selectionMode = mode, + selectedContentId = "movie:a".takeIf { staged }, + members = listOf(RoomMember(userId = "7", profileId = "p", isSelf = true, isHost = role == MemberRole.Host, connected = true)), + ) + + @Test + fun `the phase alone decides lobby or player`() { + assertEquals(WatchPartyDestination.Lobby("r"), watchPartyDestination(room(staged = true))) + // A host alone in a playing room plays. + assertEquals(WatchPartyDestination.Player("r"), watchPartyDestination(room(phase = RoomPhase.Playing, role = MemberRole.Host))) + assertNull(watchPartyDestination(room(phase = RoomPhase.Ended))) + } + + @Test + fun `guests can suggest and mark ready but not manage`() { + val e = watchPartyEligibility(room(staged = true), features, busy = false, personalVotesKnown = true) + assertTrue(e.canSuggest && e.canLobbyReady) + assertFalse(e.canStage || e.canStart || e.canSwitchMode || e.canSetPolicy || e.canEnd || e.canQueue) + } + + @Test + fun `host start needs a staged item and lobby ready is advisory for guests only`() { + assertFalse(watchPartyEligibility(room(role = MemberRole.Host), features, false, true).canStart) + val staged = watchPartyEligibility(room(role = MemberRole.Host, staged = true), features, false, true) + assertTrue(staged.canStart) + assertFalse(staged.canLobbyReady) + } + + @Test + fun `an action in flight disables the others`() { + val e = watchPartyEligibility(room(role = MemberRole.Host, staged = true), features, busy = true, personalVotesKnown = true) + assertFalse(e.canStart || e.canStage || e.canSuggest) + assertTrue(e.busy) + } + + @Test + fun `votes wait for personal vote state and promotion needs the override capability`() { + val vote = room(role = MemberRole.Host, mode = RoomSelectionMode.Vote) + assertFalse(watchPartyEligibility(vote, features, false, personalVotesKnown = false).canVote) + assertTrue(watchPartyEligibility(vote, features, false, personalVotesKnown = true).canVote) + assertFalse(watchPartyEligibility(vote, features.copy(voteHostOverride = false), false, true).canPromote) + } + + @Test + fun `missing capabilities hide their controls`() { + val host = room(role = MemberRole.Host, staged = true) + val e = watchPartyEligibility(host, features.copy(stagedSelection = false, selectionModeSwitch = false), false, true) + assertFalse(e.canStage || e.canStart || e.canSwitchMode || e.canQueue) + assertFalse(watchPartyEligibility(room(phase = RoomPhase.Playing, role = MemberRole.Host), features.copy(stopPlayback = false), false, true).canStop) + } + + @Test + fun `hosting is offered unless the server is known not to host`() { + assertTrue(watchPartyHostingOffered(WatchPartyAvailability.Available(features))) + assertFalse(watchPartyHostingOffered(WatchPartyAvailability.Available(features.copy(stagedSelection = false)))) + assertFalse(watchPartyHostingOffered(WatchPartyAvailability.NotAllowed)) + // Unknown yet: the hub explains whatever the probe finds. + assertTrue(watchPartyHostingOffered(null)) + } + + @Test + fun `unknown roles grant nothing`() { + val e = watchPartyEligibility(room(role = MemberRole.Unknown), features, false, true) + assertFalse(e.canSuggest || e.canLobbyReady || e.canVote) + } + + @Test + fun `suggestions are removable by the host or the matching suggester only`() { + val mine = Suggestion(id = "s", roomId = "r", suggesterUserId = "7", suggesterProfileId = "p", contentId = "c", contentType = "movie", title = "t", createdAt = "") + assertTrue(canRemoveSuggestion(room(), mine)) + assertFalse(canRemoveSuggestion(room(), mine.copy(suggesterProfileId = "other"))) + assertTrue(canRemoveSuggestion(room(role = MemberRole.Host), mine.copy(suggesterUserId = "9"))) + } + + @Test + fun `lobby readiness counts connected guests`() { + val r = room(role = MemberRole.Host).copy( + members = listOf( + RoomMember(userId = "1", isHost = true, connected = true, lobbyReady = true), + RoomMember(userId = "2", connected = true, lobbyReady = true), + RoomMember(userId = "3", connected = true), + RoomMember(userId = "4", connected = false, lobbyReady = true), + ), + ) + assertEquals(LobbyReadiness(ready = 1, guests = 2), lobbyReadiness(r)) + } + + // ---- invitations --------------------------------------------------------------- + + @Test + fun `codes normalize case, spaces, and dashes`() { + assertEquals("K7PQ2M4X", normalizeWatchPartyCode(" k7pq-2m4x ")) + assertNull(normalizeWatchPartyCode("K7PQ2M4")) + assertNull(normalizeWatchPartyCode("K7PQ2M4O")) + } + + @Test + fun `web invitation links keep the base path`() { + assertEquals( + WatchPartyInvite.Link("https://media.example/silo", "Zq8vN3kR5tW1yB6cD9fG2hJ4"), + parseWatchPartyInvite("https://media.example/silo/rooms/join?token=Zq8vN3kR5tW1yB6cD9fG2hJ4"), + ) + assertEquals( + WatchPartyInvite.Link("http://192.168.1.4:8096", "t"), + parseWatchPartyInvite("http://192.168.1.4:8096/rooms/join?token=t"), + ) + } + + @Test + fun `app links match the Apple format and reject bad shapes`() { + assertEquals( + WatchPartyInvite.Link("https://media.example/silo", "tok"), + parseWatchPartyInvite("silo://watch-party?server=https%3A%2F%2Fmedia.example%2Fsilo&token=tok"), + ) + assertEquals( + WatchPartyInvite.Link("https://media.example", "tok"), + parseWatchPartyInvite("SILO://watch-party?server=https://media.example&token=tok"), + ) + for (bad in listOf( + "silo://watch-party?server=https://a&token=t&token=u", + "silo://watch-party?token=t", + "silo://watch-party?server=ftp://a&token=t", + "silo://watch-party?server=https://user@a&token=t", + "silo://invite?server=https://a&token=t", + "silo://watch-party?server=https://a&token=", + "https://a/rooms/join?code=ABCD1234", + "https://a/rooms/join", + "hello", + )) { + assertNull(parseWatchPartyInvite(bad), bad) + } + } + + @Test + fun `the invitation link resolves against the server base path`() { + assertEquals( + "https://media.example/silo/rooms/join?token=t", + watchPartyInviteUrl("https://media.example/silo/", "/rooms/join?token=t"), + ) + assertNull(watchPartyInviteUrl("https://a", "https://evil/rooms/join?token=t")) + assertEquals("https://a/rooms/join?token=t", parseWatchPartyInvite("https://a/rooms/join?token=t")?.let { "https://a/rooms/join?token=t" }) + } + + @Test + fun `error text is plain and specific`() { + assertEquals("Only the host can do that.", watchPartyErrorMessage(ApiResult.Error(403, "permission_denied", ""), "x")) + assertEquals( + "Couldn't reach the server. Check your connection and try again.", + watchPartyErrorMessage(ApiResult.NetworkError(RuntimeException()), "x"), + ) + assertEquals("fallback", watchPartyErrorMessage(ApiResult.Error(500, "internal", ""), "fallback")) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentationTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentationTest.kt new file mode 100644 index 0000000000..7bf5558606 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyLobbyPresentationTest.kt @@ -0,0 +1,120 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.watchtogether.MemberRole +import org.siloserver.silo.model.watchtogether.RoomMember +import org.siloserver.silo.model.watchtogether.RoomPhase +import org.siloserver.silo.model.watchtogether.RoomSelectionMode +import org.siloserver.silo.model.watchtogether.RoomSnapshot +import org.siloserver.silo.model.watchtogether.Suggestion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchPartyLobbyPresentationTest { + private val features = WatchPartyFeatures( + stagedSelection = true, stopPlayback = true, selectionModeSwitch = true, lobbyReady = true, picker = true, + memberState = true, maxMemberStateIds = 200, voteHostOverride = false, sourceFallback = true, + ) + + private val host = RoomMember(userId = "1", profileId = "a", displayName = "Ana", isHost = true, connected = true) + private val guest = RoomMember(userId = "2", profileId = "b", displayName = "Ben", connected = true) + + private fun room( + role: MemberRole, + phase: RoomPhase = RoomPhase.Lobby, + mode: RoomSelectionMode = RoomSelectionMode.HostPick, + staged: Boolean = true, + guestReady: Boolean = false, + hostConnected: Boolean = true, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + selfRole = role, + selfCanManageRoom = role == MemberRole.Host, + selectionMode = mode, + selectedContentId = "movie:a".takeIf { staged }, + hostConnected = hostConnected, + members = listOf( + host.copy(isSelf = role == MemberRole.Host, connected = hostConnected), + guest.copy(isSelf = role == MemberRole.Guest, lobbyReady = guestReady), + ), + ) + + private fun suggestion(id: String, votes: Int) = Suggestion( + id = id, roomId = "r", contentId = "movie:$id", contentType = "movie", title = id.uppercase(), + voteCount = votes, createdAt = "2026-09-25T10:00:0${id.length}Z", + ) + + @Test + fun `host pick lobby foregrounds choose, then start, and guests ready up`() { + val none = emptyList() + assertEquals(WatchPartyPrimaryAction.ChooseTitle, watchPartyPrimaryAction(room(MemberRole.Host, staged = false), features, none)) + assertEquals(WatchPartyPrimaryAction.Start(null), watchPartyPrimaryAction(room(MemberRole.Host), features, none)) + assertEquals( + WatchPartySecondaryAction.ChangeTitle, + watchPartySecondaryAction(room(MemberRole.Host), WatchPartyPrimaryAction.Start(null)), + ) + assertEquals(WatchPartyPrimaryAction.Ready(false), watchPartyPrimaryAction(room(MemberRole.Guest), features, none)) + assertEquals(WatchPartyPrimaryAction.Ready(true), watchPartyPrimaryAction(room(MemberRole.Guest, guestReady = true), features, none)) + // Ready is advisory, so guests can mark it before the host picks, as on Apple. + assertEquals(WatchPartyPrimaryAction.Ready(false), watchPartyPrimaryAction(room(MemberRole.Guest, staged = false), features, none)) + } + + @Test + fun `a voting room starts its leader and waits while nobody has voted`() { + val voting = room(MemberRole.Host, mode = RoomSelectionMode.Vote, staged = false) + val promotable = features.copy(voteHostOverride = true) + assertEquals(WatchPartyPrimaryAction.WaitingForVotes, watchPartyPrimaryAction(voting, promotable, listOf(suggestion("a", 0)))) + assertEquals( + WatchPartyPrimaryAction.Start("B"), + watchPartyPrimaryAction(voting, promotable, listOf(suggestion("a", 1), suggestion("b", 2))), + ) + // Starting promotes the leader, which servers without the promote route can't do. + assertEquals( + WatchPartyPrimaryAction.WaitingForVotes, + watchPartyPrimaryAction(voting, features, listOf(suggestion("a", 1))), + ) + assertEquals("Nobody has voted yet.", watchPartyLobbyHint(voting, listOf(suggestion("a", 0)))) + assertEquals("Suggest a title to get the vote going.", watchPartyLobbyHint(voting, emptyList())) + } + + @Test + fun `playing rooms offer return to playback unless the guest cannot see the title`() { + assertEquals( + WatchPartyPrimaryAction.ReturnToPlayback, + watchPartyPrimaryAction(room(MemberRole.Guest, phase = RoomPhase.Playing), features, emptyList()), + ) + assertEquals( + WatchPartyPrimaryAction.None, + watchPartyPrimaryAction(room(MemberRole.Guest, phase = RoomPhase.Playing), features, emptyList(), selectionUnavailable = true), + ) + } + + @Test + fun `seats and presence read like Apple`() { + assertEquals(WatchPartySeatState.Ready, watchPartySeatState(host, RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.NotReady, watchPartySeatState(guest, RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.Away, watchPartySeatState(guest.copy(connected = false), RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.Watching, watchPartySeatState(guest.copy(isReady = true), RoomPhase.Playing)) + assertEquals("1 of 2 ready", watchPartyPresenceSummary(listOf(host, guest), RoomPhase.Lobby)) + assertEquals("only you so far", watchPartyPresenceSummary(listOf(host), RoomPhase.Lobby)) + assertEquals( + listOf("Ana", "Ben", "Cal"), + watchPartySeatOrder(listOf(guest.copy(displayName = "Cal"), guest, host)).map { it.displayName }, + ) + } + + @Test + fun `hints and banners name the people involved`() { + assertEquals("Ben hasn't marked ready. You can still start.", watchPartyLobbyHint(room(MemberRole.Host), emptyList())) + assertEquals("Everyone's ready.", watchPartyLobbyHint(room(MemberRole.Host, guestReady = true), emptyList())) + assertEquals("Ana starts the movie when everyone's set.", watchPartyLobbyHint(room(MemberRole.Guest), emptyList())) + assertEquals( + "Ana lost connection. The party holds for two minutes while they come back.", + watchPartyLobbyBanner(room(MemberRole.Guest, hostConnected = false), selectionUnavailable = false, reconnecting = false), + ) + assertNull(watchPartyLobbyBanner(room(MemberRole.Host), selectionUnavailable = false, reconnecting = false)) + assertEquals("Waiting for\nAna", watchPartyLobbyHero(room(MemberRole.Guest, staged = false), emptyList()).title) + assertEquals("Ana chose this", watchPartyLobbyHero(room(MemberRole.Guest), emptyList()).supporting) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyWireTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyWireTest.kt new file mode 100644 index 0000000000..b99c3e67cf --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchPartyWireTest.kt @@ -0,0 +1,253 @@ +package org.siloserver.silo.watchtogether + +import org.siloserver.silo.model.notifications.WsTicketResponse +import org.siloserver.silo.model.watchtogether.RoomPlaybackState +import org.siloserver.silo.model.watchtogether.Suggestion +import org.siloserver.silo.model.watchtogether.TransportAction +import org.siloserver.silo.model.watchtogether.WatchTogetherCapabilitiesV2 +import org.siloserver.silo.model.watchtogether.WsStateReport +import org.siloserver.silo.model.watchtogether.readWatchPartyFixture +import org.siloserver.silo.network.RoomRealtimeEvent +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.apiv2.PlaybackCapabilitiesV2 +import org.siloserver.silo.network.decodeRoomFrame +import org.siloserver.silo.network.validRoomTicket +import org.siloserver.silo.util.formatEpochMillisRfc3339 +import org.siloserver.silo.util.parseRfc3339ToEpochMillis +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyWireTest { + + private fun frame(name: String, receivedAtMs: Long? = null) = + decodeRoomFrame(SiloJson, readWatchPartyFixture(name), receivedAtMs) + + // ---- socket frames ----------------------------------------------------------- + + @Test + fun `socket snapshot decodes numeric ids and catching-up state`() { + val room = assertIs(frame("socket_snapshot.json")).room + assertEquals(57, room.selectedFileId) + assertEquals("12", room.members.first().userId) + assertFalse(room.hostConnected) + assertTrue(room.selfIgnoreWait) + assertTrue(room.selfMember!!.isBuffering) + assertTrue(room.selfCatchingUp) + } + + @Test + fun `catching up requires a playing or paused room`() { + val room = assertIs(frame("socket_snapshot.json")).room + assertFalse(room.copy(playbackState = RoomPlaybackState.Waiting).selfCatchingUp) + assertFalse(room.copy(selfIgnoreWait = false, members = room.members.map { it.copy(isBuffering = false) }).selfCatchingUp) + } + + @Test + fun `transport command decodes its command id and schedule`() { + val command = assertIs(frame("socket_transport_command.json")).command + assertEquals("cmd_5b2e", command.commandId) + assertEquals(TransportAction.Seek, command.action) + assertEquals(RoomPlaybackState.Waiting, command.playbackState) + assertEquals(parseRfc3339ToEpochMillis("2026-09-24T18:11:02.250Z"), parseRfc3339ToEpochMillis(command.executeAt)) + } + + @Test + fun `suggestion broadcast decodes numeric suggester ids`() { + val list = assertIs(frame("socket_suggestions_update.json")).suggestions + assertEquals(listOf("31", "12"), list.map { it.suggesterUserId }) + assertTrue(list.none { it.votedByMe }) + } + + @Test + fun `connection replaced and room closed are distinct terminal frames`() { + val replaced = assertIs(frame("socket_connection_replaced.json")) + assertEquals("This profile joined the Watch Party on another device.", replaced.reason) + assertEquals("host_left", assertIs(frame("socket_room_closed.json")).reason) + } + + @Test + fun `pong carries the receipt time stamped at the socket`() { + val pong = assertIs(frame("socket_pong.json", receivedAtMs = 42L)) + assertEquals(42L, pong.clientReceivedMs) + assertEquals("2026-09-24T18:00:00.240Z", pong.serverReceivedAt) + } + + @Test + fun `unknown frames are ignored and malformed known frames are reported`() { + assertNull(decodeRoomFrame(SiloJson, """{"type":"reaction","emoji":"x"}""")) + assertNull(decodeRoomFrame(SiloJson, "not json")) + assertIs(decodeRoomFrame(SiloJson, """{"type":"snapshot","room":"nope"}""")) + assertIs(decodeRoomFrame(SiloJson, """{"type":"snapshot","room":{"room_id":""}}""")) + assertIs(decodeRoomFrame(SiloJson, """{"type":"transport_command"}""")) + assertIs(decodeRoomFrame(SiloJson, """{"type":"suggestions_update","suggestions":[{}]}""")) + } + + @Test + fun `state reports carry a command only when they acknowledge readiness`() { + fun keys(report: WsStateReport) = + SiloJson.parseToJsonElement(SiloJson.encodeToString(WsStateReport.serializer(), report)).jsonObject.keys + assertEquals( + setOf("type", "session_id", "position_seconds", "is_paused"), + keys(WsStateReport(sessionId = "ps", positionSeconds = 1.0, isPaused = false)), + ) + assertEquals( + setOf("type", "session_id", "position_seconds", "is_paused", "command_id", "is_ready"), + keys(WsStateReport(sessionId = "ps", positionSeconds = 1.0, isPaused = false, commandId = "c", isReady = true)), + ) + } + + @Test + fun `room tickets must fit the server bounds and a header`() { + val ok = WsTicketResponse(ticket = "abc_DEF-1.2", expiresIn = 30, maxConnectionSeconds = 300, protocol = "silo.room.v2") + assertTrue(validRoomTicket(ok)) + assertTrue(validRoomTicket(ok.copy(expiresIn = 0))) + assertFalse(validRoomTicket(ok.copy(expiresIn = 31))) + assertFalse(validRoomTicket(ok.copy(maxConnectionSeconds = 0))) + assertFalse(validRoomTicket(ok.copy(maxConnectionSeconds = 301))) + assertFalse(validRoomTicket(ok.copy(protocol = "silo.events.v2"))) + assertFalse(validRoomTicket(ok.copy(ticket = ""))) + assertFalse(validRoomTicket(ok.copy(ticket = "a b"))) + } + + // ---- capabilities ------------------------------------------------------------ + + private val roomCaps = SiloJson.decodeFromString( + WatchTogetherCapabilitiesV2.serializer(), + readWatchPartyFixture("capabilities.json"), + ).copy(allowed = true) + private val playbackCaps = PlaybackCapabilitiesV2( + state = "available", + allowed = true, + protocolVersions = listOf(3), + features = listOf( + WatchPartyPlaybackFeatures.Coordinator, + WatchPartyPlaybackFeatures.FixedMediaFile, + WatchPartyPlaybackFeatures.SourceFallback, + ), + ) + + @Test + fun `a fully capable server makes every optional feature available`() { + val features = assertIs(watchPartyAvailability(roomCaps, playbackCaps)).features + assertTrue(features.stagedSelection && features.stopPlayback && features.selectionModeSwitch) + assertTrue(features.lobbyReady && features.picker && features.memberState && features.voteHostOverride) + assertTrue(features.sourceFallback) + assertEquals(200, features.maxMemberStateIds) + } + + @Test + fun `entry requires the v2 socket, replacement signal, coordinator and fixed file`() { + fun missing(room: WatchTogetherCapabilitiesV2 = roomCaps, playback: PlaybackCapabilitiesV2 = playbackCaps) = + assertIs(watchPartyAvailability(room, playback)).missing + assertEquals(listOf("room_socket"), missing(room = roomCaps.copy(socketProtocol = ""))) + assertEquals(listOf("connection_replaced"), missing(room = roomCaps.copy(connectionReplaced = false))) + assertEquals(listOf("rooms"), missing(room = roomCaps.copy(state = "not_configured"))) + assertEquals( + listOf(WatchPartyPlaybackFeatures.Coordinator), + missing(playback = playbackCaps.copy(features = listOf(WatchPartyPlaybackFeatures.FixedMediaFile))), + ) + assertEquals( + listOf(WatchPartyPlaybackFeatures.FixedMediaFile), + missing(playback = playbackCaps.copy(features = listOf(WatchPartyPlaybackFeatures.Coordinator))), + ) + } + + @Test + fun `an account that may not use rooms is not allowed rather than unsupported`() { + assertEquals(WatchPartyAvailability.NotAllowed, watchPartyAvailability(roomCaps.copy(allowed = false), playbackCaps)) + assertEquals(WatchPartyAvailability.NotAllowed, watchPartyAvailability(roomCaps, playbackCaps.copy(allowed = false))) + } + + @Test + fun `optional flags gate independently`() { + val features = assertIs( + watchPartyAvailability( + roomCaps.copy(stopPlayback = false, voteHostOverride = false, memberState = false, maxMemberStateIds = 0), + playbackCaps.copy(features = playbackCaps.features - WatchPartyPlaybackFeatures.SourceFallback), + ), + ).features + assertFalse(features.stopPlayback) + assertFalse(features.voteHostOverride) + assertFalse(features.memberState) + assertEquals(0, features.maxMemberStateIds) + assertFalse(features.sourceFallback) + assertTrue(features.stagedSelection) + } + + // ---- clock ------------------------------------------------------------------- + + @Test + fun `clock keeps the lowest round trip sample`() { + val clock = RoomClockEstimator() + // Offset 200, round trip 300 (queued). + assertTrue(clock.record(0, 350, 350, 300, monotonicNowMs = 300)) + // Offset 200, round trip 40. + assertTrue(clock.record(1_000, 1_220, 1_220, 1_040, monotonicNowMs = 1_040)) + assertEquals(200L, clock.estimate.offsetMs) + assertEquals(40L, clock.estimate.rttMs) + } + + @Test + fun `clock rejects impossible or implausible samples`() { + val clock = RoomClockEstimator() + assertFalse(clock.record(100, 0, 0, 50, monotonicNowMs = 50)) + assertFalse(clock.record(0, 10, 5, 20, monotonicNowMs = 20)) + assertFalse(clock.record(0, 3_000, 3_000, 6_000, monotonicNowMs = 6_000)) + assertNull(clock.estimate.offsetMs) + } + + @Test + fun `a wall clock jump discards earlier samples`() { + val clock = RoomClockEstimator() + clock.record(0, 220, 220, 40, monotonicNowMs = 40) + val generation = clock.estimate.generation + + assertTrue(clock.checkContinuity(wallNowMs = 60_000, monotonicNowMs = 1_000)) + + assertNull(clock.estimate.offsetMs) + assertTrue(clock.estimate.generation > generation) + assertFalse(clock.checkContinuity(wallNowMs = 61_000, monotonicNowMs = 2_000)) + } + + // ---- proof, time, ranking ----------------------------------------------------- + + @Test + fun `room proof expiry is read from the token`() { + val token = SiloJson.parseToJsonElement(readWatchPartyFixture("room_http.json")).jsonObject["room_access_token"]!! + assertEquals(1_790_461_331_000L, roomProofExpiryMs(token.toString().trim('"'))) + assertNull(roomProofExpiryMs("jwt-room")) + assertNull(roomProofExpiryMs("a.%%%.c")) + assertEquals("RoomProof(token=, expiresAtEpochMs=null)", RoomProof("secret", null).toString()) + } + + @Test + fun `timestamps round trip through the formatter`() { + for (ms in listOf(0L, 1_790_461_331_123L, 951_782_400_000L, -1L)) { + assertEquals(ms, parseRfc3339ToEpochMillis(formatEpochMillisRfc3339(ms))) + } + assertEquals("2026-09-24T18:00:00.240Z", formatEpochMillisRfc3339(parseRfc3339ToEpochMillis("2026-09-24T18:00:00.240Z")!!)) + } + + private fun suggestion(id: String, votes: Int, createdAt: String) = Suggestion( + id = id, roomId = "r", contentId = id, contentType = "movie", title = id, voteCount = votes, createdAt = createdAt, + ) + + @Test + fun `suggestions rank by votes then age regardless of arrival order`() { + val ranked = rankSuggestions( + listOf( + suggestion("new-tie", 2, "2026-09-24T18:00:00Z"), + suggestion("unvoted", 0, "2026-09-24T17:00:00Z"), + suggestion("old-tie", 2, "2026-09-24T17:30:00Z"), + ), + ) + assertEquals(listOf("old-tie", "new-tie", "unvoted"), ranked.map { it.id }) + assertEquals("old-tie", roomVoteWinner(ranked.reversed())?.id) + assertNull(roomVoteWinner(listOf(suggestion("unvoted", 0, "2026-09-24T17:00:00Z")))) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryPolicyTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryPolicyTest.kt deleted file mode 100644 index f94b33e097..0000000000 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/watchtogether/WatchTogetherEntryPolicyTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.siloserver.silo.watchtogether - -import org.siloserver.silo.model.watchtogether.MemberRole -import org.siloserver.silo.model.watchtogether.RoomPhase -import org.siloserver.silo.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/watchtogether/v2/SOURCE b/shared/src/commonTest/resources/watchtogether/v2/SOURCE new file mode 100644 index 0000000000..32da17589c --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/SOURCE @@ -0,0 +1,13 @@ +server_repository=Silo-Server/silo-server +server_commit=2812ac390829bec2b64328dcee29f0eb15a9b808 +vendored=capabilities.json (contracts/api/v2/fixtures/watch_together_capabilities.json, verbatim) +derived=every other file, written from the server's Go wire types at the same commit + +Derived fixtures follow internal/apiv2/watch_together_room_read.go (HTTP v2 +snapshot, string ids), internal/watchtogether/types.go (socket Snapshot, +MemberSummary, TransportCommand, Suggestion with numeric ids), +internal/apiv2/watch_together_suggestions.go (suggestion page), +internal/apiv2/watch_together_member_state.go, internal/apiv2/watch_together_picker.go, +and internal/api/handlers/watch_together.go (connection_replaced text). +Re-derive them when the server wire types change; the server does not +publish contract fixtures for these operations yet. diff --git a/shared/src/commonTest/resources/watchtogether/v2/capabilities.json b/shared/src/commonTest/resources/watchtogether/v2/capabilities.json new file mode 100644 index 0000000000..d6b14d58ba --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/capabilities.json @@ -0,0 +1,15 @@ +{ + "revision": "efa2c8c12c9ace1fbcf4c6e3e159a3f97ee59741f73d3ca9e303b0b1e0179cf2", + "state": "available", + "allowed": false, + "staged_selection": true, + "lobby_ready": true, + "connection_replaced": true, + "selection_mode_switch": true, + "member_state": true, + "picker": true, + "vote_host_override": true, + "stop_playback": true, + "max_member_state_ids": 200, + "socket_protocol": "silo.room.v2" +} diff --git a/shared/src/commonTest/resources/watchtogether/v2/member_state.json b/shared/src/commonTest/resources/watchtogether/v2/member_state.json new file mode 100644 index 0000000000..1680db40e0 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/member_state.json @@ -0,0 +1 @@ +{"members":[{"user_id":"12","profile_id":"p-host","display_name":"Alex","is_host":true,"is_self":true,"connected":true,"lobby_ready":false}],"items":[{"content_id":"movie:heat-1995","members":[{"user_id":"12","profile_id":"p-host","state":"in_progress","position_seconds":812.0,"duration_seconds":10200.0,"on_watchlist":true}]}]} diff --git a/shared/src/commonTest/resources/watchtogether/v2/picker.json b/shared/src/commonTest/resources/watchtogether/v2/picker.json new file mode 100644 index 0000000000..99524edada --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/picker.json @@ -0,0 +1 @@ +{"members":[{"user_id":"12","profile_id":"p-host","display_name":"Alex","is_host":true,"is_self":true,"connected":true,"lobby_ready":false}],"continue_together":[],"watchlist_union":[{"item":{"content_id":"series:the-bear","type":"series","title":"The Bear","genres":[],"keywords":[],"status":"matched"},"members":[{"user_id":"12","profile_id":"p-host","display_name":"Alex"}],"next_up":{"content_id":"episode:the-bear-s02e06","season_number":2,"episode_number":6,"title":"Fishes","member_count":1}}]} diff --git a/shared/src/commonTest/resources/watchtogether/v2/room_http.json b/shared/src/commonTest/resources/watchtogether/v2/room_http.json new file mode 100644 index 0000000000..4b6f174d3d --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/room_http.json @@ -0,0 +1,49 @@ +{ + "room": { + "room_id": "9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10", + "phase": "playing", + "playback_state": "playing", + "selection_mode": "host_pick", + "selection_revision": 3, + "selected_content_id": "movie:heat-1995", + "selected_file_id": "42", + "selected_library_id": "7", + "code": "K7PQ2M4X", + "guest_control_policy": "guest_play_pause", + "is_paused": false, + "anchor_position_seconds": 812.25, + "anchor_updated_at": "2026-09-24T18:02:11.482Z", + "generation": 11, + "member_count": 2, + "host_connected": true, + "self_role": "host", + "self_can_control_transport": true, + "self_can_manage_room": true, + "self_ignore_wait": false, + "attached_session_id": "ps_7d1f0c", + "invite_path": "/rooms/join?token=Zq8vN3kR5tW1yB6cD9fG2hJ4", + "members": [ + { + "user_id": "12", + "profile_id": "p-host", + "display_name": "Alex", + "is_host": true, + "is_self": true, + "connected": true, + "is_ready": true, + "lobby_ready": false + }, + { + "user_id": "31", + "profile_id": "p-guest", + "display_name": "Sam", + "is_host": false, + "is_self": false, + "connected": true, + "is_syncing": true, + "lobby_ready": false + } + ] + }, + "room_access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb29tX2lkIjoiOWYzYzFhNTItM2M0ZS00ZjdiLTlhMGUtNmIyZDhjMWU3YTEwIiwidXNlcl9pZCI6MTIsInByb2ZpbGVfaWQiOiJwLWhvc3QiLCJleHAiOjE3OTA0NjEzMzF9.c2lnbmF0dXJl" +} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_connection_replaced.json b/shared/src/commonTest/resources/watchtogether/v2/socket_connection_replaced.json new file mode 100644 index 0000000000..9acc9cb13d --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_connection_replaced.json @@ -0,0 +1 @@ +{"type":"connection_replaced","reason":"This profile joined the Watch Party on another device."} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_pong.json b/shared/src/commonTest/resources/watchtogether/v2/socket_pong.json new file mode 100644 index 0000000000..20537b8ac8 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_pong.json @@ -0,0 +1 @@ +{"type":"pong","client_sent_at":"2026-09-24T18:00:00.000Z","server_received_at":"2026-09-24T18:00:00.240Z","server_sent_at":"2026-09-24T18:00:00.241Z"} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_room_closed.json b/shared/src/commonTest/resources/watchtogether/v2/socket_room_closed.json new file mode 100644 index 0000000000..facd0f9ec6 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_room_closed.json @@ -0,0 +1 @@ +{"type":"room_closed","reason":"host_left"} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_snapshot.json b/shared/src/commonTest/resources/watchtogether/v2/socket_snapshot.json new file mode 100644 index 0000000000..a940036a21 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_snapshot.json @@ -0,0 +1 @@ +{"type":"snapshot","room":{"room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","phase":"playing","playback_state":"paused","selection_mode":"vote","selection_revision":4,"selected_content_id":"episode:the-bear-s02e06","selected_file_id":57,"selected_library_id":7,"code":"K7PQ2M4X","guest_control_policy":"host_only","is_paused":true,"anchor_position_seconds":95.5,"anchor_updated_at":"2026-09-24T18:10:00.000000001Z","generation":14,"member_count":2,"host_connected":false,"self_role":"guest","self_can_control_transport":false,"self_can_manage_room":false,"self_ignore_wait":true,"attached_session_id":"ps_0a9b","members":[{"user_id":12,"profile_id":"p-host","display_name":"Alex","is_host":true,"is_self":false,"connected":false,"lobby_ready":false},{"user_id":31,"profile_id":"p-guest","display_name":"Sam","is_host":false,"is_self":true,"connected":true,"is_buffering":true,"lobby_ready":false}]}} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_suggestions_update.json b/shared/src/commonTest/resources/watchtogether/v2/socket_suggestions_update.json new file mode 100644 index 0000000000..131b278340 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_suggestions_update.json @@ -0,0 +1 @@ +{"type":"suggestions_update","suggestions":[{"id":"b2a1","room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","suggester_user_id":31,"suggester_profile_id":"p-guest","content_id":"movie:collateral-2004","content_type":"movie","title":"Collateral","subtitle":"2004","poster_url":"/img/c.jpg","note":"","vote_count":2,"voted_by_me":false,"created_at":"2026-09-24T17:58:00Z"},{"id":"a9f0","room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","suggester_user_id":12,"suggester_profile_id":"p-host","content_id":"movie:heat-1995","content_type":"movie","title":"Heat","subtitle":"1995","poster_url":"","note":"classic","vote_count":1,"voted_by_me":false,"created_at":"2026-09-24T17:55:00Z"}]} diff --git a/shared/src/commonTest/resources/watchtogether/v2/socket_transport_command.json b/shared/src/commonTest/resources/watchtogether/v2/socket_transport_command.json new file mode 100644 index 0000000000..5ca2c88a85 --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/socket_transport_command.json @@ -0,0 +1 @@ +{"type":"transport_command","command":{"command_id":"cmd_5b2e","session_id":"","selection_revision":4,"action":"seek","position_seconds":1203.75,"execute_at":"2026-09-24T18:11:02.250000000Z","issued_at":"2026-09-24T18:11:01.750000000Z","playback_state":"waiting"}} diff --git a/shared/src/commonTest/resources/watchtogether/v2/suggestions_page.json b/shared/src/commonTest/resources/watchtogether/v2/suggestions_page.json new file mode 100644 index 0000000000..765197c3cf --- /dev/null +++ b/shared/src/commonTest/resources/watchtogether/v2/suggestions_page.json @@ -0,0 +1 @@ +{"items":[{"id":"a9f0","room_id":"9f3c1a52-3c4e-4f7b-9a0e-6b2d8c1e7a10","suggester_user_id":"12","suggester_profile_id":"p-host","content_id":"movie:heat-1995","content_type":"movie","title":"Heat","subtitle":"1995","poster_url":"","note":"classic","vote_count":1,"voted_by_me":true,"created_at":"2026-09-24T17:55:00Z"}],"page":{"has_more":true,"next_cursor":"c2VlZDoy"}} From 535ddda815b2b78e547a07c6bff6225c5f1c7b12 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:33:34 -0400 Subject: [PATCH 28/87] fix(tv): start and stop the cast receiver as sign-in changes (#482) * fix(tv): start and stop the cast receiver as sign-in changes The TV checked sign-in once each time the app came to the foreground. Signing in or picking a profile while the app stayed open never started the SiloCast receiver, so remotes couldn't find the TV until it was backgrounded and reopened. Signing out left the receiver running. MainTvActivity now re-checks sign-in after every completed identity transition, inside repeatOnLifecycle(STARTED), and calls start() or stop() on the main thread when the answer changes. A remote-playback temporary identity counts as signed in, so a handoff is never stopped. The receiver's server-switch handler now ignores profile and last-used updates to the registry entry. Re-registering on a profile clear raced the stop() that followed, so the TV stayed listed in Bonjour after sign-out. Co-Authored-By: Claude Opus 5.5 * fix(tv): drop stale cast sign-in reads and late receiver starts A sign-in result read on IO could reach the main thread after a newer identity change and briefly start or stop the receiver. Each result now carries the identity generation it was read under, and the activity ignores it if the generation has moved on; the transition's DID_CHANGE triggers a fresh read. TvSiloCastReceiver.start() published its socket and advertised from an IO job that stop() could not interrupt, so a quick sign-in then sign-out could leave the TV listening and listed in Bonjour. The job now publishes and advertises under the receiver lock only while its scope is current, and closes the socket otherwise. The server-switch collector re-advertises through refreshAdvertisement() for the same reason. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../org/siloserver/silo/tv/MainTvActivity.kt | 112 ++++++++++++------ .../silo/tv/cast/TvSiloCastReceiver.kt | 51 ++++---- 2 files changed, 105 insertions(+), 58 deletions(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt index 29ba028dab..c6d23f4368 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt @@ -28,7 +28,9 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import org.siloserver.silo.common.diagnostics.DiagnosticsLifecycleLogger import org.siloserver.silo.common.network.ServerReachabilityMonitor import org.siloserver.silo.common.settings.PlayerSettingsStore @@ -39,6 +41,9 @@ import org.siloserver.silo.common.startup.warmAuthenticatedStartup import org.siloserver.silo.common.startup.warmProfileSelectionStartup import org.siloserver.silo.common.ui.components.StartupSplashVideo import org.siloserver.silo.common.ui.components.StartupSplashResizeMode +import org.siloserver.silo.network.IdentityTransition +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionPhase import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.tv.ui.focus.TvFocusLog import org.siloserver.silo.network.TokenManager @@ -54,7 +59,16 @@ import org.siloserver.silo.tv.ui.navigation.TvRoute import org.siloserver.silo.tv.ui.screens.player.TvPlayerRemoteKeyBridge import org.siloserver.silo.tv.ui.theme.SiloTvTheme import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.core.qualifier.named @@ -102,6 +116,30 @@ class MainTvActivity : ComponentActivity() { // the navigation collector observes it as soon as it subscribes. handleIntent(intent) + // Run the SiloCast receiver while the app is started AND signed in, + // following sign-in, profile selection and sign-out without needing a + // background/foreground cycle. start()/stop() run here on the main + // thread, and repeatOnLifecycle cancels this block at ON_STOP, so no + // start() can land after onStop()'s stop(). + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + val receiver = get(TvSiloCastReceiver::class.java) + val identityTransitions = + get(IdentityTransitionBarrier::class.java) + var running = false + castAuthentication(identityTransitions).collect { state -> + // A read that crossed the IO hop after a newer identity + // change is stale. Every generation bump ends in a + // DID_CHANGE that triggers a fresh read, so wait for that + // one; acting on this could stop a handoff that just began. + if (state.generation != identityTransitions.generation.value) return@collect + if (state.authenticated == running) return@collect + running = state.authenticated + if (running) receiver.start() else receiver.stop() + } + } + } + setContent { var startRoute by remember { mutableStateOf(null) } var splashPlaybackComplete by remember { mutableStateOf(hasShownColdSplash) } @@ -198,27 +236,6 @@ class MainTvActivity : ComponentActivity() { val monitor = get(ServerReachabilityMonitor::class.java) monitor.startForeground() lifecycleScope.launch(Dispatchers.IO) { refresher.refreshIfStale() } - lifecycleScope.launch(Dispatchers.IO) { - // The auth check suspends; a quick background could run onStop's - // stop() first (a no-op — nothing started) and THEN this start(), - // leaving the receiver advertising while backgrounded. Re-check - // the lifecycle after the suspension. - if (isAuthenticatedForCast() && - lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) - ) { - val receiver = get(TvSiloCastReceiver::class.java) - receiver.start() - // The lifecycle check above is a TOCTOU: the activity can stop - // between the check and start(), so onStop()'s stop() lands - // BEFORE this start() and the receiver keeps advertising while - // backgrounded. Compensate after the fact — start()/stop() are - // @Synchronized and stop() is idempotent, so every interleaving - // terminates with the receiver stopped when backgrounded. - if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - receiver.stop() - } - } - } } /** @@ -352,21 +369,6 @@ class MainTvActivity : ComponentActivity() { } if (startRoute != TvRoute.Main.route) return lifecycleScope.launch(Dispatchers.IO) { - // Re-check the lifecycle before starting the cast receiver: a - // cold-start followed by an immediate Home can dispatch this after - // onStop()'s stop() already ran, leaving NSD advertising + the cast - // socket up while backgrounded. Mirrors the onStart() guard. - if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - val receiver = get(TvSiloCastReceiver::class.java) - receiver.start() - // Same TOCTOU compensation as onStart(): if the activity - // stopped between the check and start(), undo the start — - // start()/stop() are @Synchronized and stop() is idempotent, - // so every interleaving ends stopped when backgrounded. - if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - receiver.stop() - } - } warmAuthenticatedStartup( context = applicationContext, authRepository = get(AuthRepository::class.java), @@ -386,9 +388,47 @@ class MainTvActivity : ComponentActivity() { } } + /** Whether the TV may receive casts, read under identity [generation]. */ + private data class CastAuthState(val authenticated: Boolean, val generation: Long) + + /** + * Emits whether the TV is signed in far enough to receive casts, re-read + * after every committed identity change, so signing in, picking a profile + * or signing out while the app stays open starts or stops the receiver. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun castAuthentication(identityTransitions: IdentityTransitionBarrier): Flow { + val registry = get(ServerRegistry::class.java) + // Widened to nullable so a null marker can stand for "subscribed". + val transitions: SharedFlow = identityTransitions.transitions + return merge( + // The marker triggers the first read only once the subscription is + // live, so a transition finishing at that moment can't be missed. + // DID_CHANGE, not WILL_CHANGE: the new identity is only readable + // after the transition's block has run. + transitions + .onSubscription { emit(null) } + .filter { it == null || it.phase == IdentityTransitionPhase.DID_CHANGE }, + registry.activeEntry, + ) + .mapLatest { + // Captured before the read, so a transition landing mid-read + // marks the result stale. + val generation = identityTransitions.generation.value + CastAuthState(isAuthenticatedForCast(), generation) + } + // Keeps a repeated answer under a new generation: the collector may + // have dropped the earlier one as stale. + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + private suspend fun isAuthenticatedForCast(): Boolean { val registry = get(ServerRegistry::class.java) val tokenManager = get(TokenManager::class.java) + // A remote-playback overlay counts as signed in: stopping the receiver + // would end the handoff it belongs to. + if (tokenManager.hasTemporaryScope()) return true return registry.activeEntry.value != null && !tokenManager.getAccessToken().isNullOrBlank() && !tokenManager.getProfileId().isNullOrBlank() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index c196ba7fbe..6c12bc940e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow @@ -121,15 +122,21 @@ class TvSiloCastReceiver( val newScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope = newScope newScope.launch { - val socket = ServerSocket(0).also { serverSocket = it } - val server = serverRegistry.activeEntry.value - val remote = identityManager.activeIdentity - advertiser.start( - port = socket.localPort, - serverId = remote?.serverId ?: server?.id, - serverName = remote?.serverName ?: server?.displayName, - playing = activePlayer != null, - ) + val socket = ServerSocket(0) + // stop() can run while the socket is being created, and scope + // cancellation can't interrupt that. Publish and advertise under the + // lock only if this scope is still current; otherwise the stopped + // receiver would keep listening and stay listed in Bonjour. + val published = synchronized(this@TvSiloCastReceiver) { + if (scope !== newScope) return@synchronized false + serverSocket = socket + refreshAdvertisement() + true + } + if (!published) { + runCatching { socket.close() } + return@launch + } Log.i(TAG, "SiloCast listening on ${socket.localPort} for ${SiloCastProtocol.serviceType}") acceptLoop(socket) } @@ -137,21 +144,21 @@ class TvSiloCastReceiver( // receiver runs (it stays up across server switches until onStop), and // drop the live controller session — its hello was authorized against // the previous server, so keeping it would let an old-server remote - // drive playback on the new one. + // drive playback on the new one. Only a different server or advertised + // name counts: profile and last-used updates rewrite the entry too, and + // re-registering on a profile clear raced the stop() that follows it, + // losing the Bonjour goodbye so the TV stayed listed after sign-out. newScope.launch { - serverRegistry.activeEntry.drop(1).collect { entry -> - closePreviousController() - identityManager.end() - val port = synchronized(this@TvSiloCastReceiver) { serverSocket?.localPort } - if (port != null) { - advertiser.start( - port = port, - serverId = entry?.id, - serverName = entry?.displayName, - playing = activePlayer != null, - ) + serverRegistry.activeEntry + .distinctUntilChangedBy { it?.id to it?.displayName } + .drop(1) + .collect { + closePreviousController() + identityManager.end() + // Re-advertises under the lock, and is a no-op once stop() + // has cleared the socket. + refreshAdvertisement() } - } } } From 81e9206c6e66efb38d864fa1917acbfca3cbd009 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:50:44 -0400 Subject: [PATCH 29/87] feat(tv-settings): opt-in True Black Bars for grey HDR letterbox bars (#477) * feat(tv-settings): opt-in True Black Bars for grey HDR letterbox bars On some Android TV devices, such as the Onn 4K Pro, the letterbox bars of a Dolby Vision title narrower than 16:9 glow grey. The bar rows hold only the player's opaque black, and the device composites that black above reference black while Dolby Vision is on. Other devices do the opposite, and no platform signal tells them apart. Add an off-by-default, device-local True Black Bars toggle under Settings > Playback > Streaming. When it is on and the first frame has rendered, the player clears its area to transparent instead of painting it black, so the bars come from the output's own black. Reset Playback Overrides clears it, and it never syncs to the server. Refs #475 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv-player): keep the black plate while Navigation fades the player The navigation fade draws the player into a layer, so clearing it there let the other screen show through the bars. Clear only after the first frame and once the transition has settled. Found in cross-provider review. Refs #475 Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/AndroidPlayerSettingsStore.kt | 7 +++++ .../common/settings/PlayerSettingsStore.kt | 8 ++++++ .../silo/tv/ui/navigation/TvAppNavigation.kt | 1 + .../tv/ui/screens/player/TvPlayerScreen.kt | 28 ++++++++++++++++++- .../tv/ui/screens/player/TvPlayerViewModel.kt | 2 ++ .../ui/screens/settings/TvSettingsScreen.kt | 21 ++++++++++++-- .../screens/settings/TvSettingsViewModel.kt | 10 +++++++ .../model/settings/PlaybackSettingsKeys.kt | 15 ++++++++++ 8 files changed, 89 insertions(+), 3 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt index 88d36d630c..594ea7ef3b 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt @@ -288,6 +288,9 @@ class AndroidPlayerSettingsStore( ?: LetterboxExpansion.Default } + override val trueBlackBarsFlow: Flow = + profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.TrueBlackBars, false) } + override val downloadsWifiOnlyFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DownloadsWifiOnly, true) } @@ -462,6 +465,9 @@ class AndroidPlayerSettingsStore( writeStringLocal(PlaybackSettingsKeys.LetterboxExpansion, safe) } + override suspend fun setTrueBlackBars(value: Boolean) = + writeBoolLocal(PlaybackSettingsKeys.TrueBlackBars, value) + override suspend fun setDownloadsWifiOnly(value: Boolean) = writeBoolLocal(PlaybackSettingsKeys.DownloadsWifiOnly, value) @@ -722,6 +728,7 @@ class AndroidPlayerSettingsStore( it.remove(booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PictureInPictureEnabled)) it.remove(booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.ForceHdrPassthrough)) it.remove(stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.LetterboxExpansion)) + it.remove(booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.TrueBlackBars)) it.remove(intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.ResumeRewindSeconds)) it.remove(intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PassOutThreshold)) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt index 779bbdaf6a..9c1e204b78 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt @@ -75,6 +75,13 @@ interface PlayerSettingsStore { val letterboxExpansionFlow: Flow get() = flowOf(LetterboxExpansion.Default) + /** + * See [org.siloserver.silo.model.settings.PlaybackSettingsKeys.TrueBlackBars]. + * Defaulted for the same reason as [letterboxExpansionFlow]. + */ + val trueBlackBarsFlow: Flow + get() = flowOf(false) + /** Per-profile preference for restricting downloads to unmetered (Wi-Fi) * networks. Default true. Consumed by [DownloadEnqueuer] at enqueue * time to set the WorkManager NetworkType constraint. */ @@ -157,6 +164,7 @@ interface PlayerSettingsStore { suspend fun setPictureInPictureEnabled(value: Boolean) suspend fun setForceHdrPassthrough(value: Boolean) suspend fun setLetterboxExpansion(value: String) = Unit + suspend fun setTrueBlackBars(value: Boolean) = Unit suspend fun setDownloadsWifiOnly(value: Boolean) suspend fun setKeepWatchedDownloads(value: Boolean) suspend fun setDefaultDownloadQuality(value: String) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 5f8ca0c577..727a117e32 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -1335,6 +1335,7 @@ fun TvAppNavigation( initialSubtitleAutoResolved = subtitleAutoResolved, autoAdvanceCount = autoAdvanceCount, episodeSelectionHandoff = episodeSelectionHandoff, + navigationSettled = !transition.isRunning, onExit = { navController.popBackStack() }, // Host Stop: back to the room's lobby in place of the player. // The membership is kept, so the lobby follows the next Start. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index 6551f40cfe..313c77aa10 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -51,9 +51,11 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -275,6 +277,9 @@ fun TvPlayerScreen( // Consecutive auto-advance count (pass-out protection); 0 = manual start. autoAdvanceCount: Int = 0, episodeSelectionHandoff: org.siloserver.silo.common.player.video.EpisodeSelectionHandoff? = null, + // False while Navigation fades this screen in or out. True Black Bars + // keeps the black plate meanwhile (see `clearBars`). + navigationSettled: Boolean = true, // Scope the ViewModel key by fileId too so switching 4K <-> 1080p on // the detail screen and replaying actually spins up a fresh player // session instead of reusing the cached one bound to the first fileId. @@ -353,6 +358,7 @@ fun TvPlayerScreen( val hdrEnabled by viewModel.hdrEnabled.collectAsState() val dolbyVisionEnabled by viewModel.dolbyVisionEnabled.collectAsState() val forceHdrPassthrough by viewModel.forceHdrPassthrough.collectAsState() + val trueBlackBars by viewModel.trueBlackBars.collectAsState() val dolbyVisionSwitchInFlight by viewModel.dolbyVisionSwitchInFlight.collectAsState() val subtitleSearch by viewModel.subtitleSearch.collectAsState() val aiTranslate by viewModel.aiTranslate.collectAsState() @@ -394,6 +400,9 @@ fun TvPlayerScreen( // the inflated subtitleView after the AndroidView factory runs. Mirrors // the phone PlayerScreen's `playerViewRef` pattern. var playerViewRef by remember { mutableStateOf(null) } + // Latched on the first rendered frame and kept for this screen's life, so + // remounts (subtitle changes, recovery) don't repaint the bars. + var pictureShown by remember { mutableStateOf(false) } var idleOverlayFocusRequest by remember { mutableStateOf(TvIdleOverlayFocusRequest()) } val cleanPlaybackSeekScope = rememberCoroutineScope() var pendingCleanSeekDirection by remember { mutableStateOf(0) } @@ -1416,6 +1425,7 @@ fun TvPlayerScreen( ) { val mountToken = eventTime.videoMountToken() ?: return if (mountToken != viewModel.uiState.value.transportMountNonce) return + pictureShown = true startupStallDetector.onFirstFrameRendered() postResumeStallDetector.onFirstFrameRendered() viewModel.onFirstVideoFrameRendered(mountToken) @@ -1925,10 +1935,26 @@ fun TvPlayerScreen( } } + // True Black Bars (silo-android#475): once the picture is up, clear the + // player to transparent instead of painting it black, so anything not drawn + // over (the bars, Up Next, an error) shows the output's own black. Clearing, + // not just skipping the plate, also wipes the window's theme background and + // stale pixels nothing else repaints, such as the HUD after it hides. The + // plate stays until the first frame, and while Navigation fades the + // screen: the fade draws it into a layer, where clearing would let the + // other screen show through. + val clearBars = trueBlackBars && pictureShown && navigationSettled + Box( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .then( + if (clearBars) { + Modifier.drawBehind { drawRect(Color.Transparent, blendMode = BlendMode.Clear) } + } else { + Modifier.background(Color.Black) + }, + ) .onGloballyPositioned { playerRootBounds = it.videoViewportBounds() } .focusRequester(rootFocus) .onFocusChanged { playerRootHasFocus = it.isFocused } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 6256a1042a..3d7e75b90a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -1512,6 +1512,8 @@ class TvPlayerViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, true) val forceHdrPassthrough: StateFlow = playerSettingsStore.forceHdrPassthroughFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val trueBlackBars: StateFlow = playerSettingsStore.trueBlackBarsFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, false) private val dvProfile7Hdr10Fallback: StateFlow = playerSettingsStore.dvProfile7HDR10FallbackFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 8a167081ff..dd3253a362 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -283,6 +283,7 @@ fun TvSettingsScreen( onDolbyVisionEnabledChanged = viewModel::onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = viewModel::onDvProfile7HDR10FallbackChanged, onForceHdrPassthroughChanged = viewModel::onForceHdrPassthroughChanged, + onTrueBlackBarsChanged = viewModel::onTrueBlackBarsChanged, onResumeRewindSecondsChanged = viewModel::onResumeRewindSecondsChanged, onPassOutThresholdChanged = viewModel::onPassOutThresholdChanged, onNextUpPromptSecondsChanged = viewModel::onNextUpPromptSecondsChanged, @@ -430,6 +431,7 @@ private fun SettingsSplitLayout( onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, onForceHdrPassthroughChanged: (Boolean) -> Unit, + onTrueBlackBarsChanged: (Boolean) -> Unit, onResumeRewindSecondsChanged: (Int) -> Unit, onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, @@ -509,6 +511,7 @@ private fun SettingsSplitLayout( onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = onDvProfile7HDR10FallbackChanged, onForceHdrPassthroughChanged = onForceHdrPassthroughChanged, + onTrueBlackBarsChanged = onTrueBlackBarsChanged, onResumeRewindSecondsChanged = onResumeRewindSecondsChanged, onPassOutThresholdChanged = onPassOutThresholdChanged, onNextUpPromptSecondsChanged = onNextUpPromptSecondsChanged, @@ -790,6 +793,7 @@ private fun SettingsDetailPane( onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, onForceHdrPassthroughChanged: (Boolean) -> Unit, + onTrueBlackBarsChanged: (Boolean) -> Unit, onResumeRewindSecondsChanged: (Int) -> Unit, onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, @@ -844,6 +848,7 @@ private fun SettingsDetailPane( onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = onDvProfile7HDR10FallbackChanged, onForceHdrPassthroughChanged = onForceHdrPassthroughChanged, + onTrueBlackBarsChanged = onTrueBlackBarsChanged, onResumeRewindSecondsChanged = onResumeRewindSecondsChanged, onPassOutThresholdChanged = onPassOutThresholdChanged, onNextUpPromptSecondsChanged = onNextUpPromptSecondsChanged, @@ -1131,6 +1136,7 @@ private fun TvPlaybackSettingsPane( onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, onForceHdrPassthroughChanged: (Boolean) -> Unit, + onTrueBlackBarsChanged: (Boolean) -> Unit, onResumeRewindSecondsChanged: (Int) -> Unit, onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, @@ -1199,8 +1205,16 @@ private fun TvPlaybackSettingsPane( checked = state.forceHdrPassthrough, onCheckedChange = onForceHdrPassthroughChanged, ) + // Android TV only: tvOS composites its own letterbox, so the + // Apple pane has no counterpart (silo-android#475). + SettingsToggleRow( + label = "True Black Bars", + checked = state.trueBlackBars, + onCheckedChange = onTrueBlackBarsChanged, + ) // tvOS footer: the chosen preset's description, then the - // caveat for the one control here that can misbehave. + // caveat for Force HDR Passthrough. The True Black Bars + // sentence is Android TV's own. SettingsFooterText( text = ( QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.description @@ -1208,7 +1222,10 @@ private fun TvPlaybackSettingsPane( ) + " Force HDR Passthrough allows HDR playback when this TV doesn't report " + "support. It does not force the HDMI output into HDR; Android may still convert " + "the picture to SDR. Enable it only if you've confirmed your TV supports the " + - "source format. Unsupported formats may produce a black screen or incorrect colors.", + "source format. Unsupported formats may produce a black screen or incorrect colors." + + " True Black Bars stops Silo from drawing the bars around the picture during " + + "playback. Try it if they look grey in HDR or Dolby Vision, and turn it off again " + + "if they get lighter or show a thin line.", ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 813f2ec1f2..3fd1420870 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -126,6 +126,7 @@ class TvSettingsViewModel( val subtitleMatchesDevice: Boolean = false, val dvProfile7HDR10Fallback: Boolean = true, val forceHdrPassthrough: Boolean = false, + val trueBlackBars: Boolean = false, val autoSkipCredits: Boolean = false, // Seconds to skip back on resume (0 = off); consecutive auto-advances // before the "Still watching?" prompt (0 = off). @@ -395,6 +396,11 @@ class TvSettingsViewModel( _uiState.update { it.copy(forceHdrPassthrough = value) } } } + viewModelScope.launch { + playerSettingsStore.trueBlackBarsFlow.collect { value -> + _uiState.update { it.copy(trueBlackBars = value) } + } + } viewModelScope.launch { playerSettingsStore.subtitleMatchesDeviceFlow.collect { value -> _uiState.update { it.copy(subtitleMatchesDevice = value) } @@ -660,6 +666,10 @@ class TvSettingsViewModel( viewModelScope.launch { playerSettingsStore.setForceHdrPassthrough(value) } } + fun onTrueBlackBarsChanged(value: Boolean) { + viewModelScope.launch { playerSettingsStore.setTrueBlackBars(value) } + } + fun onIntroSkipModeChanged(value: IntroSkipMode) { viewModelScope.launch { playerSettingsStore.setIntroSkipMode(value) } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt index 5da298e271..658d1ee0b3 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt @@ -151,6 +151,21 @@ object PlaybackSettingsKeys { */ const val ForceHdrPassthrough = "player.force_hdr_passthrough" + /** + * Local-only per-profile TV setting, off by default. Clears the bars + * around a picture that doesn't fill the screen to transparent instead of + * painting them opaque black. + * + * Some devices composite the app window's SDR black above reference black + * while HDR or Dolby Vision is on, so painted bars glow grey (Onn 4K Pro, + * Sony TVs). Others do the reverse and lift the transparent area (Fire TV, + * Google TV Streamer). Neither the chip vendor nor any platform signal + * tells them apart (silo-android#475), so the user picks what looks right + * on their device. It describes this device's compositor, not a playback + * preference, so this never enters [DeviceSettings]. + */ + const val TrueBlackBars = "player.true_black_bars" + val DeviceSettings = listOf( PreferredQuality, MaxBitrateKbps, From b42ad84206eecbf692338d0ed768fcb694f7fa3b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:50:56 -0400 Subject: [PATCH 30/87] fix(playback): never block playback on an earlier unfinished session (#483) * fix(playback): never block playback on an earlier unfinished session Starting playback refused with "A previous playback request needs recovery" while an earlier session for the profile was unsettled. The settle step skipped sessions recorded under another server address, account or installation, but the check that blocked playback did not, so one such session blocked playback until app data was cleared. The server allows a new session while an older one is open and expires sessions nobody stops. Start now resends pending stops and then starts regardless; unreachable attempts and lost starts are dropped rather than replayed; a failed stop no longer fails the player session. The manual "Retry Pending Playback Stops" Settings rows are removed. Co-Authored-By: Claude Opus 5.5 * fix(playback): bound stop retries on the playback start path Start retried every pending stop with three attempts each, and stop requests can take up to 60 seconds, so a server that hung on stops could hold each start for minutes per pending stop. Make one attempt per stop and end the pass at the first failure; the rest wait for the next start. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../common/player/PlaybackRecoverySettings.kt | 41 ----- .../common/player/PlaybackSessionLifecycle.kt | 11 +- .../player/PlaybackSessionLifecycleTest.kt | 12 +- .../ui/screens/settings/SettingsScreen.kt | 41 ++--- ...erViewModelLoadOwnershipIntegrationTest.kt | 9 +- .../ui/screens/settings/TvSettingsScreen.kt | 12 -- docs/playback/sequenced-api-v2.md | 30 ++-- .../silo/repository/PlaybackRepository.kt | 5 +- .../silo/repository/SequencedPlayback.kt | 149 +++++++----------- .../silo/repository/SequencedPlaybackTest.kt | 104 ++++++------ 10 files changed, 149 insertions(+), 265 deletions(-) delete mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackRecoverySettings.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackRecoverySettings.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackRecoverySettings.kt deleted file mode 100644 index 05c085ceaa..0000000000 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackRecoverySettings.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.siloserver.silo.common.player - -import androidx.compose.runtime.* -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.repository.PlaybackRepository - -class PlaybackRecoverySettings { - var visible by mutableStateOf(false) - var busy by mutableStateOf(false) - var message by mutableStateOf("Retry unfinished playback requests and confirm they have stopped.") - var retry: () -> Unit = {} -} - -/** An explicit user action; entering Settings only reads the local journal. */ -@Composable -fun rememberPlaybackRecoverySettings(repository: PlaybackRepository): PlaybackRecoverySettings { - val pending by repository.pendingPlayback.collectAsState() - val scope = rememberCoroutineScope() - val state = remember { PlaybackRecoverySettings() } - LaunchedEffect(pending) { - try { state.visible = repository.pendingPlaybackCount() > 0 } - catch (e: CancellationException) { throw e } - catch (_: Exception) { state.visible = true; state.message = "Playback recovery storage is unavailable." } - } - state.retry = { - if (!state.busy) scope.launch { - state.busy = true - try { - state.message = when (val result = repository.recoverPlayback()) { - is ApiResult.Success -> if (repository.pendingPlaybackCount() == 0) "Playback stops confirmed." - else "Some playback requests remain unavailable for recovery." - is ApiResult.Error -> result.message - is ApiResult.NetworkError -> "Playback recovery could not reach the server. Retry when connected." - } - } finally { state.busy = false } - } - } - return state -} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt index 17bfdffbea..75683ff7ad 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt @@ -572,21 +572,16 @@ class PlaybackSessionLifecycle( flushFinalProgress() } - var pendingStop = false + // A failed stop stays in the playback journal, which retries it before the next + // start; the server expires the session if that never comes. if (sessionId != null && stopActiveSessionOnStop) { - val r = sessionManager.stopSession(sessionId) - pendingStop = sessionManager.isSequenced(sessionId) && r !is ApiResult.Success - when (r) { + when (val r = sessionManager.stopSession(sessionId)) { is ApiResult.Error -> Log.w(TAG, "stopSession error: ${r.code} ${r.message}") is ApiResult.NetworkError -> Log.w(TAG, "stopSession network error: ${r.exception}") else -> {} } } - if (pendingStop) { - _state.value = SessionState.Failed("Playback stop is pending. Retry from playback recovery.") - return@withLock false // Retain the old session and clocks until its terminal receipt. - } lastStartParams = null lastReportedPosition = null lastReportedDuration = 0.0 diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt index 6c5200c3b4..ebe8276b21 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt @@ -53,22 +53,20 @@ import kotlin.test.fail */ @OptIn(ExperimentalCoroutinesApi::class) class PlaybackSessionLifecycleTest { - @Test fun `sequenced final flush stays part-local and pending stop retains old ownership for terminal retry`() = runTest { + @Test fun `sequenced final flush stays part-local and a failed stop still ends the session`() = runTest { val manager = object : FakeSessionManager() { override fun isSequenced(sessionId: String) = true } manager.stopResult = ApiResult.Error(0, "stop_pending", "pending") val lifecycle = newLifecycle(manager) lifecycle.adoptActiveSession(defaultStartParams(), makeSession("part")) lifecycle.reportPosition(30.0, 400.0, true, "part", 630.0, 1000.0) - assertFalse(lifecycle.stop("part")) - assertEquals(30.0, manager.lastProgressPosition) - manager.stopResult = ApiResult.Success(Unit) + // The journal keeps the stop and retries it before the next start. assertTrue(lifecycle.stop("part")) - assertEquals(2, manager.stopCallCount) + assertEquals(1, manager.stopCallCount) assertEquals(30.0, manager.lastProgressPosition) assertTrue(lifecycle.state.value is SessionState.Idle) } - @Test fun `sequenced authority outage keeps session and stop pending without legacy progress`() = runTest { + @Test fun `sequenced authority outage ends the session without legacy progress`() = runTest { val manager = object : FakeSessionManager() { override fun isSequenced(sessionId: String) = true }.apply { @@ -84,7 +82,7 @@ class PlaybackSessionLifecycleTest { assertTrue(lifecycle.state.value is SessionState.Active) assertEquals(0, health.callCount) lifecycle.stop(expectedSessionId = "negotiated") - assertTrue(lifecycle.state.value is SessionState.Failed) + assertTrue(lifecycle.state.value is SessionState.Idle) assertTrue(personal.syncCalls.isEmpty()) assertEquals(1, manager.stopCallCount) assertEquals(2, manager.progressCallCount) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index 8adb7c421f..1cc4a13257 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material.icons.filled.MonitorHeart import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -44,7 +43,6 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.android.BuildConfig import org.siloserver.silo.android.R @@ -59,7 +57,6 @@ import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.android.ui.theme.SiloSettingsBackground import org.siloserver.silo.common.diagnostics.DiagnosticsAvailabilityUi import org.siloserver.silo.common.network.clientVersionLabel -import org.siloserver.silo.common.player.rememberPlaybackRecoverySettings import org.siloserver.silo.common.settings.CardPresentationSupport import org.siloserver.silo.model.settings.LanguageOptions import org.siloserver.silo.model.settings.QualityPresets @@ -75,8 +72,8 @@ internal const val SILO_PRIVACY_POLICY_URL = "https://siloserver.org/privacy" * * Android adds what the Apple apps keep elsewhere: Notifications, the Library * shortcuts (Watchlist, Favorites, History, Collections — reachable only from - * here on the phone), Sign in a TV, and playback-stop recovery. Each sits in - * the group it belongs to rather than in a group of its own. The Sign-in group + * here on the phone) and Sign in a TV. Each sits in the group it belongs to + * rather than in a group of its own. The Sign-in group * (the server's external sign-in providers) appears only on servers that * offer one. */ @@ -101,7 +98,6 @@ fun SettingsScreen( diagnosticsViewModel: DiagnosticsViewModel = koinViewModel(), signInViewModel: SignInSettingsViewModel = koinViewModel(), ) { - val recovery = rememberPlaybackRecoverySettings(koinInject()) val state by viewModel.uiState.collectAsState() val signInState by signInViewModel.uiState.collectAsState() val diagnosticsState by diagnosticsViewModel.state.collectAsState() @@ -128,8 +124,6 @@ fun SettingsScreen( val serverLabel = state.serverName.ifBlank { serverHost(state.serverUrl) ?: "Not connected" } val showDiagnostics = shouldShowDiagnosticsEntry(diagnosticsState) && search.matches("diagnostics", "support", "reports", "debug", "crash") - val showRecovery = recovery.visible && - search.matches("playback", "recovery", "retry", "stops", "support") val matchesInterface = search.matches( "interface", "appearance", "cards", "posters", "captions", "home", "sections", @@ -163,7 +157,7 @@ fun SettingsScreen( val hasResults = matchesInterface || matchesNotifications || matchesPlayback || matchesSubtitles || matchesDownloads || matchesWatchlist || matchesFavorites || matchesHistory || matchesCollections || - showDiagnostics || showRecovery || matchesServer || matchesPairDevice || matchesExperimental || + showDiagnostics || matchesServer || matchesPairDevice || matchesExperimental || matchesAbout || matchesSignOut || matchesSignIn Scaffold( @@ -314,28 +308,15 @@ fun SettingsScreen( } } - if (showDiagnostics || showRecovery) { + if (showDiagnostics) { item(key = "support") { - SettingsSection( - title = "Support", - footer = recovery.message.takeIf { showRecovery }, - ) { - if (showDiagnostics) { - SettingsNavigationRow( - label = "Diagnostics", - icon = Icons.Filled.MonitorHeart, - value = diagnosticsAvailabilityLabel(diagnosticsState.availability), - onClick = onNavigateToDiagnostics, - ) - } - if (showRecovery) { - SettingsNavigationRow( - label = if (recovery.busy) "Recovering Playback…" else "Retry Pending Playback Stops", - icon = Icons.Filled.Restore, - onClick = recovery.retry, - enabled = !recovery.busy, - ) - } + SettingsSection(title = "Support") { + SettingsNavigationRow( + label = "Diagnostics", + icon = Icons.Filled.MonitorHeart, + value = diagnosticsAvailabilityLabel(diagnosticsState.availability), + onClick = onNavigateToDiagnostics, + ) } } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index 10b218f651..3828797eab 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -445,7 +445,7 @@ class PlayerViewModelLoadOwnershipIntegrationTest { } @Test - fun pendingSessionStopCancelsNextUpWithoutStartingTheSuccessor() = runTest(dispatcher) { + fun failedSessionStopStillStartsTheSuccessor() = runTest(dispatcher) { val starter = DeferredNonCooperativeStarter() val fixture = playerViewModel(starter, backgroundScope) val store = ViewModelStore().also { it.put("player", fixture.viewModel) } @@ -468,10 +468,9 @@ class PlayerViewModelLoadOwnershipIntegrationTest { viewModel.offerNextEpisode() viewModel.playUpNextNow() fixture.manager.awaitStopped("session-a") - viewModel.awaitState { !it.isNextUpTransitioning } - assertEquals("episode-a", viewModel.uiState.value.contentId) - assertFalse(viewModel.uiState.value.showUpNext) - assertEquals(1, starter.startedRequestCount) + // The playback journal retries the stop before a later start; it never holds up this one. + starter.awaitRequestCount(2) + assertEquals(2, starter.startedRequestCount) } finally { store.clear() } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index dd3253a362..1886996974 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -1143,7 +1143,6 @@ private fun TvPlaybackSettingsPane( onResetPlaybackOverrides: () -> Unit, seekIntervals: SeekIntervalSettingsModel? = null, ) { - val recovery = org.siloserver.silo.common.player.rememberPlaybackRecoverySettings(org.koin.compose.koinInject()) var activePicker by remember { mutableStateOf(null) } val seekState = seekIntervals?.state?.collectAsState()?.value var seekPicker by remember { mutableStateOf?>(null) } @@ -1294,17 +1293,6 @@ private fun TvPlaybackSettingsPane( ) } } - if (recovery.visible) { - item { - SettingsGroup(title = "Playback Recovery") { - SettingsActionRow( - label = if (recovery.busy) "Recovering Playback…" else "Retry Pending Playback Stops", - onClick = recovery.retry, - ) - SettingsFooterText(text = recovery.message) - } - } - } item { SettingsGroup(title = "Reset") { SettingsActionRow( diff --git a/docs/playback/sequenced-api-v2.md b/docs/playback/sequenced-api-v2.md index ef116a5154..b4a723bbf0 100644 --- a/docs/playback/sequenced-api-v2.md +++ b/docs/playback/sequenced-api-v2.md @@ -32,14 +32,21 @@ bytes of an unresolved sample. Stops persist one UUID and body before dispatch. Only a matching HTTP 200 stopped or replayed receipt confirms completion. Network errors and HTTP 503 responses -retain the request after the bounded stop retry sequence. A later start for -the same login, server and profile first runs recovery inline and only refuses -with `playback_pending` when the retained request still cannot settle; Settings -also exposes **Retry pending playback stops**. Recovery validates the installation, -canonical account, saved login, origin and profile, resolves an uncertain start -with its original attempt, and stops an allocated session without starting a -renderer. It does not stop a currently adopted in-process player. Identity -changes fence pending requests; stored requests grant no authority. +keep the stop in the journal after the bounded retry sequence, and playback +moves on regardless. The next start for the same login, server and profile +first resends any retained stop, then starts whether or not that succeeded. +Each start makes one attempt per retained stop and ends that pass at the first +failure, so a server that is not answering stops cannot hold a start for longer +than one request. It never waits on earlier sessions: the server allows a new session while an older +one is still open and expires a session nobody stops. It does not stop a +currently adopted in-process player. Identity changes fence pending requests; +stored requests grant no authority. + +A start whose reply was lost is dropped, not replayed. A replay would open a +session the server may never have created just to stop it, and the server +expires any session the lost request did open. Retained requests recorded under +an origin, account or installation this server no longer reports can never be +acted on again, so the next start drops them as well. The app-private journal uses atomic writes and a process ownership lock and is excluded from Android backup. It stores request identities and bodies without @@ -47,11 +54,6 @@ credentials. Clearing application storage removes this recovery state. Existing queued intents retain their original bytes and authority; they are not converted, reset or replayed as new v2 intents. -Generic errors, including HTTP 409, 422 and 503, do not prove that an earlier -allocation is absent. They retain uncertain START requests. Recovery may replay -the exact retained attempt under its original validated authority; it must not -rebase the request, allocate a replacement attempt or fall back to v1. - ## Audiobooks The server has no whole-book timeline. The audiobook player stitches the @@ -74,7 +76,7 @@ only canonical positive IDs representable by its integer model. Focused tests cover persisted commands, receipt validation, lost replies, restart recovery, identity changes and shared lifecycle behavior. -Phone and TV builds check caller and Settings integration. These checks do not +Phone and TV builds check caller integration. These checks do not establish rendered media behavior, device process-kill durability, live server admission or physical TV acceptance. Actual isolated native media validation is a separate requirement; client conformance does not activate the server runtime. diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PlaybackRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PlaybackRepository.kt index 3fceb27741..6d1311a79c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PlaybackRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PlaybackRepository.kt @@ -10,15 +10,12 @@ import org.siloserver.silo.network.AuthScopeSnapshot class PlaybackRepository(private val sequenced: SequencedPlayback) { suspend fun controlOwner(sessionId: String): Pair? = sequenced.controlOwner(sessionId) - val pendingPlayback = sequenced.pending fun isSequenced(sessionId: String): Boolean = sequenced.owns(sessionId) - suspend fun pendingPlaybackCount(): Int = sequenced.pendingForCurrentViewer() - suspend fun recoverPlayback(): ApiResult = guarded { sequenced.recover() } /** [SequencedPlayback] answers null for a session it never journaled. */ private fun unknownSession() = ApiResult.Error(0, "playback_unavailable", "This playback session is not owned by the app.") private suspend fun guarded(block: suspend () -> ApiResult): ApiResult = try { block() } catch (e: CancellationException) { throw e } - catch (e: Exception) { ApiResult.Error(0, "playback_storage", "Playback recovery storage is unavailable.") } + catch (e: Exception) { ApiResult.Error(0, "playback_storage", "Playback storage is unavailable.") } /** Starts a protocol-v3 playback attempt using the supplied client and route evidence. */ suspend fun startPlaybackV3(request: PlaybackStartRequestV3, expectedMetadataOwner: AuthScopeSnapshot? = null): ApiResult = diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt index 56df3db3e5..06c15e92c3 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SequencedPlayback.kt @@ -2,8 +2,6 @@ package org.siloserver.silo.repository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable @@ -80,10 +78,8 @@ class SequencedPlayback( @kotlin.concurrent.Volatile private var liveAttempts: Set = emptySet() private val auxiliaryHeaders = mutableMapOf() private val scopes = mutableMapOf() - private val _pending = MutableStateFlow>(emptyList()) - val pending = _pending.asStateFlow() - private val _sessions = MutableStateFlow>(emptySet()) - fun owns(sessionId: String): Boolean = sessionId in _sessions.value + @kotlin.concurrent.Volatile private var sessions: Set = emptySet() + fun owns(sessionId: String): Boolean = sessionId in sessions private suspend fun load(): List { if (entries == null) { entries = store.read(); publish() } @@ -94,9 +90,7 @@ class SequencedPlayback( private fun publish() { liveAttempts = entries.orEmpty().filter { !it.terminal && it.stop == null }.map { it.attemptId }.toSet() - _sessions.value = entries.orEmpty().mapNotNull { it.sessionId }.toSet() - _pending.value = entries.orEmpty().filter { !it.isTemporary && it.needsRecovery() } - .map { it.attemptId } + sessions = entries.orEmpty().mapNotNull { it.sessionId }.toSet() } private suspend fun save(entry: PlaybackJournalEntry) { val (temporary, durable) = (load().filterNot { it.attemptId == entry.attemptId } + entry).partition { it.isTemporary } @@ -193,7 +187,7 @@ class SequencedPlayback( withStableIdentity(null, expectedMetadataOwner, request.profileId) { guard -> val current = tokens.snapshotCurrentScope() ?: return@withStableIdentity failure("identity_unavailable", "Playback needs an authenticated profile.") - // Probe availability before admission; retained intents still fence new attempts. + // Probe availability before admission. val capabilityResult = api.capabilities(current) guard() val capability = when (capabilityResult) { @@ -204,26 +198,6 @@ class SequencedPlayback( if (!current.isSameIdentityAs(tokens.snapshotCurrentScope())) throw IdentityChanged() val live = authorities.snapshotDurableLoginAuthority() guard() - // A temporary identity fences (and settles) only the attempts it admitted itself. - val temporaryLoginId = current.credentialGenerationId?.let { TEMPORARY_LOGIN_PREFIX + it } - val fenceLoginId = temporaryLoginId ?: live?.loginId - suspend fun unresolved() = load().any { it.needsRecovery() && it.loginId == fenceLoginId && - it.serverId == current.serverId && it.profileId == current.profileId } - guard() - if (unresolved()) { - // Settle the earlier attempt first (replay an uncertain start, then stop it) so a - // crashed or rejected session never needs a manual recovery step before playing again. - val settled = try { if (temporaryLoginId != null) recoverTemporaryLocked(current, temporaryLoginId) else recoverLocked() } - catch (e: CancellationException) { throw e } - catch (e: IdentityChanged) { throw e } - catch (_: Exception) { failure("playback_storage", "Playback recovery storage is unavailable.") } - guard() - if (unresolved()) return@withStableIdentity when (settled) { - is ApiResult.Success -> failure("playback_pending", "A previous playback request needs recovery before starting again.") - is ApiResult.Error -> settled - is ApiResult.NetworkError -> settled - } - } if (capability == null) return@withStableIdentity failure("server_update_required", "Update the server to use v2 playback.") if (SEQUENCED_PROGRESS_FEATURE !in capability.features) return@withStableIdentity failure("playback_unavailable", "V2 playback is unavailable on this server.") if (!capability.allowed || capability.state != PlaybackCapabilityStateV2.AVAILABLE || capability.installationId.isNullOrBlank() || @@ -243,8 +217,15 @@ class SequencedPlayback( } guard() if (!current.isSameIdentityAs(tokens.snapshotCurrentScope())) throw IdentityChanged() + // Stop what earlier attempts left open, but never make this one wait on them: the + // server expires a session nobody stops, so a stop that fails now is retried next time. + try { + if (current.credentialGenerationId != null) settleTemporary(current, loginId) + else live?.let { settleDurable(it, account.id, capability.installationId) } + } catch (e: CancellationException) { throw e } catch (e: IdentityChanged) { throw e } catch (_: Exception) {} + guard() if (load().any { it.attemptId == request.playbackAttemptId }) - return@withStableIdentity failure("attempt_exists", "This playback attempt is already recorded. Use recovery.") + return@withStableIdentity failure("attempt_exists", "This playback attempt is already recorded.") guard() val entry = PlaybackJournalEntry(current.serverId, current.serverUrl, loginId, account.id, request.profileId, capability.installationId, request.playbackAttemptId, request.v2Body(capability.installationId)) @@ -258,7 +239,7 @@ class SequencedPlayback( } private suspend fun sendStart(entry: PlaybackJournalEntry, captured: AuthScopeSnapshot, - adoptForPlayer: Boolean = true, expectedMetadataOwner: AuthScopeSnapshot? = null): ApiResult = + expectedMetadataOwner: AuthScopeSnapshot?): ApiResult = withStableIdentity(entry, expectedMetadataOwner, entry.profileId) { guard -> var sentHeaders: Map = emptyMap() when (val result = api.start(captured, entry.start) { sentHeaders = it }) { @@ -284,8 +265,8 @@ class SequencedPlayback( // the decision's server_features lists only protocol-v3 plan features. val decision = decodePlaybackDecisionV2(result.data) guard() - val ready = if (adoptForPlayer) withAuxiliaryAuthority(decision, entry, captured, sentHeaders) else decision - if (adoptForPlayer) adopted += entry.attemptId + val ready = withAuxiliaryAuthority(decision, entry, captured, sentHeaders) + adopted += entry.attemptId publish() ApiResult.Success(ready) } catch (e: IdentityChanged) { throw e } catch (e: Exception) { ApiResult.NetworkError(e) } @@ -449,7 +430,7 @@ class SequencedPlayback( val entry = load().find { it.sessionId == sessionId } ?: return@withLock null stopEntry(entry) } - private suspend fun stopEntry(original: PlaybackJournalEntry): ApiResult { + private suspend fun stopEntry(original: PlaybackJournalEntry, attempts: Int = 3): ApiResult { if (original.terminal) return ApiResult.Success(Unit) var entry = original if (entry.stop == null) { @@ -459,7 +440,7 @@ class SequencedPlayback( } fun stopPending() = failure("identity_changed", "Playback authority changed; stop remains pending.") val captured = scope(entry) ?: return stopPending() - repeat(3) { attempt -> + repeat(attempts) { attempt -> if (scope(entry) == null) return stopPending() when (val result = api.stop(captured, requireNotNull(entry.sessionId), requireNotNull(entry.stop))) { is ApiResult.Success -> { @@ -473,75 +454,55 @@ class SequencedPlayback( } else if (result.code != 503) return result is ApiResult.NetworkError -> Unit } - if (attempt < 2) delay(250L * (attempt + 1)) + if (attempt < attempts - 1) delay(250L * (attempt + 1)) } - return failure("stop_pending", "Playback stop is pending. Retry from playback recovery.") + return failure("stop_pending", "Playback stop is pending. It is retried before the next playback starts.") } - suspend fun pendingForCurrentViewer(): Int = mutex.withLock { - val live = authorities.snapshotDurableLoginAuthority() ?: return@withLock 0 - load().count { it.needsRecovery() && - it.loginId == live.loginId && it.serverId == live.scope.serverId && - it.origin == live.scope.serverUrl && it.profileId == live.scope.profileId } - } - - /** Explicit recovery revalidates installation, canonical account, saved login and profile. Never autoplay. */ - suspend fun recover(): ApiResult = mutex.withLock { recoverLocked() } - /** - * [recoverLocked] for a temporary remote-playback identity, which has no saved login to - * recover under: settles the attempts this identity admitted (replay an uncertain start, - * then stop), using its own scope. + * Stops what this viewer's earlier attempts left open: a stop that never got through, a + * replan whose outcome was lost, or a session from before a restart. Attempts recorded + * under another address, account or installation of this server can never be acted on + * again, so they are dropped; the server expires their sessions. + * + * This runs on every start, so it is bounded: each stop gets one attempt, and the first + * that fails ends this pass. The rest wait for the next start rather than delay this one. */ - private suspend fun recoverTemporaryLocked(current: AuthScopeSnapshot, loginId: String): ApiResult { - for (entry in load().filter { it.needsRecovery() && it.loginId == loginId && - it.serverId == current.serverId && it.profileId == current.profileId }) { - if (scope(entry) == null) continue - if (entry.sessionId == null) { - when (val startResult = sendStart(entry, current, adoptForPlayer = false)) { - is ApiResult.Success -> Unit - is ApiResult.Error -> return startResult - is ApiResult.NetworkError -> return startResult - } + private suspend fun settleDurable(live: DurableLoginAuthority, accountId: String, installationId: String) { + for (entry in load().filter { it.needsRecovery() && it.loginId == live.loginId && + it.serverId == live.scope.serverId && it.profileId == live.scope.profileId }) { + if (entry.origin != live.scope.serverUrl || entry.accountId != accountId || entry.installationId != installationId) { + save(entry.copy(terminal = true, progress = null)) + continue } - val result = stopEntry(load().first { it.attemptId == entry.attemptId }) - if (result !is ApiResult.Success) return result - } - return ApiResult.Success(Unit) - } - - private suspend fun recoverLocked(): ApiResult { - val live = authorities.snapshotDurableLoginAuthority() - ?: return failure("identity_unavailable", "Sign in to recover playback.") - val capability = when (val result = api.capabilities(live.scope)) { - is ApiResult.Success -> result.data - is ApiResult.Error -> return result - is ApiResult.NetworkError -> return result - } - val account = when (val result = api.account(live.scope)) { - is ApiResult.Success -> result.data - is ApiResult.Error -> return result - is ApiResult.NetworkError -> return result - } - if (live != authorities.snapshotDurableLoginAuthority()) return failure("identity_changed", "The active viewer changed.") - for (entry in load().filter { it.needsRecovery() }) { - if (entry.loginId != live.loginId || entry.serverId != live.scope.serverId || entry.origin != live.scope.serverUrl || - entry.profileId != live.scope.profileId || entry.accountId != account.id || entry.installationId != capability.installationId) continue // Existing process attempts remain fenced after any identity transition. val oldScope = scopes[entry.attemptId] if (oldScope != null && !oldScope.isSameIdentityAs(live.scope)) continue scopes[entry.attemptId] = live.scope - if (entry.sessionId == null) { - when (val startResult = sendStart(entry, live.scope, adoptForPlayer = false)) { - is ApiResult.Success -> Unit - is ApiResult.Error -> return startResult - is ApiResult.NetworkError -> return startResult - } - } - val result = stopEntry(load().first { it.attemptId == entry.attemptId }) - if (result !is ApiResult.Success) return result + if (!settle(entry)) return + } + } + + /** [settleDurable] for a temporary remote-playback identity: only the attempts it admitted itself. */ + private suspend fun settleTemporary(current: AuthScopeSnapshot, loginId: String) { + for (entry in load().filter { it.needsRecovery() && it.loginId == loginId && + it.serverId == current.serverId && it.profileId == current.profileId }) { + if (scope(entry) != null && !settle(entry)) return + } + } + + /** + * Stops [entry]'s session. A start whose reply was lost has no session id to stop, and + * replaying it would open a session the server may never have created, so it is dropped + * and left for the server to expire. A stop that fails stays journaled for next time. + * Returns whether [entry] is settled. + */ + private suspend fun settle(entry: PlaybackJournalEntry): Boolean { + if (entry.sessionId == null) { + save(entry.copy(terminal = true, progress = null)) + return true } - return ApiResult.Success(Unit) + return stopEntry(entry, attempts = 1) is ApiResult.Success } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt index 52c76075bb..6276ae59a5 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/SequencedPlaybackTest.kt @@ -83,18 +83,21 @@ class SequencedPlaybackTest { } finally { c.close() } } - @Test fun recoveryRetriesLostStopWithExactBodyAndNoAutoplay() = runTest { + @Test fun laterStartsRetryALostStopWithExactBody() = runTest { val store = Store().apply { entries = listOf(entry()) } val deletes = mutableListOf() + var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { starts++; reply(decision("session-${starts + 1}"), HttpStatusCode.Created) } "/api/v2/playback/session-1" -> { assertEquals(HttpMethod.Delete, req.method) assertTrue(req.attributes[SingleAttemptAttributeKey]) deletes += req.body.toByteArray().decodeToString() - assertEquals(store.entries.single().stop, SiloJson.decodeFromString(deletes.last())) + assertEquals(store.entries.single { it.attemptId == "attempt-1" }.stop, + SiloJson.decodeFromString(deletes.last())) if (deletes.size == 1) throw IllegalStateException("lost stop reply") reply("""{"outcome":"stopped","stop_id":"$stopId"}""") } @@ -104,30 +107,37 @@ class SequencedPlaybackTest { try { val identity = Identity() val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } - assertIs>(runtime.recover()) + // One attempt per start: the lost reply leaves the stop for the next start. + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(1, deletes.size); assertFalse(store.entries.single { it.attemptId == "attempt-1" }.terminal) + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-3"))) assertEquals(2, deletes.size); assertEquals(deletes.first(), deletes.last()) - assertTrue(store.entries.single().terminal) + assertTrue(store.entries.single { it.attemptId == "attempt-1" }.terminal) } finally { c.close() } } - @Test fun unavailableStopSurvivesRestartAndIdentityReplacementCannotReplay() = runTest { - val store = Store().apply { entries = listOf(entry()) } + @Test fun unavailableStopNeverBlocksStartAndIdentityReplacementCannotReplay() = runTest { + val store = Store().apply { entries = listOf(entry(), entry().copy(attemptId = "attempt-0", sessionId = "session-0")) } val identity = Identity() - var deletes = 0 + var deletes = 0; var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) + "/api/v2/playback/start" -> { starts++; reply(decision("session-${starts + 1}"), HttpStatusCode.Created) } else -> { deletes++; reply("""{"code":"authority_unavailable","detail":"pending"}""", HttpStatusCode.ServiceUnavailable) } } } + fun earlier() = store.entries.single { it.attemptId == "attempt-1" } try { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } - assertEquals("stop_pending", assertIs(runtime.recover()).error) - assertEquals(3, deletes); assertFalse(store.entries.single().terminal) - val body = store.entries.single().stop + // The first failed stop ends the pass: one request, and the second stop waits. + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(1, deletes); assertFalse(earlier().terminal) + assertNull(store.entries.single { it.attemptId == "attempt-0" }.stop) + val body = earlier().stop identity.login = "login-2" val restarted = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { error("Must retain StopID") } - restarted.recover() - assertEquals(3, deletes); assertEquals(body, store.entries.single().stop) + assertIs>(restarted.start(request().copy(playbackAttemptId = "attempt-3"))) + assertEquals(1, deletes); assertEquals(body, earlier().stop); assertEquals(2, starts) } finally { c.close() } } @@ -145,25 +155,26 @@ class SequencedPlaybackTest { } finally { c.close() } } - @Test fun uncertainStartIsPersistedBeforeDispatchAndBlocksAnotherAttempt() = runTest { + @Test fun uncertainStartIsPersistedBeforeDispatchAndDroppedByTheNextAttempt() = runTest { val identity = Identity(); val store = Store(); var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) else -> { starts++ - assertEquals(store.entries.single().start.toString(), req.body.toByteArray().decodeToString()) - assertTrue(store.entries.single().start["file_id"]!!.jsonPrimitive.isString) + assertEquals(store.entries.last().start.toString(), req.body.toByteArray().decodeToString()) + assertTrue(store.entries.last().start["file_id"]!!.jsonPrimitive.isString) assertTrue(req.attributes[SingleAttemptAttributeKey]) - throw IllegalStateException("lost reply") + if (starts == 1) throw IllegalStateException("lost reply") + reply(decision("session-2"), HttpStatusCode.Created) } } } try { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } assertIs(runtime.start(request())) - // A later start first replays the exact retained attempt; a lost reply keeps the fence. - assertIs(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) - assertEquals(2, starts); assertEquals(listOf("attempt-1"), runtime.pending.value) + // The lost start is never replayed: the server expires whatever it may have opened. + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) + assertEquals(2, starts); assertTrue(store.entries.single { it.attemptId == "attempt-1" }.terminal) } finally { c.close() } } @@ -241,14 +252,14 @@ class SequencedPlaybackTest { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } val first = assertIs>(runtime.start(request())) assertEquals("subtitle_unavailable_in_version", first.data.terminal?.reason) - assertTrue(store.entries.single().terminal); assertTrue(runtime.pending.value.isEmpty()) + assertTrue(store.entries.single().terminal) // The refused attempt never fences the next start; a fresh attempt goes straight to the server. assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) assertEquals(2, starts) } finally { c.close() } } - @Test fun validationRejectionRetainsExactAttemptWithoutLegacyFallback() = runTest { + @Test fun validationRejectionNeitherFallsBackNorBlocksTheNextAttempt() = runTest { val identity = Identity(); val store = Store(); var starts = 0 val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) @@ -260,40 +271,39 @@ class SequencedPlaybackTest { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } val repository = PlaybackRepository(runtime) assertEquals(422, assertIs(repository.startPlaybackV3(request())).code) - // The retained attempt is replayed byte-for-byte before a new one may start; the same refusal fences again. - assertEquals(422, assertIs(repository.startPlaybackV3(request())).code) - assertEquals(2, starts); assertEquals(request().v2Body(installation), store.entries.single().start) + assertEquals(request().v2Body(installation), store.entries.single().start) + // The refused attempt is dropped, not replayed; the next one goes straight to the server. + assertEquals(422, assertIs(repository.startPlaybackV3(request().copy(playbackAttemptId = "attempt-2"))).code) + assertEquals(2, starts) } finally { c.close() } } - @Test fun recoveryReplayStaysPendingWhenFirstStopJournalWriteFails() = runTest { + @Test fun stopThatCannotBeJournaledIsRetriedByALaterStart() = runTest { val identity = Identity() - val store = Store().apply { entries = listOf(entry().copy(sessionId = null)); failStop = true } + val store = Store().apply { entries = listOf(entry()); failStop = true } var starts = 0; var deletes = 0 - val decision = """{"protocol_version":3,"server_features":["playback_plan_v3","neutral_playback_v3_contract_v1","sequenced_progress_v1"],"outcome":"playable","session_id":"session-1","playback_plan":{"plan_id":"plan","session_id":"session-1","delivery":"original_http","stream":{"url":"/api/v2/stream/session-1","protocol":"http_progressive"},"decision_reason":"direct"}}""" val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) - "/api/v2/playback/start" -> { starts++; reply(decision, HttpStatusCode.Created) } + "/api/v2/playback/start" -> { starts++; reply(decision("session-${starts + 1}"), HttpStatusCode.Created) } "/api/v2/playback/session-1" -> { deletes++ - assertEquals(store.entries.single().stop, SiloJson.decodeFromString(req.body.toByteArray().decodeToString())) + assertEquals(store.entries.single { it.attemptId == "attempt-1" }.stop, + SiloJson.decodeFromString(req.body.toByteArray().decodeToString())) reply("""{"outcome":"stopped","stop_id":"$stopId"}""") } else -> error("Unexpected request") } } try { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, store) { stopId } - val repository = PlaybackRepository(runtime) - assertEquals("playback_storage", assertIs(repository.recoverPlayback()).error) + // The stop cannot be journaled, so it is never sent; the new start still goes ahead. + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) assertEquals(1, starts); assertEquals(0, deletes) - assertEquals(listOf("attempt-1"), runtime.pending.value) - assertEquals("session-1", store.entries.single().sessionId) - assertEquals("playback_storage", assertIs(runtime.start(request().copy(playbackAttemptId = "another"))).error) + assertFalse(store.entries.single { it.attemptId == "attempt-1" }.terminal) store.failStop = false - assertIs>(repository.recoverPlayback()) - assertEquals(1, starts); assertEquals(1, deletes) - assertTrue(store.entries.single().terminal); assertTrue(runtime.pending.value.isEmpty()) + assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-3"))) + assertEquals(2, starts); assertEquals(1, deletes) + assertTrue(store.entries.single { it.attemptId == "attempt-1" }.terminal) } finally { c.close() } } @@ -304,6 +314,7 @@ class SequencedPlaybackTest { attemptCount = 1, positionSeconds = 42.0, selectedTracks = SelectedPlaybackTracksV3(), capabilities = ClientCodecCapabilities(), clientPlaybackContext = request().clientPlaybackContext, ) + private fun decision(session: String) = adoptedDecision.replace("session-1", session) private val adoptedDecision = """{"protocol_version":3,"server_features":["playback_plan_v3","neutral_playback_v3_contract_v1","sequenced_progress_v1"],"outcome":"playable","session_id":"session-1","playback_plan":{"plan_id":"plan-0001","plan_attempt_key":"plan-key-0001","session_id":"session-1","delivery":"original_http","stream":{"url":"/api/v2/stream/session-1","protocol":"http_progressive"},"decision_reason":"direct","requested_media_file_id":"42","effective_media_file_id":"42","source":{"media_file_id":"42"}}}""" @Test fun uncertainReplanIsDurableAndCannotReplayOrRebase() = runTest { @@ -499,13 +510,12 @@ class SequencedPlaybackTest { assertIs>(runtime.progress("session-1", 12.0, false)) assertIs>(runtime.stop("session-1")) assertTrue(store.entries.isEmpty()) - assertTrue(runtime.pending.value.isEmpty()) } finally { c.close() } } - @Test fun uncertainRemotePlaybackStartIsSettledBeforeTheNextOne() = runTest { + @Test fun uncertainRemotePlaybackStartNeverBlocksTheNextOne() = runTest { val identity = remotePlaybackIdentity() - val starts = mutableListOf(); val deletes = mutableListOf() + val starts = mutableListOf() val c = client { req -> when (req.url.encodedPath) { "/api/v2/playback/capabilities" -> reply(caps()) "/api/v2/account/me" -> reply(account) @@ -513,23 +523,18 @@ class SequencedPlaybackTest { val attempt = SiloJson.parseToJsonElement(req.body.toByteArray().decodeToString()) .jsonObject["playback_attempt_id"]!!.jsonPrimitive.content starts += attempt - // The first start's reply is lost; its replay and the next start succeed. + // The first start's reply is lost; the next start succeeds. if (starts.size == 1) throw IllegalStateException("lost reply") - reply(adoptedDecision.replace("session-1", "session-$attempt"), HttpStatusCode.Created) - } - "/api/v2/playback/session-attempt-1" -> { - deletes += "attempt-1" - reply("""{"outcome":"stopped","stop_id":"$stopId"}""") + reply(decision("session-$attempt"), HttpStatusCode.Created) } else -> error("Unexpected request ${req.url}") } } try { val runtime = SequencedPlayback(PlaybackV2Api(c, ApiV2Gate.Unrestricted), identity, identity, Store()) { stopId } assertIs(runtime.start(request())) - // The phone sends another title: the lost start is replayed and stopped first. + // The phone sends another title: the lost start is dropped, not replayed. assertIs>(runtime.start(request().copy(playbackAttemptId = "attempt-2"))) - assertEquals(listOf("attempt-1", "attempt-1", "attempt-2"), starts) - assertEquals(listOf("attempt-1"), deletes) + assertEquals(listOf("attempt-1", "attempt-2"), starts) } finally { c.close() } } @@ -601,7 +606,6 @@ class SequencedPlaybackTest { assertEquals("identity_changed", assertIs(runtime.stop("session-1")).error) // Nothing to recover later: the credentials are gone for good. assertTrue(store.entries.isEmpty()) - assertTrue(runtime.pending.value.isEmpty()) } finally { c.close() } } From d71824dc433cb3030d1630ae0718e79c9f2394b0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:51:04 -0400 Subject: [PATCH 31/87] fix(tv): apply quality changes from the phone remote (#485) The TV advertises the server's quality ladder to the phone remote, but applied a remote pick with selectVideoQuality, which expects Media3 "group:track" ids. It could not parse a ladder label such as "1080p" and returned without changing anything. Route the remote command through switchQuality, the same server replan the HUD's Quality menu uses, and delete the Media3 override path. Co-authored-by: Claude Opus 5.5 --- .../tv/ui/screens/player/TvPlayerScreen.kt | 54 +++---------------- .../tv/ui/screens/player/TvPlayerViewModel.kt | 4 -- 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index 313c77aa10..6869761cd2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -146,6 +146,7 @@ import org.siloserver.silo.tv.R import org.siloserver.silo.tv.cast.SiloCastVolumeState import org.siloserver.silo.tv.cast.TvSiloCastPlayerAdapter import org.siloserver.silo.tv.cast.TvSiloCastReceiver +import org.siloserver.silo.tv.data.preferences.PlaybackQuality import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvLoadingScreen import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts @@ -514,7 +515,6 @@ fun TvPlayerScreen( contentId = contentId, watchParty = watchParty, mediaController = mediaController, - sessionPlayer = sessionPlayer, playbackSpeed = playbackSpeed, subtitleDelayMs = subtitleDelayMs, hdrEnabled = hdrEnabled, @@ -2569,7 +2569,6 @@ private fun TvSiloCastPlayerRegistration( contentId: String, watchParty: TvWatchPartyScreenController?, mediaController: MediaController?, - sessionPlayer: Player?, playbackSpeed: Double, subtitleDelayMs: Int, hdrEnabled: Boolean, @@ -2580,7 +2579,6 @@ private fun TvSiloCastPlayerRegistration( val latestSiloCastHdrEnabled by rememberUpdatedState(hdrEnabled) val latestSiloCastSubtitleAppearance by rememberUpdatedState(subtitleAppearance) val latestSiloCastMediaController by rememberUpdatedState(mediaController) - val latestSiloCastSessionPlayer by rememberUpdatedState(sessionPlayer) DisposableEffect(siloCastReceiver, viewModel, contentId) { // In a Watch Party a phone's transport is one more input: the TV // membership's permissions decide, so a host's phone controls the @@ -2622,15 +2620,14 @@ private fun TvSiloCastPlayerRegistration( latestSiloCastMediaController?.playbackParameters = PlaybackParameters(applied.toFloat()) } }, + // The phone offers the same server quality ladder as the HUD, so its pick + // is applied the same way: the server re-plans the stream at that rung. setQuality = { qualityId -> - val player = latestSiloCastMediaController ?: latestSiloCastSessionPlayer - if (player != null && selectVideoQuality(player, qualityId)) { - val resolution = viewModel.uiState.value.videoQualities - .firstOrNull { it.id == qualityId } - ?.resolution - viewModel.onVideoQualitySelectionApplied(resolution) - watchParty?.onQualityChanged() - } + switchTvPlaybackQuality( + viewModel, + watchParty, + qualityId.takeUnless { it == VIDEO_QUALITY_AUTO_ID } ?: PlaybackQuality.Auto.wireValue, + ) }, setVideoGravity = { value -> viewModel.onVideoFillModeChanged(value.toSiloCastVideoFillMode()) @@ -3609,41 +3606,6 @@ private fun TvPlayerClockScope( content(clock) } -/** - * Apply (or clear, for [VIDEO_QUALITY_AUTO_ID]) a video quality override on the - * player. Mirrors [AudioTrackManager]'s override approach but targets a specific - * format *within* the video group. This is a real Media3 track switch. - */ -internal fun selectVideoQuality(player: Player, id: String): Boolean { - if (id == VIDEO_QUALITY_AUTO_ID) { - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .clearOverridesOfType(C.TRACK_TYPE_VIDEO) - .build() - return true - } - val parts = id.split(":") - val groupOrdinal = parts.getOrNull(0)?.toIntOrNull() ?: return false - val trackIndex = parts.getOrNull(1)?.toIntOrNull() ?: return false - var ordinal = 0 - for (group in player.currentTracks.groups) { - if (group.type != C.TRACK_TYPE_VIDEO) continue - if (ordinal == groupOrdinal) { - val mediaGroup = group.mediaTrackGroup - if (trackIndex !in 0 until mediaGroup.length) return false - player.trackSelectionParameters = player.trackSelectionParameters - .buildUpon() - .setOverrideForType( - androidx.media3.common.TrackSelectionOverride(mediaGroup, trackIndex), - ) - .build() - return true - } - ordinal++ - } - return false -} - /** * The overlay layer stacked above the player surface: lifecycle notice, remote * message toast, Watch Party indicator, the Up Next diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 3d7e75b90a..74536f77e1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -5229,10 +5229,6 @@ class TvPlayerViewModel( _uiState.update { it.copy(videoFillMode = mode) } } - fun onVideoQualitySelectionApplied(resolution: String?) { - _uiState.update { it.copy(selectedFileResolution = resolution) } - } - /** * Switch the in-player video quality (tvOS ApplePlaybackQuality parity): pin * a session-level [qualityOverride] and request a protocol-v3 replan at the From 2a5018388b87d33a24c6992e4e0b9ca424e819f2 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:58:43 -0400 Subject: [PATCH 32/87] refactor(playback): remove dead playback code (#484) Remove playback declarations nothing references and production functions only tests called: unused fields and helpers, the old TV playback selector row, an unused HDR mode helper, session teardown helpers no caller uses, the episode handoff string codec, and TV quality, auto-subtitle and start-audio helpers superseded by the server quality ladder, resolveTvAutoSubtitleIdentity and resolveTvInitialAudioTrackIndex. Closed-caption ranking tests move onto the live ranking path instead of being dropped. Co-authored-by: Claude Opus 5.5 --- .../silo/common/player/HdrModeSelection.kt | 18 - .../player/PlaybackCapabilityDetector.kt | 10 - .../common/player/PlaybackSessionLifecycle.kt | 57 ---- .../common/player/PlaybackSessionManager.kt | 18 - .../common/player/PlaybackTeardownGate.kt | 3 - .../player/video/EpisodeSelectionHandoff.kt | 26 -- .../common/player/HdrModeSelectionTest.kt | 33 -- ...ackPublicationSettlementIntegrationTest.kt | 2 +- .../player/PlaybackSessionLifecycleTest.kt | 96 +----- .../PlaybackSessionManagerStagedReplanTest.kt | 1 - .../video/EpisodeSelectionHandoffTest.kt | 61 ---- .../ui/screens/player/PlayerSheetSupport.kt | 15 - .../ui/screens/player/PlayerViewModel.kt | 10 - .../audiobook/TvAudiobookPlayerScreen.kt | 5 +- .../ui/screens/detail/TvPlaybackFormatting.kt | 6 +- .../screens/detail/TvPlaybackSelectorRow.kt | 309 ------------------ .../tv/ui/screens/player/TvPlayerScreen.kt | 73 ----- .../tv/ui/screens/player/TvPlayerViewModel.kt | 75 ----- .../player/TvSubtitleTransactionAdapter.kt | 23 -- .../screens/player/TvVideoPlaybackStarter.kt | 14 - .../detail/TvPlaybackFormattingTest.kt | 5 - .../screens/player/PlayerTrackEntriesTest.kt | 151 ++------- .../TvEpisodeHandoffPlaybackStartTest.kt | 36 -- .../player/TvPlayNextSelectionHandoffTest.kt | 9 - .../TvPlayerSubtitleIntegrationPolicyTest.kt | 8 +- .../TvSubtitleSettlementOwnershipTest.kt | 20 +- .../silo/model/playback/PlaybackModels.kt | 6 - .../silo/network/apiv2/PlaybackV2Api.kt | 1 - 28 files changed, 44 insertions(+), 1047 deletions(-) delete mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/HdrModeSelection.kt delete mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/HdrModeSelectionTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/HdrModeSelection.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/HdrModeSelection.kt deleted file mode 100644 index 3173ab8fac..0000000000 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/HdrModeSelection.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.siloserver.silo.common.player - -enum class HdrType { SDR, HDR10, HDR10_PLUS, HLG, DOLBY_VISION } - -/** - * Pure HDR target selection: honor the content's HDR type only when the display - * advertises it; otherwise fall back to SDR (tone-mapped). The Android read of - * Display.Mode.getSupportedHdrTypes (API-34+) feeds [displaySupported] at the - * call site; below API-34 the platform negotiates HDR implicitly and the caller - * passes an empty set so this returns SDR — we never force an unsupported mode. - */ -object HdrModeSelection { - fun choose(contentHdr: HdrType, displaySupported: Set): HdrType = when { - contentHdr == HdrType.SDR -> HdrType.SDR - contentHdr in displaySupported -> contentHdr - else -> HdrType.SDR - } -} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt index 03c0f3daf3..4c97839246 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt @@ -4,7 +4,6 @@ import android.app.UiModeManager import android.content.Context import android.content.res.Configuration import android.media.MediaCodecList -import android.media.MediaFormat import android.os.Build import androidx.annotation.OptIn import androidx.compose.runtime.RememberObserver @@ -42,7 +41,6 @@ import org.siloserver.silo.model.playback.PlaybackTransformationExecutor import org.siloserver.silo.model.playback.PlaybackTransformationV3 import org.siloserver.silo.model.playback.PlaybackOutputContext import org.siloserver.silo.model.playback.AudioPassthroughCapabilities -import org.siloserver.silo.model.playback.AudioPassthroughEntry import kotlinx.coroutines.flow.StateFlow import org.siloserver.silo.libass.LibassBridge @@ -175,13 +173,6 @@ class PlaybackCapabilityDetector( } } - /** Decoder-only HDR facts from the most recent [detect], for diagnostics. */ - @Volatile - private var lastDecoderHdr: HdrCapabilities? = null - - /** Decoder-only HDR support independent of the attached display. */ - val decoderHdrCapabilities: HdrCapabilities? - get() = lastDecoderHdr /** * Inspect the resolved [Tracks] object (emitted by `Player.Listener.onTracksChanged`) * and declare whether direct play can proceed. Looks at the selected video @@ -359,7 +350,6 @@ class PlaybackCapabilityDetector( display = displayProbe.hdr, ).withDolbyVisionPolicy(dolbyVision) lastDisplayProbe = displayProbe - lastDecoderHdr = codecProbe.hdr.withDolbyVisionPolicy(dolbyVision) val platformAudio = detectPlatformSoftwareAudioCodecs() val ffmpegAudio = if (ffmpegAvailable) { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt index 75683ff7ad..82cd1d3c73 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt @@ -102,8 +102,6 @@ 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, @@ -358,20 +356,6 @@ class PlaybackSessionLifecycle( true } - suspend fun confirmActiveSessionPublication(sessionId: String): Boolean = - settlePendingPublicationIfCurrent( - sessionId = sessionId, - confirm = true, - settleManager = { true }, - ) - - suspend fun rollbackUnpublishedActiveSession(sessionId: String): Boolean = - settlePendingPublicationIfCurrent( - sessionId = sessionId, - confirm = false, - settleManager = { true }, - ) - private fun captureActiveSessionSnapshot(): ActiveSessionSnapshot = ActiveSessionSnapshot( state = _state.value, @@ -667,47 +651,6 @@ 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() - } - /** * Waits for a queued [stopAsync] teardown (final progress + stopSession) * to finish. Callers that revoke the credentials those requests ride on diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt index d1949c6237..01bfccd455 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt @@ -1246,24 +1246,6 @@ open class PlaybackSessionManager( return true } - /** - * Rolls back whatever deferred publication this manager still holds. - * - * The lifecycle's `rollbackCurrentPendingPublication` can only settle a - * publication the *lifecycle* knows about, and reports success when it has - * none — but the manager's is created first, so a cancellation between the - * two leaves this side pending with no owner. Callers about to start fresh - * content should clear both. - * - * Returns true when nothing is pending or the rollback succeeded. - */ - suspend fun rollbackCurrentPendingVideoPublication(): Boolean { - val pendingSessionId = videoAttemptMutex.withLock { - pendingVideoPublication?.replacement?.sessionId - } ?: return true - return rollbackUnpublishedVideoSession(pendingSessionId) - } - /** * Drops manager ownership of [sessionId] and stops it. * diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackTeardownGate.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackTeardownGate.kt index 2e1340ace3..3caeac9738 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackTeardownGate.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackTeardownGate.kt @@ -30,9 +30,6 @@ class PlaybackTeardownGate(private val lifecycle: PlaybackSessionLifecycle) { private val claimed = AtomicBoolean(false) - /** True once some route has taken ownership of this screen's teardown. */ - val isClaimed: Boolean get() = claimed.get() - /** * Ordered teardown, awaited before navigating to the next item. * diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoff.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoff.kt index d75870335b..c5cb42815d 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoff.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoff.kt @@ -1,9 +1,5 @@ package org.siloserver.silo.common.player.video -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.siloserver.silo.common.player.normalizedSubtitleCodecFamily import org.siloserver.silo.model.catalog.AudioTrack import org.siloserver.silo.model.catalog.FileVersion @@ -17,7 +13,6 @@ import org.siloserver.silo.playback.subtitleLabelIndicatesHearingImpaired * episode-local identity: target IDs and subtitle indexes are resolved only * after the next episode's catalog detail is available. */ -@Serializable data class EpisodeSelectionHandoff( val source: EpisodeSourceIntent? = null, val subtitle: EpisodeSubtitleIntent = EpisodeSubtitleIntent.auto(), @@ -35,10 +30,8 @@ data class EpisodeSelectionHandoff( val audio: EpisodeAudioIntent = EpisodeAudioIntent.auto(), ) -@Serializable enum class EpisodeAudioMode { AUTO, TRACK } -@Serializable data class EpisodeAudioIntent( val mode: EpisodeAudioMode, val language: String? = null, @@ -171,7 +164,6 @@ fun resolveAudioSelectionAcrossVersions( ) } -@Serializable data class EpisodeSourceIntent( val resolution: String, val videoCodec: String? = null, @@ -179,13 +171,10 @@ data class EpisodeSourceIntent( val container: String? = null, ) -@Serializable enum class EpisodeDynamicRange { SDR, HDR, DOLBY_VISION } -@Serializable enum class EpisodeSubtitleMode { AUTO, OFF, TRACK } -@Serializable data class EpisodeSubtitleIntent( val mode: EpisodeSubtitleMode, val language: String? = null, @@ -290,15 +279,6 @@ fun resolveEpisodeSubtitleIntent( ) } -fun encodeEpisodeSelectionHandoff(handoff: EpisodeSelectionHandoff): String = - episodeSelectionHandoffJson.encodeToString(handoff) - -fun decodeEpisodeSelectionHandoff(value: String?): EpisodeSelectionHandoff? = - value?.takeIf { it.isNotBlank() }?.let { encoded -> - runCatching { episodeSelectionHandoffJson.decodeFromString(encoded) } - .getOrNull() - } - private fun FileVersion.matchesEpisodeVideoCodec(intent: EpisodeSourceIntent): Boolean = intent.videoCodec != null && normalizedEpisodeToken(codecVideo ?: videoTracks?.firstOrNull()?.codec) == intent.videoCodec @@ -404,9 +384,3 @@ private fun normalizedEpisodeToken(value: String?): String? = ?.lowercase() ?.filter(Char::isLetterOrDigit) ?.takeIf { it.isNotEmpty() } - -private val episodeSelectionHandoffJson = Json { - encodeDefaults = false - explicitNulls = false - ignoreUnknownKeys = true -} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/HdrModeSelectionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/HdrModeSelectionTest.kt deleted file mode 100644 index a523854dc0..0000000000 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/HdrModeSelectionTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.siloserver.silo.common.player - -import kotlin.test.Test -import kotlin.test.assertEquals - -class HdrModeSelectionTest { - @Test - fun prefersExactContentHdrTypeWhenDisplaySupportsIt() { - val result = HdrModeSelection.choose( - contentHdr = HdrType.HDR10, - displaySupported = setOf(HdrType.HDR10, HdrType.DOLBY_VISION), - ) - assertEquals(HdrType.HDR10, result) - } - - @Test - fun fallsBackToSdrWhenDisplayLacksContentHdrType() { - val result = HdrModeSelection.choose( - contentHdr = HdrType.DOLBY_VISION, - displaySupported = setOf(HdrType.HDR10), - ) - assertEquals(HdrType.SDR, result) - } - - @Test - fun sdrContentStaysSdr() { - val result = HdrModeSelection.choose( - contentHdr = HdrType.SDR, - displaySupported = setOf(HdrType.HDR10), - ) - assertEquals(HdrType.SDR, result) - } -} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackPublicationSettlementIntegrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackPublicationSettlementIntegrationTest.kt index c4d7549efe..249a03ba1a 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackPublicationSettlementIntegrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackPublicationSettlementIntegrationTest.kt @@ -279,7 +279,7 @@ class PlaybackPublicationSettlementIntegrationTest { // A stale B cleanup arriving after the preflight is harmless. assertFalse(harness.manager.rollbackUnpublishedVideoSession("session-b")) - assertFalse(harness.lifecycle.rollbackUnpublishedActiveSession("session-b")) + assertFalse(harness.lifecycle.settlePendingPublicationIfCurrent("session-b", confirm = false) { true }) assertEquals("session-a", harness.manager.activeSessionIdForTest()) assertEquals("session-a", harness.lifecycle.activeSessionId()) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt index ebe8276b21..0ae5d84942 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt @@ -259,7 +259,7 @@ class PlaybackSessionLifecycleTest { ), ) - assertTrue(lifecycle.confirmActiveSessionPublication("sess-first")) + assertTrue(lifecycle.settlePendingPublicationIfCurrent("sess-first", confirm = true) { true }) assertEquals( "sess-first", (lifecycle.state.value as SessionState.Active).session.sessionId, @@ -302,100 +302,6 @@ 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/siloserver/silo/common/player/PlaybackSessionManagerStagedReplanTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionManagerStagedReplanTest.kt index 3d0b857905..af715bd9e0 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionManagerStagedReplanTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionManagerStagedReplanTest.kt @@ -1148,7 +1148,6 @@ class PlaybackSessionManagerStagedReplanTest { ) assertFalse(harness.manager.rollbackUnpublishedVideoSession("s1")) assertFalse(harness.manager.confirmVideoSessionPublication("s1")) - assertTrue(harness.manager.rollbackCurrentPendingVideoPublication()) assertEquals( emptyMap(), harness.stoppedSessions.groupingBy { it }.eachCount(), diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoffTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoffTest.kt index b17c6bddf6..b4bcc0253e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoffTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/EpisodeSelectionHandoffTest.kt @@ -111,44 +111,6 @@ class EpisodeSelectionHandoffTest { assertNull(resolveEpisodeSourceIntent(captureEpisodeSourceIntent(source), targets)) } - @Test - fun sourceIntentNeverSerializesTheOriginalFileId() { - val handoff = EpisodeSelectionHandoff( - source = captureEpisodeSourceIntent( - version(4242, "2160p", "hevc", hdr = true, container = "mkv").copy( - fileName = "source-file-name.mkv", - filePath = "/private/source-file-path.mkv", - ), - ), - subtitle = captureEpisodeSubtitleIntent( - selectedTrackIndex = 31, - subtitles = listOf( - PlayerSubtitleInfo( - index = 31, - language = "en", - codec = "srt", - label = "English", - source = "downloaded", - url = "/private/source-subtitle.srt", - downloadId = 3131, - mediaTrackId = "source-media-track-id", - ), - ), - ), - ) - - val encoded = encodeEpisodeSelectionHandoff(handoff) - - assertFalse(encoded.contains("4242")) - assertFalse(encoded.contains("31")) - assertFalse(encoded.contains("3131")) - assertFalse(encoded.contains("source-file-name")) - assertFalse(encoded.contains("source-file-path")) - assertFalse(encoded.contains("source-subtitle")) - assertFalse(encoded.contains("source-media-track-id")) - assertTrue(encoded.contains("2160p")) - } - @Test fun explicitSubtitleMatchesSemanticsAtADifferentTargetIndex() { val intent = captureEpisodeSubtitleIntent( @@ -331,29 +293,6 @@ class EpisodeSelectionHandoffTest { assertTrue(resolved.intentSpecified) } - @Test - fun malformedPayloadDecodesToNull() { - assertNull(decodeEpisodeSelectionHandoff("{not-json")) - } - - @Test - fun encodedPayloadRoundTripsSemanticIntentAndRestoresAutoDefault() { - val handoff = EpisodeSelectionHandoff( - source = captureEpisodeSourceIntent( - version(101, "2160p", "hevc", hdr = true, container = "mkv"), - ), - subtitle = EpisodeSubtitleIntent( - mode = EpisodeSubtitleMode.TRACK, - language = "en", - codecFamily = "subrip", - external = true, - ), - ) - - assertEquals(handoff, decodeEpisodeSelectionHandoff(encodeEpisodeSelectionHandoff(handoff))) - assertEquals(EpisodeSubtitleIntent.auto(), decodeEpisodeSelectionHandoff("{}")?.subtitle) - } - private fun version( fileId: Int, resolution: String, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt index 1ca7512b1b..972295c985 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt @@ -289,21 +289,6 @@ internal fun PlayerSheetCard( ) } -@Composable -internal fun PlayerSheetSectionLabel( - text: String, - modifier: Modifier = Modifier, -) { - Text( - text = text.uppercase(), - color = Color.White.copy(alpha = 0.52f), - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.8.sp, - modifier = modifier.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 8.dp), - ) -} - @Composable internal fun PlayerSheetDivider(modifier: Modifier = Modifier) { Box( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 027540a8d2..a6ca957ef0 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -270,16 +270,6 @@ internal fun selectedAudioTrackOrdinal( audioTracks: List, ): Int = selectedServerIndex.takeIf { it in audioTracks.indices } ?: 0 -private fun SubtitleIdentity.serverTrackIndexForMobile(): Int = when (this) { - SubtitleIdentity.Off -> -1 - is SubtitleIdentity.ServerSidecar -> serverIndex - is SubtitleIdentity.ServerBurnIn -> serverIndex - is SubtitleIdentity.Embedded -> serverIndex - is SubtitleIdentity.Downloaded, - is SubtitleIdentity.LocalMedia3, - -> -1 -} - class PlayerViewModel( private val videoPlaybackCoordinator: VideoPlaybackSessionCoordinator, private val catalogRepository: CatalogRepository, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt index 9922a9ca71..4f3d26af1b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt @@ -560,10 +560,7 @@ internal data class TvAudiobookPlayerMetrics( val transportPrimaryButtonWidthDp: Int, val transportPrimaryButtonHeightDp: Int, val transportButtonSpacingDp: Int, -) { - val transportRowWidthDp: Int = - (transportButtonSizeDp * 4) + transportPrimaryButtonWidthDp + (transportButtonSpacingDp * 4) -} +) internal fun tvAudiobookPlayerMetrics( maxWidthDp: Int, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormatting.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormatting.kt index 1f6ed27d27..7dfabe946e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormatting.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormatting.kt @@ -512,7 +512,7 @@ object TvPlaybackFormatting { /** * Inputs needed to preview what the player's subtitle auto-resolver would * land on, so the row can annotate "Auto" with the concrete track (or - * "None"). Mirrors the subset of [TvPlayerViewModel.resolveAutoSubtitleSelection]'s + * "None"). Mirrors the subset of `resolveTvAutoSubtitleIdentity`'s * inputs the detail page can supply. Analogue of silo-apple's * `DetailPlaybackFormatting.SubtitleAutoContext`. */ @@ -532,7 +532,7 @@ object TvPlaybackFormatting { * Selector VALUE label for Subtitles. * * When [autoContext] is supplied, the Auto preview is resolved through the - * SAME rules the player runs at launch ([TvPlayerViewModel.resolveAutoSubtitleSelection], + * SAME rules the player runs at launch (`resolveTvAutoSubtitleIdentity`, * mirrored in [autoResolvedSubtitle]) — preferred-language / mode / * forced-subs — so the row shows exactly what will play, including * "Auto - None" when Auto resolves to no subtitles. It never consults the @@ -578,7 +578,7 @@ object TvPlaybackFormatting { * subtitle auto-resolver would pick for the no-override case, or `null` * when Auto resolves to no subtitles (mode off, "no subs" preference, no * language match, or audio already in the preferred language). Mirrors - * [TvPlayerViewModel.resolveAutoSubtitleSelection] branch-for-branch over + * `resolveTvAutoSubtitleIdentity` branch-for-branch over * the catalog [SubtitleTrack] list. A resolver `NoChange`/`Disable` maps to * `null` here: the detail page starts from nothing playing, so both mean * "no subtitle". diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackSelectorRow.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackSelectorRow.kt index 14192e2beb..3b629ebd19 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackSelectorRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackSelectorRow.kt @@ -1,33 +1,13 @@ package org.siloserver.silo.tv.ui.screens.detail -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -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.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Tv import androidx.compose.runtime.Composable -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.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import org.siloserver.silo.model.catalog.PlaybackVariant import org.siloserver.silo.model.catalog.playbackEditions import org.siloserver.silo.model.catalog.hasEditionChoices @@ -265,292 +245,3 @@ internal fun TvPlaybackActionSelectors( triggerStyle = TvSelectorTriggerStyle.CircularAction, ) } - -@Composable -fun TvPlaybackSelectorRow( - versions: List, - currentVersion: FileVersion?, - selectedVersionFileId: Int?, - selectedAudioTrackIndex: Int?, - selectedSubtitleTrackIndex: Int?, - automaticAudioTrackOrdinal: Int? = null, - automaticAudioResolutionKnown: Boolean = false, - // Cascaded subtitle prefs (profile-sourced) that let the Subtitles pill - // preview the concrete track Auto will resolve to — the same rules the - // player runs at launch. - preferredSubtitleLanguage: String?, - subtitleMode: String?, - showForcedSubtitles: Boolean, - onSelectVersion: (Int?) -> Unit, - onSelectAudioTrack: (Int?) -> Unit, - onSelectSubtitleTrack: (Int?) -> Unit, - modifier: Modifier = Modifier, - /** Give every visible segment an equal, fixed share of the supplied width. */ - stretchSegments: Boolean = false, -) { - // hasAnySelector — only show once an effective playable version is known. - if (currentVersion == null) return - - val editions = TvPlaybackFormatting.editions(versions) - val currentEdition = TvPlaybackFormatting.currentEdition(versions, currentVersion) - // Scope the version list to the current edition when editions are present - // (mirrors Apple's `scopedVersions`). - val scopedVersions = if (editions.size > 1 && currentEdition != null) { - currentEdition.versions - } else { - versions - } - val editionOptions = editions.map { edition -> - val count = edition.versions.size - TvSelectorOption( - key = "edition:${edition.id}", - title = edition.label, - detail = "$count version${if (count == 1) "" else "s"}", - selected = currentEdition?.id == edition.id, - onSelect = { onSelectVersion(edition.versions.firstOrNull()?.fileId) }, - ) - } - val versionOptions = buildList { - add( - TvSelectorOption( - key = "version:auto", - title = "Auto", - detail = "Best match for this device", - selected = selectedVersionFileId == null, - onSelect = { onSelectVersion(null) }, - ), - ) - scopedVersions.forEach { version -> - add( - TvSelectorOption( - key = "version:${version.fileId}", - title = TvPlaybackFormatting.versionShortLabel(version), - detail = TvPlaybackFormatting.versionDetailLabel(version), - selected = selectedVersionFileId == version.fileId, - onSelect = { onSelectVersion(version.fileId) }, - ), - ) - } - } - // Hoisted out of the buildList blocks below: these are the REAL choices, and - // their counts — not the assembled menu row counts, which carry Auto/Off — - // decide whether each pill is interactive. - val formattedAudioOptions = - TvPlaybackFormatting.audioOptions(currentVersion, selectedAudioTrackIndex) - val formattedSubtitleOptions = TvPlaybackFormatting.subtitleOptions( - currentVersion, - selectedSubtitleTrackIndex, - preferredLanguage = preferredSubtitleLanguage, - ) - val audioSelectorOptions = buildList { - add( - TvSelectorOption( - key = "audio:auto", - title = "Auto", - detail = "Use the file default track", - selected = isAudioSelectorOptionSelected(null, selectedAudioTrackIndex), - onSelect = { onSelectAudioTrack(null) }, - ), - ) - if (formattedAudioOptions.isEmpty()) { - add( - TvSelectorOption( - key = "audio:unknown", - title = "Unknown", - detail = "", - selected = false, - onSelect = {}, - enabled = false, - ), - ) - } else { - formattedAudioOptions.forEach { option -> - add( - TvSelectorOption( - key = "audio:${option.ordinal}", - title = option.title, - detail = option.detail, - selected = option.isSelected, - onSelect = { onSelectAudioTrack(option.ordinal) }, - ), - ) - } - } - } - val subtitleSelectorOptions = buildList { - add( - TvSelectorOption( - key = "subtitle:auto", - title = "Auto", - detail = "Use your subtitle preferences", - selected = selectedSubtitleTrackIndex == null, - onSelect = { onSelectSubtitleTrack(null) }, - ), - ) - add( - TvSelectorOption( - key = "subtitle:off", - title = "Off", - detail = "Start without subtitles", - selected = selectedSubtitleTrackIndex == -1, - onSelect = { onSelectSubtitleTrack(-1) }, - ), - ) - formattedSubtitleOptions.forEach { option -> - add( - TvSelectorOption( - key = "subtitle:${option.stableId}", - title = option.title, - detail = option.detail, - selected = option.isSelected, - onSelect = { onSelectSubtitleTrack(option.selectionIndex) }, - ), - ) - } - } - - val audioValue = if (selectedAudioTrackIndex == null && !automaticAudioResolutionKnown) { - "Auto" - } else { - TvPlaybackFormatting.audioValueLabel( - currentVersion, - selectedAudioTrackIndex, - automaticAudioTrackOrdinal, - ) - } - val subtitleValue = TvPlaybackFormatting.subtitleValueLabel( - currentVersion, - selectedSubtitleTrackIndex, - autoContext = if (selectedAudioTrackIndex != null || automaticAudioResolutionKnown) { - TvPlaybackFormatting.SubtitleAutoContext( - preferredLanguage = preferredSubtitleLanguage, - mode = subtitleMode, - showForced = showForcedSubtitles, - audioLanguage = TvPlaybackFormatting.resolvedAudioLanguage( - currentVersion, - selectedAudioTrackIndex, - automaticAudioTrackOrdinal, - ), - ) - } else { - null - }, - ) - var groupExpanded by remember { mutableStateOf(false) } - - // Series supplies the measured action-row width and stretches its segments - // across that stable footprint. Other detail pages retain the compact, - // leading capsule. In fixed mode the values do not expand on focus, so the - // selector never changes shape while the viewer moves through it. - PlaybackSelectorCapsule( - modifier = modifier - .fillMaxWidth() - .onFocusChanged { groupExpanded = it.hasFocus } - .focusGroup(), - stretchContent = stretchSegments, - ) { - val segmentModifier = if (stretchSegments) Modifier.weight(1f) else Modifier - if (editions.size > 1) { - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.Filled.Layers, - label = "Edition", - value = currentEdition?.label ?: "Standard", - compactValue = currentEdition?.label ?: "Standard", - options = editionOptions, - interactive = selectorIsInteractive(editions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - SelectorDivider() - } - - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.Filled.Tv, - label = "Version", - value = TvPlaybackFormatting.versionValueLabel(currentVersion, selectedVersionFileId), - compactValue = TvPlaybackFormatting.versionCompactLabel(currentVersion), - options = versionOptions, - interactive = selectorIsInteractive(scopedVersions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - - if (formattedAudioOptions.isNotEmpty()) { - SelectorDivider() - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.AutoMirrored.Filled.VolumeUp, - label = "Audio", - value = audioValue, - compactValue = audioValue.removePrefix("Auto: "), - options = audioSelectorOptions, - interactive = selectorIsInteractive(formattedAudioOptions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - } - - if (formattedSubtitleOptions.isNotEmpty()) { - SelectorDivider() - TvAnchoredSelectorMenu( - modifier = segmentModifier, - icon = Icons.AutoMirrored.Filled.Chat, - label = "Subtitles", - value = "Subtitles $subtitleValue", - compactValue = compactSubtitleSelectorValue(subtitleValue), - options = subtitleSelectorOptions, - interactive = selectorIsInteractive(formattedSubtitleOptions.size), - triggerStyle = TvSelectorTriggerStyle.ConnectedSegment, - groupExpanded = groupExpanded, - connectedFillWidth = stretchSegments, - connectedExpandValue = !stretchSegments, - ) - } - } -} - -/** The one chrome shell shared by both the live selector and its loading state. */ -@Composable -private fun PlaybackSelectorCapsule( - modifier: Modifier = Modifier, - stretchContent: Boolean = false, - content: @Composable RowScope.() -> Unit, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = (if (stretchContent) Modifier.fillMaxWidth() else Modifier) - .height(25.dp) - .background(Color.Black.copy(alpha = 0.26f), CircleShape) - .border(0.75.dp, Color.White.copy(alpha = 0.42f), CircleShape) - .padding(1.dp), - verticalAlignment = Alignment.CenterVertically, - content = content, - ) - } -} - -@Composable -private fun SelectorDivider() { - androidx.compose.foundation.layout.Box( - modifier = Modifier - .width(0.5.dp) - .height(17.dp) - .background(Color.White.copy(alpha = 0.22f)), - ) -} - -internal fun compactSubtitleSelectorValue(value: String): String = value - .removePrefix("Auto: ") - .removePrefix("Auto - ") - .substringBefore(" · ") diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index 6869761cd2..acbefd442f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -155,11 +155,6 @@ import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved private const val CONTROLS_AUTO_HIDE_MS = 5_000L -// slow) under NonCancellable while holding engineSwitchMutex, so this must be -// long enough not to abort a legitimately slow init, yet short enough to -// recover from a wedged init instead of stranding a permanent black screen and -// blocking every later switch behind the held mutex. 25s splits that range. -private const val ENGINE_SWITCH_TIMEOUT_MS = 25_000L // Pre-revision-9 relative seeks: 10s back, 30s forward, matching tvOS // (gobackward.10 / goforward.30). A server with the profile-wide // player.video_skip_* settings replaces them (see resolvedSkip*Ms below). @@ -3394,74 +3389,6 @@ data class VideoQualityOption( internal const val VIDEO_QUALITY_AUTO_ID = "-1" -/** - * Flatten the current [Tracks] video group(s) into per-format quality options. - * Each format becomes a resolution/bitrate-labelled option. "Auto" is prepended - * and is selected whenever no single format override is active (adaptive). - */ -internal fun extractVideoQualityOptions(tracks: Tracks): List { - val variants = mutableListOf() - val selectedFlags = mutableListOf() - var videoGroupOrdinal = 0 - for (group in tracks.groups) { - if (group.type != C.TRACK_TYPE_VIDEO) continue - val mediaGroup = group.mediaTrackGroup - for (trackIndex in 0 until mediaGroup.length) { - val format = mediaGroup.getFormat(trackIndex) - selectedFlags.add(group.isTrackSelected(trackIndex)) - variants.add( - VideoQualityOption( - id = "$videoGroupOrdinal:$trackIndex", - label = formatVideoQualityLabel(format, trackIndex), - isSelected = false, - resolution = format.height.takeIf { it > 0 }?.let { "${it}p" }, - ), - ) - } - videoGroupOrdinal++ - } - if (variants.isEmpty()) return emptyList() - - // An explicit single-variant override is in effect only when EXACTLY one - // variant is selected among multiple. Several selected (or none) = adaptive, - // so Auto is the active option. (A single-variant group is trivially "Auto" - // — there is nothing to switch.) - val hasMultipleVariants = variants.size > 1 - val selectedCount = selectedFlags.count { it } - val overrideActive = hasMultipleVariants && selectedCount == 1 - val selectedVariantIndex = if (overrideActive) selectedFlags.indexOfFirst { it } else -1 - - val resolved = variants.mapIndexed { idx, v -> - v.copy(isSelected = idx == selectedVariantIndex) - } - return buildList { - add( - VideoQualityOption( - id = VIDEO_QUALITY_AUTO_ID, - label = "Auto", - isSelected = !overrideActive, - ), - ) - addAll(resolved) - } -} - -private fun formatVideoQualityLabel(format: Format, trackIndex: Int): String { - val height = format.height.takeIf { it > 0 } - val resolution = when { - height != null -> "${height}p" - else -> null - } - val bitrate = format.bitrate.takeIf { it > 0 }?.let { bps -> - when { - bps >= 1_000_000 -> "%.1f Mbps".format(bps / 1_000_000.0) - else -> "%.0f Kbps".format(bps / 1_000.0) - } - } - val parts = listOfNotNull(resolution, bitrate) - return if (parts.isEmpty()) "Variant ${trackIndex + 1}" else parts.joinToString(" · ") -} - internal fun resizeModeForVideoFillMode(mode: VideoFillMode): Int = when (mode) { VideoFillMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT VideoFillMode.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 74536f77e1..8bb09eebc7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -85,7 +85,6 @@ import org.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.model.playback.AutoSubtitleCandidate import org.siloserver.silo.model.playback.executableMedia3ClientTransformations import org.siloserver.silo.model.playback.AutoSubtitleContext -import org.siloserver.silo.model.playback.AutoSubtitleResolution import org.siloserver.silo.model.playback.inventoryAutoSubtitleCandidates import org.siloserver.silo.model.playback.resolveAutoSubtitle import org.siloserver.silo.model.playback.selectedCandidate @@ -455,43 +454,6 @@ internal fun subtitleTracksWithSelection( track.copy(isSelected = selectedIndex >= 0 && track.index == selectedIndex) } -internal sealed class SubtitleAutoSelection { - data object NoChange : SubtitleAutoSelection() - data object Disable : SubtitleAutoSelection() - data class Select(val index: Int) : SubtitleAutoSelection() -} - -/** - * Ranks MOUNTED Media3 text tracks through the shared resolver. - * - * The ranking itself lives in [resolveAutoSubtitle] — one cascade, one language - * table, one SDH predicate, one bitmap predicate, shared with the detail page's - * Auto preview. This only adapts [PlayerTrackEntry] into candidates. - */ -internal fun resolveAutoSubtitleSelection( - audioTracks: List, - subtitleTracks: List, - preferredLanguage: String?, - subtitleMode: String?, - showForced: Boolean, -): SubtitleAutoSelection = - when ( - val resolution = resolveAutoSubtitle( - candidates = playerTrackAutoSubtitleCandidates(subtitleTracks), - context = AutoSubtitleContext( - preferredLanguage = preferredLanguage, - mode = subtitleMode, - showForced = showForced, - audioLanguage = audioTracks.firstOrNull { it.isSelected }?.language, - ), - ) - ) { - AutoSubtitleResolution.NoChange -> SubtitleAutoSelection.NoChange - AutoSubtitleResolution.Disable -> SubtitleAutoSelection.Disable - is AutoSubtitleResolution.Select -> - SubtitleAutoSelection.Select(resolution.candidate.selectionIndex) - } - /** * The identity Auto resolves to for a launch that carried NO decision (deep * link, cast, remote/realtime start). @@ -559,29 +521,6 @@ internal fun playerTrackAutoSubtitleCandidates( ) } -internal fun preferredAutoTextSubtitleIndex( - tracks: List, - preferredLanguage: String?, -): Int? { - return when ( - val selection = resolveAutoSubtitleSelection( - audioTracks = emptyList(), - subtitleTracks = tracks, - preferredLanguage = preferredLanguage, - subtitleMode = "auto", - showForced = true, - ) - ) { - // This helper answers "which track should we MOVE to" — an idempotent - // re-select of the already-selected target (see the resolver) is not a - // move, so it stays null here. - is SubtitleAutoSelection.Select -> - selection.index.takeUnless { idx -> tracks.any { it.index == idx && it.isSelected } } - SubtitleAutoSelection.Disable, - SubtitleAutoSelection.NoChange -> null - } -} - internal fun resolveInitialSubtitleTrackIndex( requestedOrdinal: Int, subtitleTracks: List, @@ -1490,9 +1429,7 @@ class TvPlayerViewModel( // is latched WITHOUT validation here precisely because the track list may // not be populated yet. private val _pendingRemoteAudioIndex = MutableStateFlow(null) - val pendingRemoteAudioIndex: StateFlow = _pendingRemoteAudioIndex.asStateFlow() private val _pendingRemoteSubtitleIndex = MutableStateFlow(null) - val pendingRemoteSubtitleIndex: StateFlow = _pendingRemoteSubtitleIndex.asStateFlow() // compareAndSet so a command arriving during the suspending apply isn't // clobbered by the clear of the one we just handled. @@ -1514,9 +1451,6 @@ class TvPlayerViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, false) val trueBlackBars: StateFlow = playerSettingsStore.trueBlackBarsFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) - private val dvProfile7Hdr10Fallback: StateFlow = - playerSettingsStore.dvProfile7HDR10FallbackFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, false) val matchContentFrameRate: StateFlow = playerSettingsStore.matchContentFrameRateFlow .stateIn(viewModelScope, SharingStarted.Eagerly, false) // Effective = custom appearance unless "Match Device Settings" is on @@ -5931,15 +5865,6 @@ class TvPlayerViewModel( } } - /** Ordered path used by auto-advance before the singleton lifecycle starts the next item. */ - suspend fun stopSessionForExit(): Boolean { - subtitleTransactions.invalidateAndAwaitSettlement() - playbackMutationFence.invalidateAll() - prepareSessionExit() - subtitleTransactions.persistCommittedSelectionAndFlush() - return lifecycleTeardown.stopOrdered(expectedSessionId = exitSessionId) - } - /** Ordinary Back/remote-stop path: snapshot locally and return to detail immediately. */ fun stopSessionForExitAsync( positionMs: Long? = null, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt index 0f045c911a..685c82c10b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt @@ -42,8 +42,6 @@ import org.siloserver.silo.model.playback.rebaseDownloadedSubtitleUrl import org.siloserver.silo.model.playback.reduceSubtitleTransition import org.siloserver.silo.model.playback.resolvedSelectedSubtitleIndex import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.playback.downloadedSubtitleArtifactTrackId -import org.siloserver.silo.playback.subtitleLabelIndicatesHearingImpaired import org.siloserver.silo.repository.port.PlaybackWriteScope internal data class TvSubtitlePlaybackContext( @@ -2590,27 +2588,6 @@ private fun VideoSessionStartV3.Ready.selectedTvSubtitleIdentity(): SubtitleIden ?.let(::tvSubtitleIdentity) } -private fun PlayerSubtitleInfo.isDownloadedTvRow(): Boolean = - isLocalDownloadedSubtitle() - -private fun PlayerSubtitleInfo.toDownloadedTvIdentity(): SubtitleIdentity.Downloaded { - val id = requireNotNull(downloadId) - return SubtitleIdentity.Downloaded( - downloadId = id, - media = org.siloserver.silo.model.playback.SubtitleMediaIdentity( - trackId = downloadedSubtitleArtifactTrackId(id), - label = label, - language = language, - codecFamily = codec, - forced = forced ?: false, - hearingImpaired = label - ?.takeIf(::subtitleLabelIndicatesHearingImpaired) - ?.let { true } - ?: false, - ), - ) -} - private fun TvStagedSubtitleCandidate.validationFailure( identity: SubtitleIdentity, ): String? = when (identity) { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt index b01247ac19..3a4e12aa41 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -564,20 +564,6 @@ fun resolveTvPlaybackStartSelection( ) } -/** - * Playback authority for audio at launch. A track chosen on the movie/show - * detail is title-level intent and therefore outranks both a carried episode - * choice and the global language/quality preference. The preference is only - * the fallback when neither manual source supplied a track. - */ -internal fun resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex: Int?, - episodeHandoffTrackIndex: Int?, - automaticPreferenceTrackIndex: Int?, -): Int? = requestedTitleTrackIndex - ?: episodeHandoffTrackIndex - ?: automaticPreferenceTrackIndex - /** Converts the client-side selection to the server's non-negative index contract. */ fun resolveTvServerSubtitleTrackIndex( episodeSelectionHandoff: EpisodeSelectionHandoff?, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormattingTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormattingTest.kt index fba78f34ba..cf8dfc6e39 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormattingTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormattingTest.kt @@ -266,11 +266,6 @@ class TvPlaybackFormattingTest { assertEquals("4K · HDR", TvPlaybackFormatting.versionCompactLabel(v)) } - @Test fun compactSubtitleSelectorValue_dropsAutoAndTechnicalSuffixes() { - assertEquals("English", compactSubtitleSelectorValue("Auto - English · SRT")) - assertEquals("Off", compactSubtitleSelectorValue("Off")) - } - @Test fun versionValueLabel_matchesTvOsDolbyVisionSummary() { val v = fileVersion( resolution = "2160p", diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt index 3faef38e8e..2518e37042 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt @@ -8,7 +8,10 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import org.siloserver.silo.model.catalog.AudioTrack +import org.siloserver.silo.model.playback.AutoSubtitleContext import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.model.playback.resolveAutoSubtitle +import org.siloserver.silo.model.playback.selectedCandidate import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -103,11 +106,11 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(6, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(6, autoPick(tracks)) } @Test - fun autoSubtitlePreferenceLeavesSelectedTextTrackAlone() { + fun autoSubtitlePreferenceKeepsTheSelectedTextTrack() { val tracks = listOf( PlayerTrackEntry( index = 6, @@ -118,7 +121,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(null, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(6, autoPick(tracks)) } @Test @@ -140,7 +143,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(7, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(7, autoPick(tracks)) } @Test @@ -178,8 +181,8 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(7, preferredAutoTextSubtitleIndex(ccFirst, preferredLanguage = "en")) - assertEquals(8, preferredAutoTextSubtitleIndex(closedCaptionFirst, preferredLanguage = "en")) + assertEquals(7, autoPick(ccFirst)) + assertEquals(8, autoPick(closedCaptionFirst)) } @Test @@ -194,7 +197,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(4, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(4, autoPick(tracks)) } @Test @@ -216,7 +219,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(4, preferredAutoTextSubtitleIndex(tracks, preferredLanguage = "en")) + assertEquals(4, autoPick(tracks)) } @Test @@ -239,16 +242,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals( - SubtitleAutoSelection.Disable, - resolveAutoSubtitleSelection( - audioTracks = audio, - subtitleTracks = subtitles, - preferredLanguage = "en", - subtitleMode = "auto", - showForced = true, - ), - ) + assertEquals(null, autoPick(subtitles, audio = audio)) } @Test @@ -279,16 +273,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals( - SubtitleAutoSelection.Select(2), - resolveAutoSubtitleSelection( - audioTracks = audio, - subtitleTracks = subtitles, - preferredLanguage = "en", - subtitleMode = "auto", - showForced = true, - ), - ) + assertEquals(2, autoPick(subtitles, audio = audio)) } @Test @@ -310,23 +295,15 @@ class PlayerTrackEntriesTest { ), ) - assertEquals( - SubtitleAutoSelection.Select(1), - resolveAutoSubtitleSelection( - audioTracks = listOf( - PlayerTrackEntry( - index = 0, - label = "Japanese AAC", - language = "ja", - isSelected = true, - ), - ), - subtitleTracks = subtitles, - preferredLanguage = "en", - subtitleMode = "auto", - showForced = true, + val japaneseAudio = listOf( + PlayerTrackEntry( + index = 0, + label = "Japanese AAC", + language = "ja", + isSelected = true, ), ) + assertEquals(1, autoPick(subtitles, audio = japaneseAudio)) } @Test @@ -489,80 +466,6 @@ class PlayerTrackEntriesTest { assertEquals(listOf(MimeTypes.APPLICATION_PGS, MimeTypes.APPLICATION_SUBRIP), entries.map { it.codecOrMime }) } - @Test - fun videoQualityOptionsFlattenPerFormatVariantsWithAutoFirst() { - // A single video group carrying three resolution variants must surface - // three real options, not collapse to one — plus a synthetic "Auto". - val group = TrackGroup( - videoFormat(width = 1920, height = 1080, bitrate = 8_000_000), - videoFormat(width = 1280, height = 720, bitrate = 4_000_000), - videoFormat(width = 640, height = 360, bitrate = 1_000_000), - ) - val tracks = Tracks( - listOf( - Tracks.Group( - group, - true, - intArrayOf(C.FORMAT_HANDLED, C.FORMAT_HANDLED, C.FORMAT_HANDLED), - // 720p explicitly selected = an override is active. - booleanArrayOf(false, true, false), - ), - ), - ) - - val options = extractVideoQualityOptions(tracks) - - // Auto + three variants. - assertEquals(4, options.size) - assertEquals(VIDEO_QUALITY_AUTO_ID, options.first().id) - assertEquals("Auto", options.first().label) - assertTrue(options[1].label.contains("1080p")) - assertTrue(options[2].label.contains("720p")) - assertTrue(options[3].label.contains("360p")) - // The explicitly-selected variant (720p) is selected, not Auto. - assertEquals(options[2].id, options.first { it.isSelected }.id, "720p variant should be selected") - } - - @Test - fun videoQualityAutoSelectedWhenNoOverrideAndDisabledWhenSingleVariant() { - val adaptiveGroup = TrackGroup( - videoFormat(width = 1920, height = 1080, bitrate = 8_000_000), - videoFormat(width = 1280, height = 720, bitrate = 4_000_000), - ) - val adaptive = Tracks( - listOf( - Tracks.Group( - adaptiveGroup, - true, - intArrayOf(C.FORMAT_HANDLED, C.FORMAT_HANDLED), - // Adaptive: both selectable, none pinned as an override. - booleanArrayOf(true, true), - ), - ), - ) - val adaptiveOptions = extractVideoQualityOptions(adaptive) - // With more than one selected variant there is no single override, so - // Auto is the selected option. - assertTrue(adaptiveOptions.first().isSelected) - - // A single-variant group offers no genuine quality choice: Auto + one - // variant = size 2, so the HUD row disables (hasQualityChoice = size>2). - val singleGroup = TrackGroup(videoFormat(width = 1920, height = 1080, bitrate = 8_000_000)) - val single = Tracks( - listOf( - Tracks.Group( - singleGroup, - true, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(true), - ), - ), - ) - val singleOptions = extractVideoQualityOptions(single) - assertEquals(2, singleOptions.size) - assertTrue(singleOptions.first().isSelected) - } - private fun videoFormat(width: Int, height: Int, bitrate: Int): Format = Format.Builder() .setSampleMimeType(MimeTypes.VIDEO_H264) @@ -581,4 +484,18 @@ class PlayerTrackEntriesTest { .setSampleMimeType(MimeTypes.APPLICATION_SUBRIP) .setSelectionFlags(if (forced) C.SELECTION_FLAG_FORCED else 0) .build() + + /** Auto's pick over mounted tracks: the ranking `resolveTvAutoSubtitleIdentity` runs without a server inventory. */ + private fun autoPick( + subtitles: List, + audio: List = emptyList(), + ): Int? = resolveAutoSubtitle( + candidates = playerTrackAutoSubtitleCandidates(subtitles), + context = AutoSubtitleContext( + preferredLanguage = "en", + mode = "auto", + showForced = true, + audioLanguage = audio.firstOrNull { it.isSelected }?.language, + ), + ).selectedCandidate()?.selectionIndex } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt index 5a4423adc3..597f089947 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt @@ -15,42 +15,6 @@ import org.siloserver.silo.model.playback.ClientCodecCapabilities import org.siloserver.silo.playback.audioTrackFingerprint class TvEpisodeHandoffPlaybackStartTest { - @Test - fun manualTitleAudioChoiceWinsOverHandoffAndPlaybackSettings() { - assertEquals( - 2, - resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex = 2, - episodeHandoffTrackIndex = 1, - automaticPreferenceTrackIndex = 0, - ), - ) - } - - @Test - fun playbackAudioSettingIsOnlyTheAutomaticFallback() { - assertEquals( - 0, - resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex = null, - episodeHandoffTrackIndex = null, - automaticPreferenceTrackIndex = 0, - ), - ) - } - - @Test - fun episodeHandoffAudioWinsOverAutomaticPlaybackSetting() { - assertEquals( - 1, - resolveTvStartAudioTrackIndex( - requestedTitleTrackIndex = null, - episodeHandoffTrackIndex = 1, - automaticPreferenceTrackIndex = 0, - ), - ) - } - @Test fun explicitDetailFileIdWinsOverEpisodeHandoff() { val resolved = resolveTvPlaybackStartSelection( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt index c7eb5983e7..1abf3222b7 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt @@ -2,7 +2,6 @@ package org.siloserver.silo.tv.ui.screens.player import org.siloserver.silo.common.player.video.EpisodeSubtitleMode import org.siloserver.silo.common.player.video.ResolvedEpisodeSelection -import org.siloserver.silo.common.player.video.encodeEpisodeSelectionHandoff import org.siloserver.silo.model.catalog.FileVersion import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.model.playback.SubtitleIdentity @@ -197,13 +196,6 @@ class TvPlayNextSelectionHandoffTest { assertEquals(true, handoff.subtitle.forced) assertEquals(true, handoff.subtitle.hearingImpaired) assertEquals(true, handoff.subtitle.external) - val encoded = encodeEpisodeSelectionHandoff(handoff) - assertTrue(encoded.contains("777").not(), "download identity is episode-local") - assertTrue(encoded.contains("download-track").not(), "Media3 identity is episode-local") - assertTrue(encoded.contains("fileId").not(), "file identity is episode-local") - assertTrue(encoded.contains("index").not(), "subtitle indexes are episode-local") - assertTrue(encoded.contains("source-42").not(), "file name and path are episode-local") - assertTrue(encoded.contains("example.test").not(), "subtitle URLs are episode-local") } @Test @@ -229,7 +221,6 @@ class TvPlayNextSelectionHandoffTest { assertEquals(false, handoff.subtitle.forced) assertEquals(true, handoff.subtitle.hearingImpaired) assertNull(handoff.subtitle.external) - assertTrue(encodeEpisodeSelectionHandoff(handoff).contains("media3-opaque-id").not()) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt index f3fedadae3..b527bca520 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt @@ -302,7 +302,7 @@ class TvPlayerSubtitleIntegrationPolicyTest { val row = embeddedPgsRow() val track = embeddedPgsTrack() - val selection = resolveAutoSubtitleSelection( + val identity = resolveTvAutoSubtitleIdentity( audioTracks = listOf( PlayerTrackEntry( index = 0, @@ -312,6 +312,7 @@ class TvPlayerSubtitleIntegrationPolicyTest { ), ), subtitleTracks = listOf(track), + subtitleRows = listOf(row), preferredLanguage = "en", subtitleMode = "always", showForced = true, @@ -319,12 +320,9 @@ class TvPlayerSubtitleIntegrationPolicyTest { // Bitmap tracks stay deprioritised-but-allowed: it is the only English // candidate, so Always must still pick it. - val selected = assertIs(selection) - assertEquals(track.index, selected.index) - - val identity = tvMountedSubtitleIdentity(track, listOf(track), listOf(row)) assertEquals(tvSubtitleIdentity(row), identity) assertIs(identity) + assertEquals(identity, tvMountedSubtitleIdentity(track, listOf(track), listOf(row))) // The identity the auto path commits is the identity the HUD ticks. val presentation = buildTvSubtitleHudPresentation( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt index 9b1bfd900f..206a316331 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt @@ -785,27 +785,14 @@ class TvSubtitleSettlementOwnershipTest { } @Test - fun `real exit and clear wire settlement before invalidation and lifecycle stop`() { + fun `clear wires settlement before invalidation and lifecycle stop`() { val source = File( "src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt", ).readText() - val exitBody = source - .substringAfter("suspend fun stopSessionForExit()") - .substringBefore("fun onExit()") val clearBody = source .substringAfter("override fun onCleared()") .substringBefore("\n }\n\n}") - assertBefore( - exitBody, - "subtitleTransactions.invalidateAndAwaitSettlement()", - "playbackMutationFence.invalidateAll()", - ) - assertBefore( - exitBody, - "subtitleTransactions.invalidateAndAwaitSettlement()", - "lifecycleTeardown.stopOrdered(", - ) // Every exit path runs prepareSessionExit, which blanks uiState.sessionId. // If the id is not latched BEFORE that write, each of the three stops // below passes null and the ownership guard they exist for never engages @@ -818,7 +805,6 @@ class TvSubtitleSettlementOwnershipTest { "lastAdoptedSessionId = it", "sessionId = null", ) - assertTrue(exitBody.contains("lifecycleTeardown.stopOrdered(expectedSessionId = exitSessionId)")) assertBefore( clearBody, "subtitleTransactions.reserveDurableFinalPersistence()", @@ -846,10 +832,6 @@ class TvSubtitleSettlementOwnershipTest { // and supersedes it, which is how auto-advance broke. assertTrue(clearBody.contains("lifecycleTeardown.stopDetached(expectedSessionId")) assertFalse(clearBody.contains("sessionLifecycle.stop")) - val adoptionBody = source - .substringAfter("private suspend fun adoptSubtitlePlayback(") - .substringBefore("private suspend fun confirmSubtitlePlaybackPublication(") - assertFalse(adoptionBody.contains("rollbackUnpublishedActiveSession(")) } private suspend fun TestScope.mountUnpublished( diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt index 12a9c9925f..cda0f67850 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt @@ -471,9 +471,3 @@ data class DeliverySubtitleCapabilities( @SerialName("sidecar_bitmap") val sidecarBitmap: Boolean = false, @SerialName("font_attachments") val fontAttachments: Boolean = false, ) - -@Serializable -data class ProgressRequest( - val position: Double, - @SerialName("is_paused") val isPaused: Boolean -) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/PlaybackV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/PlaybackV2Api.kt index 0d24312eb9..ec2dae56af 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/PlaybackV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/PlaybackV2Api.kt @@ -26,7 +26,6 @@ object PlaybackMutationOutcomeV2 { const val STALE_SAMPLE = "stale_sample" const val STOPPED = "stopped" val PROGRESS = setOf(APPLIED, REPLAYED, STALE_SAMPLE) - val STOP = setOf(STOPPED, REPLAYED) } /** `revision` and `deliveries` are served but never read by Android. */ From 39eaf8e98cae744b771bba052977cea0d1bc9d20 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 6 Oct 2026 16:16:30 +0200 Subject: [PATCH 33/87] feat(playback): shuffle libraries, series, seasons, and collections on phone and TV (#476) * feat(playback): shuffle libraries, series, seasons, and collections on phone and TV Adds Shuffle against the server's /api/v2/shuffles operations. Movie, TV, and mixed libraries, series, seasons, and server and personal collections get a Shuffle entry point on phone and TV, gated by the capability. Every pick plays from the beginning; the up-next screen announces the server's next pick with Pick Another and Stop shuffling, and the sequential next episode controls are off during a shuffle. A multi-part pick plays its later parts before the shuffle moves on. * fix(shuffle): drop a shuffle read that overlapped an advance or skip A read that started while an advance or Pick Another was waiting for the server passed the mutation counter check, so a stale answer could put back the pick the mutation replaced. * fix(player): keep shuffle jobs from acting after On Deck or a failed part stop On Deck now cancels a running advance, part change, or Pick Another, and clears the transition an advance may hold. A part change whose session stop is refused keeps that part queued, and one that is no longer current after the stop does not load. * fix(tv): play shuffles started from the Libraries and Audio pages * fix(player): end a shuffle part change when the item reloads during its stop A reload of the same item while the part change waited for the session stop passed the shuffle and content checks, so the part change then loaded its queued part over the reload. It now also checks the load generation. * fix(libraries): show Shuffle only on the Browse tab, after the tabs The Shuffle chip shuffles the whole library, which reads as a Browse action; on Recommended and Collections it sat beside unrelated content. It now appears only on Browse and sits at the end of the tab row, so Recommended, Library, and Collections keep their positions when it appears or disappears. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- FEATURES.md | 1 + .../silo/common/ui/ShuffleLauncher.kt | 66 ++++ .../silo/android/di/AndroidModule.kt | 1 + .../android/ui/navigation/AppNavigation.kt | 19 ++ .../silo/android/ui/navigation/Routes.kt | 5 +- .../silo/android/ui/screens/MainScreen.kt | 12 + .../collections/CollectionDetailScreen.kt | 17 + .../collections/CollectionDetailViewModel.kt | 11 + .../ui/screens/detail/ItemDetailScreen.kt | 27 ++ .../ui/screens/detail/SeriesDetailContent.kt | 34 +- .../ui/screens/libraries/LibrariesScreen.kt | 46 +++ .../player/MobileVideoPlaybackStarter.kt | 9 +- .../ui/screens/player/PlayerNextUpScreen.kt | 108 ++++++- .../ui/screens/player/PlayerOverlay.kt | 15 +- .../android/ui/screens/player/PlayerScreen.kt | 3 + .../ui/screens/player/PlayerViewModel.kt | 267 +++++++++++++++- .../ui/screens/shuffle/ShuffleLauncher.kt | 21 ++ ...erViewModelLoadOwnershipIntegrationTest.kt | 296 ++++++++++++++++++ .../siloserver/silo/tv/di/AndroidTvModule.kt | 1 + .../silo/tv/ui/components/TvOptionDialog.kt | 5 +- .../silo/tv/ui/navigation/TvAppNavigation.kt | 36 +++ .../silo/tv/ui/navigation/TvRoute.kt | 7 +- .../collections/TvCollectionDetailScreen.kt | 34 +- .../ui/screens/detail/TvItemDetailScreen.kt | 67 ++++ .../ui/screens/libraries/TvLibrariesScreen.kt | 3 + .../library/TvLibraryBrowseControls.kt | 25 ++ .../TvLibraryCollectionDetailScreen.kt | 22 ++ .../screens/library/TvLibraryDetailScreen.kt | 20 ++ .../tv/ui/screens/player/TvPlayerScreen.kt | 122 ++++++-- .../tv/ui/screens/player/TvPlayerViewModel.kt | 246 ++++++++++++++- .../screens/player/TvVideoPlaybackStarter.kt | 8 +- .../silo/tv/ui/shell/TvMainShell.kt | 13 + docs/README.md | 2 +- docs/api-v2/android-migration-status.md | 1 + docs/shuffle-api-v2.md | 55 ++++ .../org/siloserver/silo/di/NetworkModule.kt | 1 + .../siloserver/silo/di/RepositoryModule.kt | 1 + .../silo/model/feature/ShuffleFeatureStore.kt | 87 +++++ .../silo/model/shuffle/ShuffleModels.kt | 69 ++++ .../silo/network/apiv2/ShufflesV2Api.kt | 117 +++++++ .../siloserver/silo/playback/PlaybackParts.kt | 45 +++ .../silo/playback/ShufflePlayback.kt | 153 +++++++++ .../model/feature/ShuffleFeatureStoreTest.kt | 95 ++++++ .../silo/network/apiv2/ShufflesV2Test.kt | 160 ++++++++++ .../silo/playback/PlaybackPartsTest.kt | 52 +++ .../silo/playback/ShufflePlaybackTest.kt | 211 +++++++++++++ 46 files changed, 2559 insertions(+), 57 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ShuffleLauncher.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/shuffle/ShuffleLauncher.kt create mode 100644 docs/shuffle-api-v2.md create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStore.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/shuffle/ShuffleModels.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Api.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackParts.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStoreTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Test.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackPartsTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt diff --git a/FEATURES.md b/FEATURES.md index f4cc7295a5..fc94599a7a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -39,6 +39,7 @@ File pointers are repository-relative. | AI subtitle transcription / translation (quota-tracked) | ✅ | ✅ | TV: `TvAiTranslateDialog` | | AI description translation (on-view, server-gated) | ✅ | ✅ | `DescriptionTranslationController`; gated by `/api/v1/metadata/ai/status`; metadata-language setting in Settings | | Intro auto-skip (+ manual skip banner) | ✅ | ✅ | | +| Shuffle (library, series, season, collection) | ✅ | ✅ | Server-picked random movies and episodes, gated by `/api/v2/shuffles/capabilities`. Picks play from the start; the up-next screen offers Pick Another and Stop shuffling. TV has no mixed-library screen, so mixed libraries shuffle on phone only. [docs](docs/shuffle-api-v2.md) | | Chapters | ✅ | ✅ | Server-extracted; TV scrubber markers | | Sleep timer | ✅ | ✅ | Configurable default | | Playback speed | ✅ | ✅ | | diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ShuffleLauncher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ShuffleLauncher.kt new file mode 100644 index 0000000000..722492ff15 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ShuffleLauncher.kt @@ -0,0 +1,66 @@ +package org.siloserver.silo.common.ui + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.siloserver.silo.model.feature.ShuffleFeatureStore +import org.siloserver.silo.model.feature.ShuffleStart +import org.siloserver.silo.model.shuffle.Shuffle +import org.siloserver.silo.model.shuffle.ShuffleCapability +import org.siloserver.silo.model.shuffle.ShuffleScopeKind + +/** + * Starts shuffles from an entry point: offers a scope kind only when the + * server's capability allows it, ignores presses while a start is in flight, + * and reports a failure as a toast. + */ +class ShuffleLauncher internal constructor( + private val store: ShuffleFeatureStore, + private val scope: CoroutineScope, + private val context: Context, + private val onStarted: State<(Shuffle) -> Unit>, + private val capability: State, +) { + var isStarting by mutableStateOf(false) + private set + + fun supports(kind: ShuffleScopeKind): Boolean = capability.value.supports(kind) + + fun start(kind: ShuffleScopeKind, id: String) { + if (isStarting || id.isBlank()) return + isStarting = true + scope.launch { + try { + when (val result = store.start(kind, id)) { + is ShuffleStart.Started -> onStarted.value(result.shuffle) + is ShuffleStart.Failed -> Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show() + } + } finally { + isStarting = false + } + } + } +} + +/** A [ShuffleLauncher] that calls [onStarted] with each shuffle it starts. */ +@Composable +fun rememberShuffleLauncher(store: ShuffleFeatureStore, onStarted: (Shuffle) -> Unit): ShuffleLauncher { + val scope = rememberCoroutineScope() + val context = LocalContext.current.applicationContext + val latestOnStarted = rememberUpdatedState(onStarted) + val capability = store.capability.collectAsState() + return remember(store, scope, context) { + ShuffleLauncher(store, scope, context, latestOnStarted, capability) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 0313af4953..bb6f36065d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -445,6 +445,7 @@ val androidModule = module { castPlaybackPreparer = get(), seekIntervalStore = get(), activeProfileStore = get(), + shuffleFeatureStore = get(), ) } viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index d3ffd48e0d..ed56ae87dc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -1155,6 +1155,14 @@ fun AppNavigation( CollectionDetailScreen( collectionId = backStackEntry.arguments?.getString("collectionId") ?: "", onBackClick = { navController.popBackStack() }, + onShuffleStarted = { shuffle -> + navController.navigate( + org.siloserver.silo.android.ui.screens.shuffle.shufflePlayerRoute( + shuffle, + backStackEntry.arguments?.getString("libraryId")?.toIntOrNull(), + ), + ) + }, onItemClick = { contentId -> navController.navigate( Route.ItemDetail( @@ -1277,6 +1285,11 @@ fun AppNavigation( resolvedEpisodeId = episodeId resolvedSeriesId = seriesId }, + onShuffleStarted = { shuffle -> + navController.navigate( + org.siloserver.silo.android.ui.screens.shuffle.shufflePlayerRoute(shuffle, libraryId), + ) + }, onPersonClick = { personId -> personId.toLongOrNull()?.let { id -> navController.navigate(Route.PersonDetail(id).route) @@ -1471,6 +1484,11 @@ fun AppNavigation( nullable = true defaultValue = null }, + navArgument("shuffleId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStackEntry -> val playerViewModel = koinViewModel() @@ -1499,6 +1517,7 @@ fun AppNavigation( backStackEntry.arguments?.getString("resumePosition"), ), roomId = backStackEntry.arguments?.getString("roomId"), + shuffleId = backStackEntry.arguments?.getString("shuffleId"), navController = navController, viewModel = playerViewModel, ) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt index b518e92176..1acd53fc22 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt @@ -216,6 +216,8 @@ sealed class Route(val route: String) { val resumePositionSeconds: Double? = null, val roomId: String? = null, val libraryId: Int? = null, + /** The running shuffle this item is a pick of; picks play from the beginning. */ + val shuffleId: String? = null, ) : Route( buildString { append("player/${contentId.routeEncode()}") @@ -230,6 +232,7 @@ sealed class Route(val route: String) { VideoPlayerRouteArgs.encodeResumePosition(resumePositionSeconds) ?.let { "${VideoPlayerRouteArgs.RESUME_POSITION}=$it" }, roomId?.takeIf { it.isNotBlank() }?.let { "roomId=${Uri.encode(it)}" }, + shuffleId?.takeIf { it.isNotBlank() }?.let { "shuffleId=${Uri.encode(it)}" }, ) if (queryParams.isNotEmpty()) { append("?") @@ -239,7 +242,7 @@ sealed class Route(val route: String) { ) { companion object { const val ROUTE = - "player/{contentId}?libraryId={libraryId}&fileId={fileId}&quality={quality}&audioTrackIndex={audioTrackIndex}&subtitleTrackIndex={subtitleTrackIndex}&resumePosition={resumePosition}&roomId={roomId}" + "player/{contentId}?libraryId={libraryId}&fileId={fileId}&quality={quality}&audioTrackIndex={audioTrackIndex}&subtitleTrackIndex={subtitleTrackIndex}&resumePosition={resumePosition}&roomId={roomId}&shuffleId={shuffleId}" } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt index ca487c0093..00b37680cf 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt @@ -163,6 +163,7 @@ fun MainScreen( val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() val metadataAiFeatureStore: MetadataAiFeatureStore = koinInject() + val shuffleFeatureStore: org.siloserver.silo.model.feature.ShuffleFeatureStore = koinInject() val overlayPrefsStore: OverlayPrefsStore = koinInject() val activeProfileStore: ActiveProfileStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() @@ -284,6 +285,8 @@ fun MainScreen( requestsFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() + shuffleFeatureStore.reset() + shuffleFeatureStore.refresh() } fun signOutFromProfileMenu() { @@ -441,7 +444,16 @@ fun MainScreen( ) } Tab.Libraries -> { + val shuffleLauncher = org.siloserver.silo.android.ui.screens.shuffle.rememberShuffleLauncher { shuffle -> + navController.navigate( + org.siloserver.silo.android.ui.screens.shuffle.shufflePlayerRoute( + shuffle, + libraryId = shuffle.scope.id.toIntOrNull(), + ), + ) + } LibrariesScreen( + shuffleLauncher = shuffleLauncher, onItemClick = { contentId, libraryId -> navController.navigate(Route.ItemDetail(contentId, libraryId = libraryId).route) }, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt index 4c5b693c85..f8aeb66947 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt @@ -13,6 +13,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,10 +45,14 @@ fun CollectionDetailScreen( collectionId: String, onBackClick: () -> Unit, onItemClick: (String) -> Unit, + // Plays the first pick of a shuffle started from the top bar. + onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: CollectionDetailViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() val gridState = rememberLazyGridState() + val shuffleLauncher = org.siloserver.silo.android.ui.screens.shuffle.rememberShuffleLauncher(onShuffleStarted) + val shuffleKind = viewModel.shuffleScopeKind LaunchedEffect(collectionId) { viewModel.initialize(collectionId) @@ -70,6 +77,16 @@ fun CollectionDetailScreen( SiloTopBar( title = state.title, onBackClick = onBackClick, + actions = { + if (state.items.isNotEmpty() && shuffleLauncher.supports(shuffleKind)) { + IconButton( + onClick = { shuffleLauncher.start(shuffleKind, collectionId) }, + enabled = !shuffleLauncher.isStarting, + ) { + Icon(Icons.Filled.Shuffle, contentDescription = "Shuffle") + } + } + }, ) }, containerColor = MaterialTheme.colorScheme.background, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt index d51511fbf8..22040c31dd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt @@ -53,6 +53,17 @@ class CollectionDetailViewModel( private val collectionSource: String? = savedStateHandle.get("source") private val isLibraryUserCollection: Boolean get() = libraryId != null && collectionSource == "user_collection" + + /** The shuffle scope this collection is: a library's server collection or a user collection. */ + val shuffleScopeKind: org.siloserver.silo.model.shuffle.ShuffleScopeKind + get() = if (libraryId != null && !isLibraryUserCollection) { + org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION + } else { + org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION + } + + /** The library the collection was opened from, carried into shuffled playback. */ + val browseLibraryId: Int? get() = libraryId private val pageSize = 40 fun initialize(id: String) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index 46caf619f5..b23ee13f1f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -138,10 +138,13 @@ fun ItemDetailScreen( // Auto-presents the cast remote after "Play on device" launches, mirroring // Apple's playOnTV: the connect/handoff handshake renders in the remote. onOpenCastRemote: () -> Unit = {}, + // Plays the first pick of a shuffle started from the series overflow menu. + onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: ItemDetailViewModel, modifier: Modifier = Modifier, ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.siloserver.silo.android.ui.screens.shuffle.rememberShuffleLauncher(onShuffleStarted) val seriesRedirect = remember(state.detail) { state.detail?.let(::seriesDetailRedirect) } var seriesRedirectFailed by rememberSaveable(state.detail?.contentId) { mutableStateOf(false) } LaunchedEffect(seriesRedirect) { @@ -742,6 +745,30 @@ fun ItemDetailScreen( onSeasonWatchedChange = { season, watched -> viewModel.setSeasonWatched(season, watched) }, + onShuffleSeries = if ( + shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.SERIES) + ) { + { + shuffleLauncher.start( + org.siloserver.silo.model.shuffle.ShuffleScopeKind.SERIES, + detail.contentId, + ) + } + } else { + null + }, + onShuffleSeason = if ( + shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.SEASON) + ) { + { season -> + shuffleLauncher.start( + org.siloserver.silo.model.shuffle.ShuffleScopeKind.SEASON, + season.contentId, + ) + } + } else { + null + }, onFavoriteClick = { viewModel.toggleFavorite() }, onWatchlistClick = { viewModel.toggleWatchlist() }, onToggleWatched = { viewModel.toggleWatched() }, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt index 1a8271fafe..b9714d36c2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.CheckCircle @@ -75,6 +76,10 @@ fun SeriesDetailContent( onEpisodeWatchedChange: (String, Boolean) -> Unit, onSeasonSelected: (Int) -> Unit, onSeasonWatchedChange: ((Season, Boolean) -> Unit)? = null, + // Shuffle the whole series, or the season on screen; null where the + // server doesn't offer that scope. + onShuffleSeries: (() -> Unit)? = null, + onShuffleSeason: ((Season) -> Unit)? = null, onFavoriteClick: () -> Unit, onWatchlistClick: () -> Unit, onToggleWatched: () -> Unit, @@ -119,6 +124,10 @@ fun SeriesDetailContent( val markableSelectedSeason = selectedSeason?.takeIf { onSeasonWatchedChange != null && it.episodeCount > 0 } + // Hidden on a season with fewer than two playable episodes. + val shuffleableSelectedSeason = selectedSeason?.takeIf { + onShuffleSeason != null && org.siloserver.silo.model.shuffle.canShuffleSeason(episodes) + } val episodeCountSubtitle = selectedSeason?.episodeCount?.takeIf { it > 0 }?.let { count -> "$count episode${if (count == 1) "" else "s"}" } @@ -287,8 +296,31 @@ fun SeriesDetailContent( onToggleFavorite = onFavoriteClick, onToggleWatchlist = onWatchlistClick, onToggleWatched = onToggleWatched, - overflow = if (markableSelectedSeason != null || partyAction != null) { + overflow = if ( + markableSelectedSeason != null || partyAction != null || + onShuffleSeries != null || shuffleableSelectedSeason != null + ) { { dismiss -> + if (onShuffleSeries != null) { + DropdownMenuItem( + text = { Text("Shuffle Series") }, + leadingIcon = { Icon(Icons.Filled.Shuffle, contentDescription = null) }, + onClick = { + dismiss() + onShuffleSeries() + }, + ) + } + if (shuffleableSelectedSeason != null && onShuffleSeason != null) { + DropdownMenuItem( + text = { Text("Shuffle ${phoneSeasonLabel(shuffleableSelectedSeason)}") }, + leadingIcon = { Icon(Icons.Filled.Shuffle, contentDescription = null) }, + onClick = { + dismiss() + onShuffleSeason(shuffleableSelectedSeason) + }, + ) + } if (markableSelectedSeason != null && onSeasonWatchedChange != null) { val seasonWatched = markableSelectedSeason.userData?.played == true DropdownMenuItem( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index 164b464cca..91e87e1ea5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.LocalMovies +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.CircularProgressIndicator @@ -800,10 +801,23 @@ fun LibrariesScreen( onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, + shuffleLauncher: org.siloserver.silo.common.ui.ShuffleLauncher? = null, modifier: Modifier = Modifier, ) { val state by viewModel.uiState.collectAsState() val selectedLibrary = state.libraries.firstOrNull { it.id == state.selectedLibraryId } + // Movie, TV, and mixed libraries shuffle from the Browse tab when the + // server offers it. + val onShuffle = selectedLibrary + ?.takeIf { state.selectedTab == LibrariesSubtab.Browse } + ?.takeIf { org.siloserver.silo.model.shuffle.isShuffleLibraryType(it.type) } + ?.takeIf { shuffleLauncher?.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY) == true } + ?.let { library -> + { + shuffleLauncher?.start(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY, library.id.toString()) + Unit + } + } // Recommended tab scroll state — drives the chrome scrim opacity so the // header fades in its scrim once the user scrolls the rows underneath it. @@ -931,6 +945,8 @@ fun LibrariesScreen( onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, onSignOutClick = onSignOutClick, + onShuffleClick = onShuffle, + shuffleEnabled = shuffleLauncher?.isStarting != true, modifier = Modifier.onSizeChanged { chromeHeightPx = it.height }, ) } @@ -1336,6 +1352,8 @@ private fun LibrariesFloatingChrome( onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, + onShuffleClick: (() -> Unit)? = null, + shuffleEnabled: Boolean = true, modifier: Modifier = Modifier, ) { val statusBarPadding = WindowInsets.statusBars.asPaddingValues() @@ -1393,6 +1411,8 @@ private fun LibrariesFloatingChrome( onRecommendedClick = { onTabSelected(LibrariesSubtab.Recommended) }, onBrowseClick = { onTabSelected(LibrariesSubtab.Browse) }, onCollectionsClick = { onTabSelected(LibrariesSubtab.Collections) }, + onShuffleClick = onShuffleClick, + shuffleEnabled = shuffleEnabled, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -1502,6 +1522,8 @@ private fun LibrarySubtabRow( onRecommendedClick: () -> Unit, onBrowseClick: () -> Unit, onCollectionsClick: () -> Unit, + onShuffleClick: (() -> Unit)? = null, + shuffleEnabled: Boolean = true, modifier: Modifier = Modifier, ) { Row( @@ -1523,6 +1545,30 @@ private fun LibrarySubtabRow( selected = selectedTab == LibrariesSubtab.Collections, onClick = onCollectionsClick, ) + if (onShuffleClick != null) { + // Starts a shuffle of the whole library; drawn as a chip so it + // matches the tabs beside it. + Surface( + onClick = onShuffleClick, + enabled = shuffleEnabled, + shape = RoundedCornerShape(999.dp), + color = SiloSurfaceElevated, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Text(text = "Shuffle", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + } + } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt index 1ba1b937f5..f33a892d7f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt @@ -219,7 +219,14 @@ internal class MobileVideoPlaybackStarter( watchDetail.versions, watchDetail.userData?.lastFileId, preferredQuality, - ) + ).let { selected -> + // A start from the beginning opens a multi-part item at its first part. + if (org.siloserver.silo.model.playback.isExplicitStartOver(request.resumePositionOverride)) { + org.siloserver.silo.playback.firstPlaybackPart(watchDetail.versions, selected) + } else { + selected + } + } } // Room starts keep the file through every replan; renewals keep // whatever the original start chose. diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt index f855cb9bfc..a15f689cbc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -72,6 +73,11 @@ fun PlayerNextUpScreen( onBack: () -> Unit, onVideoBoundsChanged: (Rect) -> Unit, compactTabletop: Boolean = false, + // Set while a shuffle plays: the next item is a random pick, and the + // viewer can pick another or stop shuffling. + shuffle: PlayerViewModel.ShuffleUiState? = null, + onPickAnother: () -> Unit = {}, + onStopShuffling: () -> Unit = {}, modifier: Modifier = Modifier, ) { BoxWithConstraints(modifier = modifier.fillMaxSize()) { @@ -83,9 +89,35 @@ fun PlayerNextUpScreen( horizontalAlignment = panelAlignment, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + shuffle?.scopeLabel?.let { scope -> + Row( + modifier = Modifier + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.10f)) + .padding(horizontal = 12.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(14.dp), + ) + Text( + text = "Shuffling $scope", + color = Color.White.copy(alpha = 0.75f), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } Text( text = when { nextEpisode == null -> "FINISHED" + shuffle != null -> "UP NEXT AT RANDOM" videoEnded -> "PLAYING NEXT" else -> "UP NEXT" }, @@ -95,7 +127,10 @@ fun PlayerNextUpScreen( letterSpacing = 2.sp, ) if (nextEpisode != null) { - nextEpisode.seriesTitle?.takeIf { it.isNotBlank() }?.let { title -> + // A shuffled movie has no series line; its own title heads the card. + val heading = nextEpisode.seriesTitle?.takeIf { it.isNotBlank() } + ?: nextEpisode.title.takeUnless { nextEpisode.isEpisode } + heading?.let { title -> Text( text = title, color = Color.White, @@ -105,14 +140,16 @@ fun PlayerNextUpScreen( overflow = TextOverflow.Ellipsis, ) } - Text( - text = nextEpisode.label, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + if (nextEpisode.isEpisode) { + Text( + text = nextEpisode.label, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } if (nextEpisode.runtimeMinutes > 0) { Text( text = "${nextEpisode.runtimeMinutes} min", @@ -142,18 +179,38 @@ fun PlayerNextUpScreen( onPlayNow = onPlayNow, onKeepWatching = onKeepWatching, onBack = onBack, + pickAnother = if (shuffle != null && nextEpisode != null) { + PickAnotherAction(enabled = !shuffle.pickingAnother, onClick = onPickAnother) + } else { + null + }, ) if (countdownSeconds != null) { CountdownRing(countdownSeconds, countdownTotalSeconds) } } - Text( - text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", - color = Color.White.copy(alpha = 0.54f), - fontSize = 12.sp, - modifier = Modifier.clip(CircleShape).clickable(onClick = onToggleAutoPlay) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) + shuffle?.message?.let { message -> + Text(message, color = Color(0xFFFFB4AB), fontSize = 13.sp) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", + color = Color.White.copy(alpha = 0.54f), + fontSize = 12.sp, + modifier = Modifier.clip(CircleShape).clickable(onClick = onToggleAutoPlay) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + if (shuffle != null) { + Text("·", color = Color.White.copy(alpha = 0.54f), fontSize = 12.sp) + Text( + text = "Stop shuffling", + color = Color.White.copy(alpha = 0.54f), + fontSize = 12.sp, + modifier = Modifier.clip(CircleShape).clickable(onClick = onStopShuffling) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } if (!compactTabletop && onDeckItems.isNotEmpty()) { Text("On Deck", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) val onDeckState = rememberLazyListState() @@ -204,6 +261,8 @@ private fun NextUpVideoPane(onBoundsChanged: (Rect) -> Unit, modifier: Modifier) ) } +private class PickAnotherAction(val enabled: Boolean, val onClick: () -> Unit) + @Composable private fun NextUpActionButtons( hasNextEpisode: Boolean, @@ -211,6 +270,7 @@ private fun NextUpActionButtons( onPlayNow: () -> Unit, onKeepWatching: () -> Unit, onBack: () -> Unit, + pickAnother: PickAnotherAction? = null, modifier: Modifier = Modifier, ) { if (hasNextEpisode) { @@ -231,6 +291,22 @@ private fun NextUpActionButtons( Text("Play Now") } } + if (pickAnother != null) { + OutlinedButton( + onClick = pickAnother.onClick, + enabled = pickAnother.enabled, + modifier = modifier, + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Pick Another", color = Color.White) + } + } if (!videoEnded) { OutlinedButton( onClick = onKeepWatching, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index a971b0eeb7..ad909a7da4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -394,7 +394,8 @@ fun PlayerOverlay( playbackSpeed = playbackSpeed, // A party plays at 1x; the saved speed is never changed from it. playbackSpeedEnabled = !inRoom, - nextEpisode = state.nextEpisode.takeUnless { inRoom }, + // A shuffle replaces the series order: no sequential next episode. + nextEpisode = state.nextEpisode.takeUnless { inRoom || state.shuffle != null }, brightnessFraction = brightnessFraction, seekEnabled = seekEnabled, playPauseEnabled = playPauseEnabled, @@ -482,7 +483,10 @@ fun PlayerOverlay( modifier = Modifier.zIndex(3f), ) { PlayerNextUpScreen( - nextEpisode = state.nextEpisode ?: retainedUpNextInfo, + // The retained card covers the fade-out only; a shuffle that + // can no longer play anything shows Finished while open. + nextEpisode = state.nextEpisode + ?: retainedUpNextInfo.takeUnless { state.showUpNext && state.shuffle != null }, onVideoBoundsChanged = onNextUpVideoBoundsChanged, onDeckItems = state.onDeckItems, videoEnded = state.upNextVideoEnded, @@ -497,6 +501,13 @@ fun PlayerOverlay( onPlayOnDeckItem = viewModel::playOnDeckItemNow, onBack = handleBack, compactTabletop = tabletopMode, + shuffle = state.shuffle, + onPickAnother = viewModel::pickAnotherShuffle, + onStopShuffling = { + // Stop shuffling leaves the player, back to where the shuffle started. + viewModel.stopShuffling() + handleBack() + }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt index 47d6811919..7c570171c9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt @@ -249,6 +249,8 @@ fun PlayerScreen( // party. When set, a WatchPartyPlayback binds this player to the room and // the room's playback context, not the route, decides what plays. roomId: String? = null, + // The running shuffle this item is a pick of (see PlayerViewModel.attachShuffle). + shuffleId: String? = null, navController: NavHostController, viewModel: PlayerViewModel = koinViewModel(), ) { @@ -841,6 +843,7 @@ fun PlayerScreen( // the route, so nothing plays solo first. if (inRoom) return@LaunchedEffect if (!viewModel.claimInitialRouteLoad()) return@LaunchedEffect + viewModel.attachShuffle(shuffleId) viewModel.loadContent( libraryId = libraryId, contentId = contentId, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index a6ca957ef0..939f7f0aa5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -304,6 +304,8 @@ class PlayerViewModel( // Cached active profile; offline playback reads subtitle preferences from // it instead of waiting on the server. Optional for the same reason. private val activeProfileStore: org.siloserver.silo.model.profile.ActiveProfileStore? = null, + // Shuffle: the server picks what plays next. Optional for the same reason. + private val shuffleFeatureStore: org.siloserver.silo.model.feature.ShuffleFeatureStore? = null, ) : ViewModel() { // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. @@ -341,6 +343,8 @@ class PlayerViewModel( private const val SEEK_SETTLE_DEADLINE_MS = 15_000L // Up-next auto-play countdown length (matches TV's NEXT_UP_COUNTDOWN_SECONDS). const val UP_NEXT_COUNTDOWN_SECONDS = 10 + const val SHUFFLE_CONTINUE_FAILED = "Couldn't continue the shuffle." + const val SHUFFLE_PICK_FAILED = "Couldn't pick another." /** iOS resolveOnDeckItems: section pools feeding the On Deck carousel. */ // Stored orientation-mode values — raw-value parity with iOS // `PlayerOrientationMode` so the device-scoped setting round-trips. @@ -378,11 +382,30 @@ class PlayerViewModel( val runtimeMinutes: Int, val seriesTitle: String? = null, val overview: String? = null, + /** False for a shuffled movie, which has no season or episode line. */ + val isEpisode: Boolean = true, ) { val label: String - get() = "S$seasonNumber·E$episodeNumber" + (title?.let { " — $it" } ?: "") + get() = if (!isEpisode) { + title.orEmpty() + } else { + "S$seasonNumber·E$episodeNumber" + (title?.let { " — $it" } ?: "") + } } + /** + * Set while a shuffle plays: the Up Next card announces the server's + * random pick from the scope [scopeLabel] names, and the player offers no + * sequential next episode. + */ + data class ShuffleUiState( + /** Null until the shuffle has been read. */ + val scopeLabel: String? = null, + val pickingAnother: Boolean = false, + /** A transient failure to continue or pick another. */ + val message: String? = null, + ) + data class PlayerUiState( val isLoading: Boolean = true, val error: String? = null, @@ -474,6 +497,7 @@ class PlayerViewModel( val upNextVideoEnded: Boolean = false, /** Remaining auto-play countdown seconds; null = no countdown (gated / auto-play off). */ val upNextCountdownSeconds: Int? = null, + val shuffle: ShuffleUiState? = null, val preferredAudioLanguage: String? = null, val preferredTextLanguage: String? = null, /** @@ -699,6 +723,15 @@ class PlayerViewModel( private var pendingApproachingEndVideoEnded: Boolean? = null private var upNextCountdownJob: Job? = null private val nextUpTransitionGate = NextUpTransitionGate() + // The running shuffle this player plays picks from; null outside a shuffle. + private var shufflePlayback: org.siloserver.silo.playback.ShufflePlayback? = null + // The later part of a multi-part pick that plays before the shuffle moves on. + private var shuffleNextPartFileId: Int? = null + // Once per item: the server re-checks the next pick when the card opens. + private var shuffleRefreshedForPostRoll = false + private var shuffleAdvanceJob: Job? = null + private var shufflePickJob: Job? = null + private var shuffleMessageJob: Job? = null // Auto-dismiss timer for the transient version-switch failure pill. A new // failure cancels the prior job so a stale one can't clear the fresh // message early (repeated identical failures used to be dismissed within a @@ -1186,6 +1219,8 @@ class PlayerViewModel( // AutoPlayGuard streak intentionally PERSISTS across episodes.) autoAdvanceHandled = false pendingApproachingEndVideoEnded = null + shuffleNextPartFileId = null + shuffleRefreshedForPostRoll = false resetPlaybackRecoveryState() upNextCountdownJob?.cancel() upNextCountdownJob = null @@ -4545,7 +4580,18 @@ class PlayerViewModel( autoPlayGuard.recordUserAction() upNextCountdownJob?.cancel() upNextCountdownJob = null - _uiState.update { it.copy(showUpNext = false, upNextCountdownSeconds = null) } + // An On Deck item is outside the shuffle: playback leaves it, but the + // shuffle stays on the server for another device to continue. An + // advance, part change, or Pick Another still running must not move + // the shuffle or load over the On Deck item. + if (shuffleAdvanceJob?.isActive == true) { + shuffleAdvanceJob?.cancel() + // An advance may already hold the transition for its pick. + nextUpTransitionGate.cancel() + } + shufflePickJob?.cancel() + shufflePlayback = null + _uiState.update { it.copy(showUpNext = false, upNextCountdownSeconds = null, shuffle = null) } viewModelScope.launch { val outgoingSession = retainedOwnedSessionId ?: _uiState.value.sessionId if (!sessionLifecycle.stop(expectedSessionId = outgoingSession)) return@launch @@ -4554,6 +4600,10 @@ class PlayerViewModel( } private fun resolveNextEpisode() { + if (shufflePlayback != null) { + resolveShuffleNext() + return + } val state = _uiState.value val seriesId = state.seriesId ?: return val curSeason = state.seasonNumber ?: return @@ -4620,6 +4670,17 @@ class PlayerViewModel( if (nextUpTransitionGate.isActive) return // Watch Party is authoritative — never auto-advance a room member. if (remoteTransportSuppressed) return + if (shufflePlayback != null) { + // A multi-part pick plays its later parts before the shuffle moves on. + shuffleNextPartFileId?.let { fileId -> + if (videoEnded) playNextShufflePart(fileId) + return + } + if (!shuffleRefreshedForPostRoll) { + shuffleRefreshedForPostRoll = true + refreshShuffleNext() + } + } if (autoAdvanceHandled) { if (videoEnded) { // Keep Watching dismisses the credits prompt, but finishing the @@ -4768,6 +4829,10 @@ class PlayerViewModel( _uiState.update { it.copy(showUpNext = false, upNextCountdownSeconds = null) } return } + if (shufflePlayback != null) { + advanceShuffle() + return + } val nextContentId = _uiState.value.nextEpisode?.contentId ?: return if (!nextUpTransitionGate.begin(nextContentId)) return _uiState.update { @@ -4793,6 +4858,204 @@ class PlayerViewModel( } } + // ---- Shuffle ---------------------------------------------------------------- + + /** + * Plays this player's items as picks of the running shuffle [shuffleId]: + * the Up Next card announces the server's next pick, and no sequential + * next episode is offered. Called once, before the route's first load. + */ + fun attachShuffle(shuffleId: String?) { + if (shuffleId.isNullOrBlank() || shufflePlayback?.shuffleId == shuffleId) return + val store = shuffleFeatureStore ?: return + shufflePlayback = store.playback(shuffleId) + _uiState.update { it.copy(shuffle = ShuffleUiState()) } + } + + private fun resolveShuffleNext() { + val shuffle = shufflePlayback ?: return + val state = _uiState.value + val forContentId = state.contentId + // The post-roll waits for the last part of a multi-part pick. + shuffleNextPartFileId = org.siloserver.silo.playback.nextPlaybackPartFileId( + versions = state.versions, + currentFileId = state.versions.getOrNull(state.selectedVersionIndex)?.fileId, + ) + if (shuffleNextPartFileId != null) return + resolveNextEpisodeJob?.cancel() + // The advance that started this item already returned its next pick. + if (shuffle.latest?.current?.contentId == forContentId) { + publishShuffleNext(forContentId) + return + } + resolveNextEpisodeJob = viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Re-reads the shuffle as the card opens: the server replaces a next pick that can no longer play. */ + private fun refreshShuffleNext() { + val shuffle = shufflePlayback ?: return + val forContentId = _uiState.value.contentId + viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Shows the shuffle's pick after [forContentId], or the finished state when there is none. */ + private fun publishShuffleNext(forContentId: String) { + val shuffle = shufflePlayback ?: return + if (_uiState.value.contentId != forContentId || nextUpTransitionGate.isActive) return + val info = shuffle.nextPickAfter(forContentId)?.let { pick -> + NextEpisodeInfo( + contentId = pick.contentId, + seasonNumber = pick.seasonNumber, + episodeNumber = pick.episodeNumber, + title = pick.title, + stillUrl = pick.stillUrl, + stillThumbhash = pick.stillThumbhash, + runtimeMinutes = pick.runtimeMinutes, + seriesTitle = pick.seriesTitle, + overview = pick.overview, + isEpisode = pick.isEpisode, + ) + } + val previous = _uiState.value.nextEpisode + _uiState.update { + it.copy( + nextEpisode = info, + shuffle = it.shuffle?.copy(scopeLabel = shuffle.scopeLabel ?: it.shuffle.scopeLabel), + ) + } + val countingDownAtEnd = upNextCountdownJob?.isActive == true && _uiState.value.upNextVideoEnded + when { + info == null -> cancelUpNextCountdown() + // A different pick, such as Pick Another, gets the full countdown + // rather than whatever was left of the previous one. + previous != null && previous.contentId != info.contentId && countingDownAtEnd -> { + _uiState.update { it.copy(upNextCountdownSeconds = UP_NEXT_COUNTDOWN_SECONDS) } + startUpNextCountdown() + } + previous == null && !autoAdvanceHandled -> + pendingApproachingEndVideoEnded?.let { videoEnded -> commitApproachingEnd(info, videoEnded) } + } + } + + /** Plays the next part of a multi-part pick from its start. */ + private fun playNextShufflePart(fileId: Int) { + if (shuffleAdvanceJob?.isActive == true) return + shuffleNextPartFileId = null + val contentId = _uiState.value.contentId + val shuffle = shufflePlayback + // Every load moves this counter, so a reload of the same item during the stop also ends the part change. + val loadGeneration = aiPlaybackGeneration + fun stillCurrent() = loadGeneration == aiPlaybackGeneration && + shufflePlayback === shuffle && _uiState.value.contentId == contentId + shuffleAdvanceJob = viewModelScope.launch { + val outgoingSession = retainedOwnedSessionId ?: _uiState.value.sessionId + if (!sessionLifecycle.stop(expectedSessionId = outgoingSession)) { + // Nothing played, so the part is still next when playback ends. + if (stillCurrent() && shuffleNextPartFileId == null) shuffleNextPartFileId = fileId + return@launch + } + if (!stillCurrent()) return@launch + loadContent( + contentId = contentId, + preferredFileId = fileId, + resumePositionOverride = 0.0, + suppressResumeRewind = true, + preserveRouteIntent = true, + ) + } + } + + /** + * Moves the shuffle past the item that played and plays the returned + * pick from the beginning. The server moves on only while that item is + * still current, so a repeated press or a retry plays the same pick. + */ + private fun advanceShuffle() { + val shuffle = shufflePlayback ?: return + if (_uiState.value.nextEpisode == null) return + if (shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val fromContentId = _uiState.value.contentId + _uiState.update { it.copy(upNextCountdownSeconds = null) } + val pendingPick = shufflePickJob + shuffleAdvanceJob = viewModelScope.launch { + // A Pick Another still in flight decides what plays; wait for it. + pendingPick?.join() + if (shufflePlayback !== shuffle) return@launch + val result = shuffle.advance(fromContentId) + if (_uiState.value.contentId != fromContentId || shufflePlayback !== shuffle) return@launch + val target = (result as? ApiResult.Success)?.data?.current?.contentId + if (target == null) { + if (shuffle.exhausted) publishShuffleNext(fromContentId) else showShuffleMessage(SHUFFLE_CONTINUE_FAILED) + return@launch + } + if (!nextUpTransitionGate.begin(target)) return@launch + _uiState.update { + it.copy(showUpNext = true, isNextUpTransitioning = true, upNextCountdownSeconds = null) + } + val outgoingSession = retainedOwnedSessionId ?: _uiState.value.sessionId + if (!sessionLifecycle.stop(expectedSessionId = outgoingSession)) { + nextUpTransitionGate.cancel() + _uiState.update { it.copy(showUpNext = false, isNextUpTransitioning = false) } + return@launch + } + if (!nextUpTransitionGate.isActive) return@launch + loadContent( + contentId = target, + // Like the first pick, every shuffled item plays from the start. + resumePositionOverride = 0.0, + suppressResumeRewind = true, + ) + } + } + + /** Up Next "Pick Another": the server replaces the announced pick. */ + fun pickAnotherShuffle() { + val shuffle = shufflePlayback ?: return + if (shufflePickJob?.isActive == true || shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val forContentId = _uiState.value.contentId + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = true)) } + shufflePickJob = viewModelScope.launch { + val result = shuffle.pickAnother() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = false)) } + if (_uiState.value.contentId != forContentId || shufflePlayback !== shuffle) return@launch + if (result !is ApiResult.Success && !shuffle.exhausted) { + showShuffleMessage(SHUFFLE_PICK_FAILED) + return@launch + } + publishShuffleNext(forContentId) + } + } + + /** + * Up Next "Stop shuffling": ends the shuffle on the server. The screen + * leaves the player right after, back to where the shuffle started. + */ + fun stopShuffling() { + val shuffle = shufflePlayback ?: return + shufflePlayback = null + shuffleAdvanceJob?.cancel() + shufflePickJob?.cancel() + upNextCountdownJob?.cancel() + upNextCountdownJob = null + shuffle.stopInBackground() + _uiState.update { it.copy(shuffle = null, nextEpisode = null, upNextCountdownSeconds = null) } + } + + private fun showShuffleMessage(message: String) { + shuffleMessageJob?.cancel() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = message)) } + shuffleMessageJob = viewModelScope.launch { + delay(4_000) + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = null)) } + } + } + // ---- Settings setters (forward to per-profile DataStore) ------------------- fun onSetPlaybackSpeed(value: Double) { viewModelScope.launch { playerSettingsStore.setPlaybackSpeed(value) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/shuffle/ShuffleLauncher.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/shuffle/ShuffleLauncher.kt new file mode 100644 index 0000000000..0c5c8c4df6 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/shuffle/ShuffleLauncher.kt @@ -0,0 +1,21 @@ +package org.siloserver.silo.android.ui.screens.shuffle + +import androidx.compose.runtime.Composable +import org.koin.compose.koinInject +import org.siloserver.silo.android.ui.navigation.Route +import org.siloserver.silo.common.ui.ShuffleLauncher +import org.siloserver.silo.model.shuffle.Shuffle + +/** A [ShuffleLauncher] that calls [onStarted] with each shuffle it starts. */ +@Composable +fun rememberShuffleLauncher(onStarted: (Shuffle) -> Unit): ShuffleLauncher = + org.siloserver.silo.common.ui.rememberShuffleLauncher(koinInject(), onStarted) + +/** The player route for a shuffle's first pick, which plays from the beginning. */ +fun shufflePlayerRoute(shuffle: Shuffle, libraryId: Int? = null): String = + Route.Player( + contentId = shuffle.current.contentId, + resumePositionSeconds = 0.0, + libraryId = libraryId, + shuffleId = shuffle.id, + ).route diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index 3828797eab..3000402da3 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -700,6 +700,9 @@ class PlayerViewModelLoadOwnershipIntegrationTest { private fun playerViewModel( starter: DeferredNonCooperativeStarter, scope: CoroutineScope, + shufflesApi: org.siloserver.silo.network.apiv2.ShufflesV2Api? = null, + shuffleStore: org.siloserver.silo.model.feature.ShuffleFeatureStore? = + shufflesApi?.let { org.siloserver.silo.model.feature.ShuffleFeatureStore(it) }, ): PlayerFixture { val client = noOpClient() val tokenManager = FakeTokenManager() @@ -749,12 +752,302 @@ class PlayerViewModelLoadOwnershipIntegrationTest { scopeProvider = { null }, write = {}, ), + shuffleFeatureStore = shuffleStore, ), manager = manager, lifecycle = lifecycle, ) } + /** A shuffle server whose answers each test scripts; it records every request. */ + private class FakeShuffleServer { + val requests: MutableList = java.util.Collections.synchronizedList(mutableListOf()) + var read: String = shuffle(current = EPISODE_A, next = MOVIE_B) + var advance: String = shuffle(current = MOVIE_B, next = EPISODE_C) + var skip: String = shuffle(current = EPISODE_A, next = EPISODE_C) + var readStatus: HttpStatusCode = HttpStatusCode.OK + /** Holds the skip response until completed, to model a slow Pick Another. */ + var skipGate: kotlinx.coroutines.CompletableDeferred? = null + + val api = org.siloserver.silo.network.apiv2.ShufflesV2Api( + HttpClient(MockEngine { request -> + val body = (request.body as? io.ktor.http.content.TextContent)?.text.orEmpty() + requests += "${request.method.value} ${request.url.encodedPath} $body".trim() + val path = request.url.encodedPath + val (status, json) = when { + request.method.value == "DELETE" -> HttpStatusCode.NoContent to "" + path == "/api/v2/shuffles" -> HttpStatusCode.Created to + shuffle(current = EPISODE_A, next = MOVIE_B) + path.endsWith("/advance") -> HttpStatusCode.OK to advance + path.endsWith("/skip") -> { + skipGate?.await() + HttpStatusCode.OK to skip + } + readStatus.value >= 400 -> readStatus to + """{"type":"https://siloserver.org/docs/api/v2/problems/conflict","title":"Conflict","status":${readStatus.value}}""" + else -> HttpStatusCode.OK to read + } + respond(json, status, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(SiloJson) } }, + FakeTokenManager(), + ApiV2Gate.Unrestricted, + ) + + companion object { + const val EPISODE_A = + """{"content_id":"episode-a","type":"episode","title":"Pilot","series_title":"Show","season_number":1,"episode_number":1}""" + const val MOVIE_B = """{"content_id":"movie-b","type":"movie","title":"Film","runtime":90}""" + const val EPISODE_C = + """{"content_id":"episode-c","type":"episode","title":"Third","series_title":"Show","season_number":0,"episode_number":3}""" + + fun shuffle(current: String, next: String) = + """{"id":"sh1","scope":{"kind":"season","id":"show-S1","title":"Season 1","parent_title":"Show"},"current":$current,"next":$next,"created_at":"2026-10-05T10:00:00.000Z","updated_at":"2026-10-05T10:00:00.000Z"}""" + } + } + + private suspend fun TestScope.startShuffledEpisode( + starter: DeferredNonCooperativeStarter, + viewModel: PlayerViewModel, + ) { + viewModel.attachShuffle("sh1") + viewModel.loadContent("episode-a", preferredFileId = 1, resumePositionOverride = 0.0, suppressResumeRewind = true) + starter.awaitRequestCount(1) + starter.complete(0, ready(starter.request(0), "session-a")) + viewModel.awaitState { it.sessionId == "session-a" && !it.isLoading } + runCurrent() + } + + @Test + fun shuffleAnnouncesTheServerPickAndAdvancesToItFromTheBeginning() = runTest(dispatcher) { + val server = FakeShuffleServer() + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + val state = viewModel.uiState.value + assertEquals("Show · Season 1", state.shuffle?.scopeLabel) + val next = requireNotNull(state.nextEpisode) + assertEquals("movie-b", next.contentId) + // A movie heads the card with its own title, without an S:E line. + assertFalse(next.isEpisode) + assertEquals("Film", next.label) + + viewModel.onApproachingEnd(videoEnded = true) + assertTrue(viewModel.uiState.value.showUpNext) + assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS, viewModel.uiState.value.upNextCountdownSeconds) + + viewModel.playUpNextNow() + starter.awaitRequestCount(2) + // A second press while the advance is in flight plays the same pick once. + viewModel.playUpNextNow() + runCurrent() + val successor = starter.request(1) + assertEquals("movie-b", successor.contentId) + assertEquals(0.0, successor.resumePositionOverride) + assertTrue(successor.suppressResumeRewind) + assertEquals(2, starter.startedRequestCount) + assertEquals( + 1, + server.requests.count { it == """POST /api/v2/shuffles/sh1/advance {"from_content_id":"episode-a"}""" }, + ) + + starter.complete(1, ready(successor, "session-b")) + viewModel.awaitState { it.sessionId == "session-b" && !it.isLoading } + viewModel.onFirstVideoFrameRendered(viewModel.uiState.value.mediaMountGeneration) + runCurrent() + // The advance already named the following pick; no sequential episode replaces it. + assertEquals("episode-c", viewModel.uiState.value.nextEpisode?.contentId) + assertTrue(viewModel.uiState.value.nextEpisode?.isEpisode == true) + } finally { + store.clear() + } + } + + @Test + fun playNowDuringASlowPickAnotherPlaysTheNewPick() = runTest(dispatcher) { + val server = FakeShuffleServer().apply { skipGate = kotlinx.coroutines.CompletableDeferred() } + server.advance = FakeShuffleServer.shuffle(FakeShuffleServer.EPISODE_C, FakeShuffleServer.MOVIE_B) + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + viewModel.onApproachingEnd(videoEnded = true) + + viewModel.pickAnotherShuffle() + runCurrent() + assertTrue(viewModel.uiState.value.shuffle?.pickingAnother == true) + // Play Now while Pick Another is still waiting for the server. + viewModel.playUpNextNow() + runCurrent() + assertTrue(server.requests.none { it.contains("/advance") }) + + requireNotNull(server.skipGate).complete(Unit) + starter.awaitRequestCount(2) + assertEquals("episode-c", starter.request(1).contentId) + val skip = server.requests.indexOfFirst { it.contains("/skip") } + val advance = server.requests.indexOfFirst { it.contains("/advance") } + assertTrue(skip in 0 until advance) + } finally { + store.clear() + } + } + + @Test + fun theFirstPickKnowsItsNextFromTheStartEvenWhenReadsFail() = runTest(dispatcher) { + val server = FakeShuffleServer().apply { readStatus = HttpStatusCode.ServiceUnavailable } + val starter = DeferredNonCooperativeStarter() + val shuffleStore = org.siloserver.silo.model.feature.ShuffleFeatureStore(server.api) + assertTrue( + shuffleStore.start(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY, "1") is + org.siloserver.silo.model.feature.ShuffleStart.Started, + ) + val fixture = playerViewModel(starter, backgroundScope, server.api, shuffleStore) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.onApproachingEnd(videoEnded = true) + // The card's re-read fails like every read here. + withContext(Dispatchers.Default) { + withTimeout(5_000) { + while (server.requests.toList().none { it == "GET /api/v2/shuffles/sh1" }) delay(10) + } + } + runCurrent() + // The pick the start returned still plays next. + assertEquals("movie-b", viewModel.uiState.value.nextEpisode?.contentId) + assertTrue(viewModel.uiState.value.showUpNext) + } finally { + store.clear() + } + } + + @Test + fun aShuffleWithOnePlayableItemShowsFinishedAndPlayNowDoesNothing() = runTest(dispatcher) { + val server = FakeShuffleServer().apply { + read = FakeShuffleServer.shuffle(FakeShuffleServer.EPISODE_A, FakeShuffleServer.EPISODE_A) + } + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.onApproachingEnd(videoEnded = true) + runCurrent() + assertTrue(viewModel.uiState.value.showUpNext) + assertNull(viewModel.uiState.value.nextEpisode) + assertNull(viewModel.uiState.value.upNextCountdownSeconds) + viewModel.playUpNextNow() + testScheduler.advanceTimeBy(30_000) + runCurrent() + assertEquals(1, starter.startedRequestCount) + assertTrue(server.requests.none { it.contains("/advance") }) + } finally { + store.clear() + } + } + + @Test + fun aShuffleThatCanNoLongerPlayAnythingShowsFinishedWhenTheCardOpens() = runTest(dispatcher) { + val server = FakeShuffleServer() + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + server.readStatus = HttpStatusCode.Conflict + viewModel.onApproachingEnd(videoEnded = true) + viewModel.awaitState { it.nextEpisode == null } + assertTrue(viewModel.uiState.value.showUpNext) + assertNull(viewModel.uiState.value.upNextCountdownSeconds) + } finally { + store.clear() + } + } + + @Test + fun pickAnotherReplacesThePickWithAFullCountdownAndStopEndsTheShuffle() = runTest(dispatcher) { + val server = FakeShuffleServer() + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + viewModel.awaitState { it.nextEpisode != null } + viewModel.onApproachingEnd(videoEnded = true) + testScheduler.advanceTimeBy(3_500) + runCurrent() + assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS - 3, viewModel.uiState.value.upNextCountdownSeconds) + + viewModel.pickAnotherShuffle() + viewModel.awaitState { it.nextEpisode?.contentId == "episode-c" } + assertTrue(server.requests.contains("""POST /api/v2/shuffles/sh1/skip {"next_content_id":"movie-b"}""")) + assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS, viewModel.uiState.value.upNextCountdownSeconds) + + viewModel.stopShuffling() + assertNull(viewModel.uiState.value.shuffle) + assertNull(viewModel.uiState.value.nextEpisode) + testScheduler.advanceTimeBy(30_000) + runCurrent() + // Stopping cancels the countdown: nothing else starts. + assertEquals(1, starter.startedRequestCount) + } finally { + store.clear() + } + } + + @Test + fun aReplacementLoadDuringThePartChangeStopKeepsThePartChangeFromLoading() = runTest(dispatcher) { + val server = FakeShuffleServer() + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope, server.api) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + startShuffledEpisode(starter, viewModel) + // Part 1 of a two-part pick is playing; part 2 is queued. + PlayerViewModel::class.java.getDeclaredField("shuffleNextPartFileId").let { + it.isAccessible = true + it.set(viewModel, 2) + } + fixture.lifecycle.adoptActiveSession( + params = StartParams( + contentId = "episode-a", fileId = 1, capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "mobile", appVersion = "test"), + ), + session = allocatedReady("session-a").session, + manageProgress = false, + ) + fixture.manager.sequenced = true + val stopGate = kotlinx.coroutines.CompletableDeferred() + fixture.manager.stopGate = stopGate + + // Part 1 ends: the part change stops the session and is held there. + viewModel.onApproachingEnd(videoEnded = true) + fixture.manager.awaitStopped("session-a") + // A replacement load of the same item starts while that stop is pending. + viewModel.loadContent("episode-a", preferredFileId = 1, preserveRouteIntent = true) + stopGate.complete(Unit) + starter.awaitRequestCount(2) + runCurrent() + + assertEquals(2, starter.startedRequestCount) + assertEquals(1, starter.request(1).preferredFileId) + } finally { + store.clear() + } + } + private fun ready( request: VideoPlaybackStartRequest, sessionId: String, @@ -1261,6 +1554,8 @@ private class RecordingPlaybackSessionManager( ) { var sequenced = false var stopResult: ApiResult = ApiResult.Success(Unit) + /** Holds every stop until completed, to model a slow stop request. */ + var stopGate: kotlinx.coroutines.CompletableDeferred? = null override fun isSequenced(sessionId: String): Boolean = sequenced private val stopped = mutableListOf() @@ -1280,6 +1575,7 @@ private class RecordingPlaybackSessionManager( stopActiveContexts += contextActive } stoppedSignal.update { it + sessionId } + stopGate?.await() return stopResult } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index f7d0aa5b27..7be730521b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -559,6 +559,7 @@ val androidTvModule = module { catalogRepository = get(), serverReachabilityMonitor = get(), launchArgs = params.get(), + shuffleFeatureStore = get(), ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt index 777c723071..8b675748fc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt @@ -72,9 +72,12 @@ fun TvOptionDialog( title: String, options: List, onDismiss: () -> Unit, + /** The option to focus first, when it is enabled; otherwise the selected or first enabled one. */ + initialFocusKey: String? = null, ) { val firstRowFocus = remember { FocusRequester() } - val focusedKey = options.firstOrNull { it.selected && it.enabled }?.key + val focusedKey = initialFocusKey?.takeIf { key -> options.any { it.key == key && it.enabled } } + ?: options.firstOrNull { it.selected && it.enabled }?.key ?: options.firstOrNull { it.enabled }?.key val focusedIndex = options.indexOfFirst { it.key == focusedKey } val listState: LazyListState = rememberLazyListState() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 727a117e32..0726fc141f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -267,6 +267,24 @@ private fun tvIsWatchPartyEntry(entry: NavBackStackEntry, destination: WatchPart entry.arguments?.getString(TvRoute.Player.ARG_ROOM_ID) == destination.roomId } +/** Plays a shuffle's first pick, from the beginning like every pick. */ +private fun NavHostController.navigateToTvShufflePlayback( + shuffle: org.siloserver.silo.model.shuffle.Shuffle, + libraryId: Int?, + lastPlaybackNavigation: MutableState, +) { + navigateToTvPlayback( + destination = TvRoute.Player( + contentId = shuffle.current.contentId, + resumePositionSeconds = 0.0, + libraryId = libraryId, + shuffleId = shuffle.id, + ).route, + contentId = shuffle.current.contentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) +} + /** * Shows a Watch Party screen: the hub (null), the room's lobby, or its * player. A party screen already on the back stack is returned to instead of @@ -970,6 +988,9 @@ fun TvAppNavigation( launchSingleTop = true } }, + onPlayShuffle = { shuffle, libraryId -> + navController.navigateToTvShufflePlayback(shuffle, libraryId, lastPlaybackNavigation) + }, onPlayItem = { playContentId, itemType, resumePositionSeconds -> // A fast double Select otherwise stacks a second player, // starting two sessions and leaving Back on a duplicate. @@ -1063,6 +1084,9 @@ fun TvAppNavigation( // actually binds to that version instead of always defaulting // to the server's first listed file (which for multi-version // titles is often the lower-resolution encode). + onShuffleStarted = { shuffle -> + navController.navigateToTvShufflePlayback(shuffle, libraryId, lastPlaybackNavigation) + }, onPlay = { playContentId, fileId, audioTrackIndex, audioPicked, subtitleSelection, itemType, resumePositionSeconds -> // A fast Select after entering detail can overlap the route // transition. Collapse an identical second Play request @@ -1253,6 +1277,11 @@ fun TvAppNavigation( nullable = true defaultValue = null }, + navArgument(TvRoute.Player.ARG_SHUFFLE_ID) { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStack -> val contentId = backStack.arguments @@ -1336,6 +1365,7 @@ fun TvAppNavigation( autoAdvanceCount = autoAdvanceCount, episodeSelectionHandoff = episodeSelectionHandoff, navigationSettled = !transition.isRunning, + shuffleId = backStack.arguments?.getString(TvRoute.Player.ARG_SHUFFLE_ID), onExit = { navController.popBackStack() }, // Host Stop: back to the room's lobby in place of the player. // The membership is kept, so the lobby follows the next Start. @@ -1509,6 +1539,9 @@ fun TvAppNavigation( navController.navigateToTvItemDetail(contentId, libraryId = libraryId) }, onBack = { navController.popBackStack() }, + onShuffleStarted = { shuffle -> + navController.navigateToTvShufflePlayback(shuffle, libraryId, lastPlaybackNavigation) + }, ) } @@ -1535,6 +1568,9 @@ fun TvAppNavigation( navController.navigateToTvItemDetail(contentId) }, onBack = { navController.popBackStack() }, + onShuffleStarted = { shuffle -> + navController.navigateToTvShufflePlayback(shuffle, null, lastPlaybackNavigation) + }, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt index 27ca4d96bf..0e176f0aea 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt @@ -127,6 +127,8 @@ sealed class TvRoute(val route: String) { /** Opaque key for a process-only, target-bound episode selection handoff. */ val episodeSelectionHandoffNonce: String? = null, val libraryId: Int? = null, + /** The running shuffle this item is a pick of; picks play from the beginning. */ + val shuffleId: String? = null, ) : TvRoute( buildString { append("player/${contentId.routeEncode()}") @@ -150,6 +152,7 @@ sealed class TvRoute(val route: String) { VideoPlayerRouteArgs.encodeResumePosition(resumePositionSeconds)?.let { value -> add("${VideoPlayerRouteArgs.RESUME_POSITION}=$value") } + shuffleId?.takeIf { it.isNotBlank() }?.let { add("$ARG_SHUFFLE_ID=${it.routeEncode()}") } } if (query.isNotEmpty()) append("?").append(query.joinToString("&")) }, @@ -160,7 +163,8 @@ sealed class TvRoute(val route: String) { "&subtitleTrackIndex={subtitleTrackIndex}" + "&subtitleAutoResolved={subtitleAutoResolved}" + "&autoAdvanceCount={autoAdvanceCount}&resumePosition={resumePosition}" + - "&episodeSelectionHandoffNonce={episodeSelectionHandoffNonce}" + "&episodeSelectionHandoffNonce={episodeSelectionHandoffNonce}" + + "&shuffleId={shuffleId}" const val ARG_CONTENT_ID = "contentId" const val ARG_FILE_ID = "fileId" const val ARG_QUALITY = "quality" @@ -172,6 +176,7 @@ sealed class TvRoute(val route: String) { const val ARG_AUTO_ADVANCE_COUNT = "autoAdvanceCount" const val ARG_RESUME_POSITION = VideoPlayerRouteArgs.RESUME_POSITION const val ARG_EPISODE_SELECTION_HANDOFF_NONCE = "episodeSelectionHandoffNonce" + const val ARG_SHUFFLE_ID = "shuffleId" } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionDetailScreen.kt index 703706cbd1..73f6400029 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionDetailScreen.kt @@ -38,6 +38,8 @@ fun TvCollectionDetailScreen( title: String, onItemClick: (contentId: String) -> Unit, onBack: () -> Unit, + // Plays the first pick of a shuffle started from the header. + onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvCollectionDetailViewModel = koinViewModel( key = "collection-$collectionId", parameters = { parametersOf(collectionId, title) }, @@ -45,6 +47,10 @@ fun TvCollectionDetailScreen( ) { val state by viewModel.uiState.collectAsState() val gridState = rememberLazyGridState() + val shuffleLauncher = org.siloserver.silo.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) BackHandler(enabled = true) { onBack() } @@ -76,14 +82,32 @@ fun TvCollectionDetailScreen( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - Text( - text = state.name.ifBlank { title }, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, + androidx.compose.foundation.layout.Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 12.dp), - ) + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + text = state.name.ifBlank { title }, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.weight(1f), + ) + if ( + state.items.isNotEmpty() && + shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION) + ) { + org.siloserver.silo.tv.ui.screens.library.TvShufflePill( + onClick = { + shuffleLauncher.start( + org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION, + collectionId, + ) + }, + ) + } + } when { state.isLoading && state.items.isEmpty() -> TvLoadingScreen() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index ee49c5044c..263590a5b6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -194,12 +194,18 @@ fun TvItemDetailScreen( onWatchParty: (WatchPartyDestination?) -> Unit, onOpenPerson: (personId: Long) -> Unit, onBack: () -> Unit, + // Plays the first pick of a shuffle started from the More menu. + onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvItemDetailViewModel = koinViewModel( key = "item-detail-$contentId-$libraryId-${seasonNumber ?: "default"}-${initialEpisodeContentId ?: "default"}", parameters = { parametersOf(contentId, libraryId) }, ), ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.siloserver.silo.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) val seriesRedirect = remember(state.detail) { state.detail?.let(::tvSeriesDetailRedirect) } @@ -327,6 +333,7 @@ fun TvItemDetailScreen( onSeasonClick = onSeasonClick, onWatchParty = onWatchParty, onOpenPerson = onOpenPerson, + shuffleLauncher = shuffleLauncher, ) } } @@ -347,6 +354,7 @@ private fun TvDetailContent( onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, onWatchParty: (WatchPartyDestination?) -> Unit, onOpenPerson: (personId: Long) -> Unit, + shuffleLauncher: org.siloserver.silo.common.ui.ShuffleLauncher, ) { val playFocus = remember { FocusRequester() } // The circular Version control in the hero action cluster. Hoisted here so @@ -864,6 +872,35 @@ private fun TvDetailContent( watchedSeason = state.seasons .firstOrNull { it.seasonNumber == state.selectedSeason } ?.takeIf { isSeriesDetail && !isShowingSeriesOverview && it.episodeCount > 0 }, + onShuffleSeries = if ( + isSeriesDetail && + shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.SERIES) + ) { + { + shuffleLauncher.start( + org.siloserver.silo.model.shuffle.ShuffleScopeKind.SERIES, + detail.contentId, + ) + } + } else { + null + }, + // Season mode offers the season on screen, + // hidden with fewer than two playable episodes. + shuffleSeason = state.seasons + .firstOrNull { it.seasonNumber == state.selectedSeason } + ?.takeIf { season -> + isSeriesDetail && !isShowingSeriesOverview && !state.episodesLoading && + state.episodes.all { it.seasonNumber == season.seasonNumber } && + org.siloserver.silo.model.shuffle.canShuffleSeason(state.episodes) && + shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.SEASON) + }, + onShuffleSeason = { season -> + shuffleLauncher.start( + org.siloserver.silo.model.shuffle.ShuffleScopeKind.SEASON, + season.contentId, + ) + }, ) }, ) @@ -1512,6 +1549,9 @@ private fun HeroActionRow( libraryId: Int?, onWatchParty: (WatchPartyDestination?) -> Unit, watchedSeason: org.siloserver.silo.model.catalog.Season? = null, + onShuffleSeries: (() -> Unit)? = null, + shuffleSeason: org.siloserver.silo.model.catalog.Season? = null, + onShuffleSeason: (org.siloserver.silo.model.catalog.Season) -> Unit = {}, ) { val watchPartyEntry = rememberTvWatchPartyDetailEntry() var moreOpen by remember(detail.contentId) { mutableStateOf(false) } @@ -1790,6 +1830,31 @@ private fun HeroActionRow( if (moreOpen) { val options = buildList { + // Shuffle leads the menu, as on the web. + if (onShuffleSeries != null) { + add( + TvDialogOption( + key = "shuffle-series", + title = "Shuffle Series", + onClick = { + moreOpen = false + onShuffleSeries() + }, + ), + ) + } + shuffleSeason?.let { season -> + add( + TvDialogOption( + key = "shuffle-season", + title = "Shuffle ${tvSeasonPickerLabel(season)}", + onClick = { + moreOpen = false + onShuffleSeason(season) + }, + ), + ) + } add( TvDialogOption( key = "favorite", @@ -1877,6 +1942,8 @@ private fun HeroActionRow( title = "More Actions", options = options, onDismiss = { moreOpen = false }, + // Shuffle is first and takes focus over a selected toggle below it. + initialFocusKey = options.firstOrNull()?.key?.takeIf { it.startsWith("shuffle-") }, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt index bb2cffe202..95a7503ded 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt @@ -26,6 +26,8 @@ fun TvLibrariesScreen( // route to the user-collection detail rather than the library one (#69). onUserCollectionClick: (collectionId: String, title: String) -> Unit, onInitialContentFocus: () -> Unit = {}, + /** Plays a shuffle the library page started, bound to that library. */ + onPlayShuffle: (shuffle: org.siloserver.silo.model.shuffle.Shuffle, libraryId: Int?) -> Unit, viewModel: TvLibrariesViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -65,6 +67,7 @@ fun TvLibrariesScreen( } }, onInitialContentFocus = onInitialContentFocus, + onShuffleStarted = { shuffle -> onPlayShuffle(shuffle, selectedLibrary.id) }, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt index 4e79bd2240..a208a474be 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.outlined.Cancel @@ -106,6 +107,8 @@ fun TvBrowseControlRow( * grid below has no card to give it to (an empty or fully-filtered list). */ sortPillFocusRequester: FocusRequester? = null, + /** Starts a shuffle of what the grid browses; null where nothing can shuffle. */ + onShuffle: (() -> Unit)? = null, ) { // Clearing removes the Clear pill from composition; focus must hop to the // Filter pill first or it would snap away to the nearest surviving scope. @@ -214,6 +217,28 @@ fun TvBrowseControlRow( ) } } + + if (onShuffle != null) TvShufflePill(onClick = onShuffle) + } +} + +/** A Shuffle action drawn like the browse control pills beside it. */ +@Composable +fun TvShufflePill(onClick: () -> Unit, modifier: Modifier = Modifier) { + BrowseControlPill(onClick = onClick, modifier = modifier) { foreground -> + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = foreground, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Shuffle", + style = MaterialTheme.typography.labelLarge.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = foreground, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt index 67745aae96..eddc523bb3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt @@ -46,12 +46,31 @@ fun TvLibraryCollectionDetailScreen( libraryType: String, onItemClick: (contentId: String) -> Unit, onBack: () -> Unit, + // Plays the first pick of a shuffle started from the header. + onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvLibraryCollectionDetailViewModel = koinViewModel( key = "library-collection-$libraryId-$collectionId", parameters = { parametersOf(libraryId, collectionId, title) }, ), ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.siloserver.silo.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) + val onShuffle = if ( + state.items.isNotEmpty() && + shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION) + ) { + { + shuffleLauncher.start( + org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION, + collectionId, + ) + } + } else { + null + } BackHandler(onBack = onBack) @@ -125,6 +144,7 @@ fun TvLibraryCollectionDetailScreen( onSort = { openPanel = TvCollectionPanel.Sort }, onFilter = { openPanel = TvCollectionPanel.Filter }, onClearFilters = viewModel::clearFilters, + onShuffle = onShuffle, ) }, emptyState = { @@ -174,6 +194,7 @@ private fun CollectionHeader( onSort: () -> Unit, onFilter: () -> Unit, onClearFilters: () -> Unit, + onShuffle: (() -> Unit)? = null, ) { Column(modifier = Modifier.fillMaxWidth()) { Text( @@ -195,6 +216,7 @@ private fun CollectionHeader( onSort = onSort, onFilter = onFilter, onClearFilters = onClearFilters, + onShuffle = onShuffle, ) Spacer(modifier = Modifier.weight(1f)) itemCountLabel(state)?.let { label -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt index c83919de7c..f44a0a5681 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -114,12 +114,27 @@ fun TvLibraryDetailScreen( // again rather than being keyed on the section value alone. sectionRequestNonce: Int = 0, onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + // Plays the first pick of a shuffle started from the Library tab. + onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvLibraryDetailViewModel = koinViewModel( key = "library-$libraryId", parameters = { parametersOf(libraryId, libraryTitle, libraryType) }, ), ) { val state by viewModel.uiState.collectAsState() + val shuffleLauncher = org.siloserver.silo.common.ui.rememberShuffleLauncher( + org.koin.compose.koinInject(), + onShuffleStarted, + ) + // Movie, TV, and mixed libraries shuffle when the server offers it. + val onShuffleLibrary = if ( + org.siloserver.silo.model.shuffle.isShuffleLibraryType(libraryType) && + shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY) + ) { + { shuffleLauncher.start(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY, libraryId.toString()) } + } else { + null + } // Apply the committed cascade section on entry / whenever the commit // changes it. Keyed on sectionRequestNonce (bumped on every commit) AND the @@ -161,6 +176,7 @@ fun TvLibraryDetailScreen( onSortKeySelected = viewModel::onSortKeySelected, onFacetSelectionApplied = viewModel::onFacetSelectionApplied, onContentUpFallbackChanged = onContentUpFallbackChanged, + onShuffle = onShuffleLibrary, ) TvLibraryTab.Genres -> LibraryTab( state = state, @@ -332,6 +348,7 @@ private fun LibraryTab( /** Shell hook for overriding D-pad Up while the A–Z rail holds focus. */ onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, onClearAudiobookGroup: (() -> Unit)? = null, + onShuffle: (() -> Unit)? = null, ) { val restoredGridItemFocusRequester = remember { FocusRequester() } val gridState = rememberLazyGridState() @@ -407,6 +424,7 @@ private fun LibraryTab( onOpenSortPanel = { openPanel = TvBrowsePanel.Sort }, onOpenFilterPanel = { openPanel = TvBrowsePanel.Filter }, onClearFilters = { onFacetSelectionApplied(TvCatalogFacetSelection()) }, + onShuffle = onShuffle, ) } // The A–Z jump rail only makes sense for title-sorted browsing (the @@ -466,6 +484,7 @@ private fun LibraryGrid( onOpenSortPanel: () -> Unit = {}, onOpenFilterPanel: () -> Unit = {}, onClearFilters: () -> Unit = {}, + onShuffle: (() -> Unit)? = null, ) { var attachedRestoreItemId by remember { mutableStateOf(null) } val nearEnd by remember( @@ -560,6 +579,7 @@ private fun LibraryGrid( onFilter = onOpenFilterPanel, onClearFilters = onClearFilters, modifier = Modifier.onFocusChanged { controlsFocused = it.hasFocus }, + onShuffle = onShuffle, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index acbefd442f..e6dc7bf2a0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -276,11 +277,13 @@ fun TvPlayerScreen( // False while Navigation fades this screen in or out. True Black Bars // keeps the black plate meanwhile (see `clearBars`). navigationSettled: Boolean = true, + // The running shuffle this item is a pick of; picks play from the beginning. + shuffleId: String? = null, // Scope the ViewModel key by fileId too so switching 4K <-> 1080p on // the detail screen and replaying actually spins up a fresh player // session instead of reusing the cached one bound to the first fileId. viewModel: TvPlayerViewModel = koinViewModel( - key = "tv-player-$contentId-$libraryId-${preferredFileId ?: "auto"}-${preferredQuality ?: "quality-auto"}-${roomId ?: "solo"}-${resumePositionOverride ?: "server"}-${initialAudioTrackIndex ?: "a"}-${initialSubtitleTrackIndex ?: "s"}", + key = "tv-player-$contentId-$libraryId-${preferredFileId ?: "auto"}-${preferredQuality ?: "quality-auto"}-${roomId ?: "solo"}-${resumePositionOverride ?: "server"}-${initialAudioTrackIndex ?: "a"}-${initialSubtitleTrackIndex ?: "s"}-${shuffleId ?: "in-order"}", parameters = { parametersOf( TvPlayerLaunchArgs( @@ -296,6 +299,7 @@ fun TvPlayerScreen( initialSubtitleAutoResolved = initialSubtitleAutoResolved, autoAdvanceCount = autoAdvanceCount, episodeSelectionHandoff = episodeSelectionHandoff, + shuffleId = shuffleId, ), ) }, @@ -2379,6 +2383,7 @@ fun TvPlayerScreen( hudOpen = state.hudOpen, showNextUp = state.showNextUp, nextEpisode = state.nextEpisode, + shuffle = state.shuffle, nextUpVideoEnded = state.nextUpVideoEnded, nextUpCountdownSeconds = state.nextUpCountdownSeconds, nextUpCountdownTotalSeconds = state.nextUpCountdownTotalSeconds, @@ -2400,6 +2405,12 @@ fun TvPlayerScreen( ), onPlayNextNow = viewModel::playNextEpisodeNow, onKeepWatching = viewModel::dismissNextUp, + onPickAnother = viewModel::pickAnotherShuffle, + onStopShuffling = { + // Stop shuffling leaves the player, back to where the shuffle started. + viewModel.stopShuffling() + stopPlaybackAndExit() + }, onToggleAutoPlayNext = { viewModel.onSetAutoPlayNext(!autoPlayNextEnabled) }, onExitPlayback = { stopPlaybackAndExit() }, onNextUpVideoBoundsChanged = { nextUpVideoBounds = it }, @@ -3051,6 +3062,11 @@ private fun TvPlayerNextUpOverlay( onToggleAutoPlay: () -> Unit, onBack: () -> Unit, onVideoBoundsChanged: (Rect) -> Unit, + // Set while a shuffle plays: the next item is a random pick, and the + // viewer can pick another or stop shuffling. + shuffle: TvShuffleUiState? = null, + onPickAnother: () -> Unit = {}, + onStopShuffling: () -> Unit = {}, ) { val primaryFocus = remember { FocusRequester() } var upNextHasFocus by remember { mutableStateOf(false) } @@ -3096,8 +3112,32 @@ private fun TvPlayerNextUpOverlay( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(20.dp), ) { + shuffle?.scopeLabel?.let { scope -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color.White.copy(alpha = 0.10f)) + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.tv.material3.Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(16.dp), + ) + androidx.tv.material3.Text( + text = "Shuffling $scope", + color = Color.White.copy(alpha = 0.75f), + style = androidx.tv.material3.MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + } + } val eyebrow = when { nextEpisode == null -> if (videoEnded) "Finished" else "More To Watch" + shuffle != null -> "Up Next at Random" videoEnded -> "Playing Next" else -> "Up Next" } @@ -3109,15 +3149,18 @@ private fun TvPlayerNextUpOverlay( if (nextEpisode != null) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - nextEpisode.seriesTitle?.takeIf { it.isNotBlank() }?.let { seriesTitle -> + // A shuffled movie has no series line; its own title heads the panel. + val heading = nextEpisode.seriesTitle?.takeIf { it.isNotBlank() } + ?: nextEpisode.title.takeUnless { nextEpisode.isEpisode } + heading?.let { title -> androidx.tv.material3.Text( - text = seriesTitle, + text = title, color = Color.White, style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, maxLines = 1, ) } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (nextEpisode.isEpisode) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { androidx.tv.material3.Text( text = "S${nextEpisode.seasonNumber}·E${nextEpisode.episodeNumber}", color = Color.White.copy(alpha = 0.62f), @@ -3166,6 +3209,14 @@ private fun TvPlayerNextUpOverlay( } } + if (shuffle != null) { + TvDialogActionRow( + title = "Pick Another", + onClick = onPickAnother, + enabled = !shuffle.pickingAnother, + modifier = Modifier.width(260.dp), + ) + } if (!videoEnded) { TvDialogActionRow( title = "Keep Watching", @@ -3178,21 +3229,49 @@ private fun TvPlayerNextUpOverlay( onClick = onBack, modifier = Modifier.width(160.dp), ) + shuffle?.message?.let { message -> + androidx.tv.material3.Text( + text = message, + color = Color(0xFFFFB4AB), + style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, + ) + } // Interactive toggle (was a dead focusable): OK flips // auto-play; focus inverts the pill so the D-pad stop is // visible. var autoPlayToggleFocused by remember { mutableStateOf(false) } - androidx.tv.material3.Text( - text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", - color = if (autoPlayToggleFocused) Color.Black else Color.White.copy(alpha = 0.54f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - modifier = Modifier - .onFocusChanged { autoPlayToggleFocused = it.isFocused } - .clip(RoundedCornerShape(percent = 50)) - .background(if (autoPlayToggleFocused) Color.White else Color.Transparent) - .clickable { onToggleAutoPlay() } - .padding(horizontal = 12.dp, vertical = 4.dp), - ) + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.tv.material3.Text( + text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", + color = if (autoPlayToggleFocused) Color.Black else Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + modifier = Modifier + .onFocusChanged { autoPlayToggleFocused = it.isFocused } + .clip(RoundedCornerShape(percent = 50)) + .background(if (autoPlayToggleFocused) Color.White else Color.Transparent) + .clickable { onToggleAutoPlay() } + .padding(horizontal = 12.dp, vertical = 4.dp), + ) + if (shuffle != null) { + var stopFocused by remember { mutableStateOf(false) } + androidx.tv.material3.Text( + text = "·", + color = Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + ) + androidx.tv.material3.Text( + text = "Stop shuffling", + color = if (stopFocused) Color.Black else Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + modifier = Modifier + .onFocusChanged { stopFocused = it.isFocused } + .clip(RoundedCornerShape(percent = 50)) + .background(if (stopFocused) Color.White else Color.Transparent) + .clickable { onStopShuffling() } + .padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } } else { // Finished / no-next-episode state. androidx.tv.material3.Text( @@ -3201,7 +3280,7 @@ private fun TvPlayerNextUpOverlay( style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, ) androidx.tv.material3.Text( - text = "No next episode is available.", + text = if (shuffle != null) "Nothing else in this shuffle can play." else "No next episode is available.", color = Color.White.copy(alpha = 0.62f), style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, ) @@ -3467,8 +3546,9 @@ private fun TvPlayerViewModel.UiState.toSiloCastPlaybackState( supportsSubtitlePosition = true, volume = volumeState.volume, isMuted = volumeState.isMuted, - hasNextEpisode = nextEpisode != null, - nextEpisodeTitle = nextEpisode?.title, + // A shuffle replaces the series order: the remote offers no sequential next. + hasNextEpisode = nextEpisode != null && shuffle == null, + nextEpisodeTitle = nextEpisode?.title?.takeIf { shuffle == null }, error = error, ) } @@ -3559,6 +3639,7 @@ private fun TvPlayerOverlays( nextUpVideoEnded: Boolean, nextUpCountdownSeconds: Int?, nextUpCountdownTotalSeconds: Int, + shuffle: TvShuffleUiState?, autoPlayNextEnabled: Boolean, introSkipState: IntroAutoSkipState, /** Bumps when the pill's timer (re)starts, so its fill re-anchors. */ @@ -3575,6 +3656,8 @@ private fun TvPlayerOverlays( showSpinner: Boolean, onPlayNextNow: () -> Unit, onKeepWatching: () -> Unit, + onPickAnother: () -> Unit, + onStopShuffling: () -> Unit, onToggleAutoPlayNext: () -> Unit, onExitPlayback: () -> Unit, onNextUpVideoBoundsChanged: (Rect) -> Unit, @@ -3766,6 +3849,9 @@ private fun TvPlayerOverlays( onToggleAutoPlay = onToggleAutoPlayNext, onBack = onExitPlayback, onVideoBoundsChanged = onNextUpVideoBoundsChanged, + shuffle = shuffle, + onPickAnother = onPickAnother, + onStopShuffling = onStopShuffling, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 8bb09eebc7..11aeca9b74 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -603,6 +603,21 @@ data class NextEpisodeState( val overview: String? = null, val seriesTitle: String? = null, val runtimeMinutes: Int = 0, + /** False for a shuffled movie, which has no season or episode line. */ + val isEpisode: Boolean = true, +) + +/** + * Set while a shuffle plays: the Up Next overlay announces the server's random + * pick from the scope [scopeLabel] names, and no sequential next episode is + * offered. + */ +data class TvShuffleUiState( + /** Null until the shuffle has been read. */ + val scopeLabel: String? = null, + val pickingAnother: Boolean = false, + /** A transient failure to continue or pick another. */ + val message: String? = null, ) data class TvPlayerLaunchArgs( @@ -634,6 +649,8 @@ data class TvPlayerLaunchArgs( */ val autoAdvanceCount: Int = 0, val episodeSelectionHandoff: EpisodeSelectionHandoff? = null, + /** The running shuffle this item is a pick of; picks play from the beginning. */ + val shuffleId: String? = null, ) /** @@ -710,6 +727,9 @@ class TvPlayerViewModel( // Pre-play reachability gate (issue #33): drives Retry's fresh probe. private val serverReachabilityMonitor: ServerReachabilityMonitor, private val launchArgs: TvPlayerLaunchArgs, + // Shuffle: the server picks what plays next. Optional so tests that + // construct the VM directly stay source-compatible. + private val shuffleFeatureStore: org.siloserver.silo.model.feature.ShuffleFeatureStore? = null, ) : ViewModel() { companion object { @@ -735,6 +755,8 @@ class TvPlayerViewModel( // How long a coordinated source fallback may take before the original // refusal is shown after all. private const val ROOM_FALLBACK_WAIT_MS = 15_000L + const val SHUFFLE_CONTINUE_FAILED = "Couldn't continue the shuffle." + const val SHUFFLE_PICK_FAILED = "Couldn't pick another." } // Up-Next auto-play countdown ticker. Cancelled on dismiss / Play Now / @@ -1116,6 +1138,7 @@ class TvPlayerViewModel( val nextUpVideoEnded: Boolean = false, val nextUpCountdownSeconds: Int? = null, val nextUpCountdownTotalSeconds: Int = NEXT_UP_COUNTDOWN_SECONDS, + val shuffle: TvShuffleUiState? = null, // Live player statistics — reduced from [PlaybackAnalyticsListener.Event]s // by [reducePlayerStats]. Always non-null so the HUD Stats pane has a // snapshot to read; populates field-by-field as events arrive. @@ -1125,7 +1148,12 @@ class TvPlayerViewModel( val videoFillMode: VideoFillMode = VideoFillMode.Fit, ) - private val _uiState = MutableStateFlow(UiState(contentId = contentId)) + private val _uiState = MutableStateFlow( + UiState( + contentId = contentId, + shuffle = TvShuffleUiState().takeIf { !launchArgs.shuffleId.isNullOrBlank() && shuffleFeatureStore != null }, + ), + ) val uiState: StateFlow = _uiState.asStateFlow() val presentationState: StateFlow = uiState .map(UiState::withoutPlaybackClock) @@ -1335,6 +1363,16 @@ class TvPlayerViewModel( // so the Up-Next overlay couldn't arm its countdown. Carries the "video has // ended" flag forward so the countdown re-arms once nextEpisode resolves. private var pendingApproachingEndVideoEnded: Boolean? = null + // The running shuffle this player plays picks from; null outside a shuffle. + private var shufflePlayback: org.siloserver.silo.playback.ShufflePlayback? = + launchArgs.shuffleId?.takeIf { it.isNotBlank() }?.let { id -> shuffleFeatureStore?.playback(id) } + // The later part of a multi-part pick that plays before the shuffle moves on. + private var shuffleNextPartFileId: Int? = null + // Once per item: the server re-checks the next pick when the overlay opens. + private var shuffleRefreshedForPostRoll = false + private var shuffleAdvanceJob: Job? = null + private var shufflePickJob: Job? = null + private var shuffleMessageJob: Job? = null /** * Transient player notice (server reconnecting, suspend warnings, etc.) emitted by * [PlaybackSessionLifecycle]. `null` means show nothing. @@ -4297,6 +4335,10 @@ class TvPlayerViewModel( private fun resolveNextEpisode() { // The room owns what plays next; nothing here may offer it. if (roomId != null) return + if (shufflePlayback != null) { + resolveShuffleNext() + return + } val state = _uiState.value val seriesId = state.seriesId ?: return val curSeason = state.seasonNumber ?: return @@ -4370,6 +4412,17 @@ class TvPlayerViewModel( // (it would silently leave/desync the room). Mirrors the remote-control // transport gate. if (roomId != null) return + if (shufflePlayback != null) { + // A multi-part pick plays its later parts before the shuffle moves on. + shuffleNextPartFileId?.let { fileId -> + if (videoEnded) playNextShufflePart(fileId) + return + } + if (!shuffleRefreshedForPostRoll) { + shuffleRefreshedForPostRoll = true + refreshShuffleNext() + } + } // Playback end reopens a dismissed card without restarting its timer. // A card still showing keeps its existing countdown. if (autoAdvanceHandled) { @@ -4422,7 +4475,8 @@ class TvPlayerViewModel( // The room owns what plays next; Play Now could never advance. if (roomId != null) return false val state = _uiState.value - return state.nextEpisode != null && !state.showNextUp + // A shuffle replaces the series order: no sequential next episode. + return state.nextEpisode != null && !state.showNextUp && state.shuffle == null } /** @@ -4549,12 +4603,31 @@ class TvPlayerViewModel( // collector enforced this guard; keep it here now that TV advances // in place so a remote Next command cannot desynchronise the room. if (!shouldNavigateToLocalNext(inWatchTogetherRoom = roomId != null)) return + if (shufflePlayback != null) { + advanceShuffle(nextAutoAdvanceCount) + return + } + val next = _uiState.value.nextEpisode ?: return + playInPlace(next.contentId, targetFileId = null, nextAutoAdvanceCount = nextAutoAdvanceCount) + } + + /** + * Replaces the mounted item in place with [targetContentId] (and + * [targetFileId], when a specific file must play) from the beginning. + * [showCard] keeps the Up Next overlay up through the handoff; a later + * part of the same item plays without it. + */ + private fun playInPlace( + targetContentId: String, + targetFileId: Int?, + nextAutoAdvanceCount: Int, + showCard: Boolean = true, + ) { val state = _uiState.value - val next = state.nextEpisode ?: return - if (!nextUpTransitionGate.begin(next.contentId)) return + if (!nextUpTransitionGate.begin(targetContentId)) return _uiState.update { it.copy( - showNextUp = true, + showNextUp = it.showNextUp || showCard, isNextUpTransitioning = true, nextUpCountdownSeconds = null, ) @@ -4594,8 +4667,8 @@ class TvPlayerViewModel( if (!nextUpTransitionGate.isActive) return@launch lastAdoptedSessionId = null - contentId = next.contentId - preferredFileId = null + contentId = targetContentId + preferredFileId = targetFileId preferredQuality = null qualityOverride = null initialAudioTrackIndex = null @@ -4606,6 +4679,8 @@ class TvPlayerViewModel( autoAdvanceCount = nextAutoAdvanceCount autoAdvanceHandled = false pendingApproachingEndVideoEnded = null + shuffleNextPartFileId = null + shuffleRefreshedForPostRoll = false episodeSelectionHandoffSlot.replace(handoff) loadContent( startPositionOverride = 0.0, @@ -4647,6 +4722,163 @@ class TvPlayerViewModel( return stopped } + // ---- Shuffle ---- + + private fun resolveShuffleNext() { + val shuffle = shufflePlayback ?: return + val state = _uiState.value + val forContentId = state.contentId + // The overlay waits for the last part of a multi-part pick. + shuffleNextPartFileId = org.siloserver.silo.playback.nextPlaybackPartFileId( + versions = state.fileVersions, + currentFileId = state.selectedFileId ?: state.mediaFileId, + ) + if (shuffleNextPartFileId != null) return + resolveNextEpisodeJob?.cancel() + // The advance that started this item already returned its next pick. + if (shuffle.latest?.current?.contentId == forContentId) { + publishShuffleNext(forContentId) + return + } + resolveNextEpisodeJob = viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Re-reads the shuffle as the overlay opens: the server replaces a next pick that can no longer play. */ + private fun refreshShuffleNext() { + val shuffle = shufflePlayback ?: return + val forContentId = _uiState.value.contentId + viewModelScope.launch { + shuffle.refresh() + publishShuffleNext(forContentId) + } + } + + /** Shows the shuffle's pick after [forContentId], or the finished state when there is none. */ + private fun publishShuffleNext(forContentId: String) { + val shuffle = shufflePlayback ?: return + if (_uiState.value.contentId != forContentId || nextUpTransitionGate.isActive) return + val next = shuffle.nextPickAfter(forContentId)?.let { pick -> + NextEpisodeState( + contentId = pick.contentId, + seasonNumber = pick.seasonNumber, + episodeNumber = pick.episodeNumber, + title = pick.title, + stillUrl = pick.stillUrl, + overview = pick.overview, + seriesTitle = pick.seriesTitle, + runtimeMinutes = pick.runtimeMinutes, + isEpisode = pick.isEpisode, + ) + } + val previous = _uiState.value.nextEpisode + _uiState.update { + it.copy( + nextEpisode = next, + shuffle = it.shuffle?.copy(scopeLabel = shuffle.scopeLabel ?: it.shuffle.scopeLabel), + ) + } + val countingDownAtEnd = nextUpCountdownJob?.isActive == true && _uiState.value.nextUpVideoEnded + when { + next == null -> { + nextUpCountdownJob?.cancel() + nextUpCountdownJob = null + _uiState.update { it.copy(nextUpCountdownSeconds = null) } + } + // A different pick, such as Pick Another, gets the full countdown + // rather than whatever was left of the previous one. + previous != null && previous.contentId != next.contentId && countingDownAtEnd -> { + _uiState.update { + it.copy( + nextUpCountdownSeconds = NEXT_UP_COUNTDOWN_SECONDS, + nextUpCountdownTotalSeconds = NEXT_UP_COUNTDOWN_SECONDS, + ) + } + startNextUpCountdown() + } + previous == null && !autoAdvanceHandled -> + pendingApproachingEndVideoEnded?.let { videoEnded -> commitApproachingEnd(next, videoEnded) } + } + } + + /** Plays the next part of a multi-part pick from its start, without the overlay. */ + private fun playNextShufflePart(fileId: Int) { + shuffleNextPartFileId = null + playInPlace(contentId, targetFileId = fileId, nextAutoAdvanceCount = autoAdvanceCount, showCard = false) + } + + /** + * Moves the shuffle past the item that played and plays the returned + * pick from the beginning. The server moves on only while that item is + * still current, so a repeated press or a retry plays the same pick. + */ + private fun advanceShuffle(nextAutoAdvanceCount: Int) { + val shuffle = shufflePlayback ?: return + if (_uiState.value.nextEpisode == null) return + if (shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val fromContentId = _uiState.value.contentId + _uiState.update { it.copy(nextUpCountdownSeconds = null) } + val pendingPick = shufflePickJob + shuffleAdvanceJob = viewModelScope.launch { + // A Pick Another still in flight decides what plays; wait for it. + pendingPick?.join() + if (shufflePlayback !== shuffle) return@launch + val result = shuffle.advance(fromContentId) + if (_uiState.value.contentId != fromContentId || shufflePlayback !== shuffle) return@launch + val target = (result as? ApiResult.Success)?.data?.current?.contentId + if (target == null) { + if (shuffle.exhausted) publishShuffleNext(fromContentId) else showShuffleMessage(SHUFFLE_CONTINUE_FAILED) + return@launch + } + // Like the first pick, every shuffled item plays from the start. + playInPlace(target, targetFileId = null, nextAutoAdvanceCount = nextAutoAdvanceCount) + } + } + + /** Up Next "Pick Another": the server replaces the announced pick. */ + fun pickAnotherShuffle() { + val shuffle = shufflePlayback ?: return + if (shufflePickJob?.isActive == true || shuffleAdvanceJob?.isActive == true || nextUpTransitionGate.isActive) return + val forContentId = _uiState.value.contentId + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = true)) } + shufflePickJob = viewModelScope.launch { + val result = shuffle.pickAnother() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(pickingAnother = false)) } + if (_uiState.value.contentId != forContentId || shufflePlayback !== shuffle) return@launch + if (result !is ApiResult.Success && !shuffle.exhausted) { + showShuffleMessage(SHUFFLE_PICK_FAILED) + return@launch + } + publishShuffleNext(forContentId) + } + } + + /** + * Up Next "Stop shuffling": ends the shuffle on the server. The screen + * leaves the player right after, back to where the shuffle started. + */ + fun stopShuffling() { + val shuffle = shufflePlayback ?: return + shufflePlayback = null + shuffleAdvanceJob?.cancel() + shufflePickJob?.cancel() + nextUpCountdownJob?.cancel() + nextUpCountdownJob = null + shuffle.stopInBackground() + _uiState.update { it.copy(shuffle = null, nextEpisode = null, nextUpCountdownSeconds = null) } + } + + private fun showShuffleMessage(message: String) { + shuffleMessageJob?.cancel() + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = message)) } + shuffleMessageJob = viewModelScope.launch { + delay(4_000) + _uiState.update { it.copy(shuffle = it.shuffle?.copy(message = null)) } + } + } + /** Up-Next "Keep Watching" — dismiss the overlay and stay on the current episode. */ fun dismissNextUp() { if (nextUpTransitionGate.isActive) return diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt index 3a4e12aa41..1b80ab0e01 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -33,6 +33,7 @@ import org.siloserver.silo.model.playback.applyResumeRewind import org.siloserver.silo.model.playback.buildPlaybackSubtitleChoices import org.siloserver.silo.model.playback.enrichAuthoritativePlaybackSubtitleChoices import org.siloserver.silo.model.playback.isExplicitStartOver +import org.siloserver.silo.playback.firstPlaybackPart import org.siloserver.silo.model.playback.resolvePlaybackStartRequestPosition import org.siloserver.silo.model.playback.resolvePlaybackStartPosition import org.siloserver.silo.network.ApiResult @@ -128,6 +129,7 @@ class TvVideoPlaybackStarter( targetVersions = watchDetail.versions, targetLastFileId = watchDetail.userData?.lastFileId, preferredQuality = preferredQuality, + fromBeginning = isExplicitStartOver(request.resumePositionOverride), ) val version = watchDetail.versions.first { it.fileId == resolvedEpisodeSelection.fileId } // The server rejects -1, while the Ready result retains it for the @@ -519,6 +521,8 @@ fun resolveTvPlaybackStartSelection( targetVersions: List, targetLastFileId: Int?, preferredQuality: String?, + /** A start from the beginning opens a multi-part item at its first part. */ + fromBeginning: Boolean = false, ): ResolvedEpisodeSelection { require(targetVersions.isNotEmpty()) { "targetVersions must not be empty" } @@ -530,7 +534,9 @@ fun resolveTvPlaybackStartSelection( ?.let { preferredId -> targetVersions.firstOrNull { it.fileId == preferredId } } ?: semanticFileId ?.let { handoffFileId -> targetVersions.firstOrNull { it.fileId == handoffFileId } } - ?: selectPlaybackVersion(targetVersions, targetLastFileId, preferredQuality) + ?: selectPlaybackVersion(targetVersions, targetLastFileId, preferredQuality).let { selected -> + if (fromBeginning) firstPlaybackPart(targetVersions, selected) else selected + } val resolvedSubtitle = resolveEpisodeSubtitleIntent( intent = episodeSelectionHandoff?.subtitle ?: EpisodeSubtitleIntent.auto(), targetSubtitles = buildPlaybackSubtitleChoices( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt index 7ecfd93327..5c63167077 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt @@ -205,6 +205,8 @@ fun TvMainShell( onPlayItem: (contentId: String, type: String?, resumePositionSeconds: Double?) -> Unit, /** Open the Watch Party hub. */ onOpenWatchParty: () -> Unit, + // Plays the first pick of a shuffle started from a library screen. + onPlayShuffle: (shuffle: org.siloserver.silo.model.shuffle.Shuffle, libraryId: Int?) -> Unit = { _, _ -> }, onOpenPersonDetail: (personId: Long) -> Unit, ) { val nestedNav = rememberNavController() @@ -217,6 +219,7 @@ fun TvMainShell( val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() val metadataAiFeatureStore: org.siloserver.silo.model.feature.MetadataAiFeatureStore = koinInject() + val shuffleFeatureStore: org.siloserver.silo.model.feature.ShuffleFeatureStore = koinInject() val serverRegistry: ServerRegistry = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() @@ -342,6 +345,8 @@ fun TvMainShell( requestsFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() + shuffleFeatureStore.reset() + shuffleFeatureStore.refresh() } val focusManager = LocalFocusManager.current @@ -1237,6 +1242,7 @@ fun TvMainShell( onLibraryCollectionClick = openLibraryCollectionDetail, onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Libraries.route) { @@ -1245,6 +1251,7 @@ fun TvMainShell( onLibraryCollectionClick = openLibraryCollectionDetail, onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, + onPlayShuffle = onPlayShuffle, ) } // Content-type tabs (Skyline §3.1). Each renders the library @@ -1266,6 +1273,7 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Series.route) { @@ -1282,6 +1290,7 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Music.route) { @@ -1298,6 +1307,7 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.Audiobooks.route) { @@ -1314,6 +1324,7 @@ fun TvMainShell( onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, + onPlayShuffle = onPlayShuffle, ) } shellComposable(TvMainRoute.ForYou.route) { @@ -1710,6 +1721,7 @@ private fun TvLibraryTypeContent( onUserCollectionClick: (collectionId: String, title: String) -> Unit, onInitialContentFocus: () -> Unit, onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + onPlayShuffle: (shuffle: org.siloserver.silo.model.shuffle.Shuffle, libraryId: Int?) -> Unit = { _, _ -> }, ) { if (library == null) { // Only assert "no libraries" once loading has settled AND this type @@ -1753,6 +1765,7 @@ private fun TvLibraryTypeContent( initialSection = selectedPill.toLibraryTab(), sectionRequestNonce = sectionRequestNonce, onContentUpFallbackChanged = onContentUpFallbackChanged, + onShuffleStarted = { shuffle -> onPlayShuffle(shuffle, library.id) }, ) } } diff --git a/docs/README.md b/docs/README.md index 73bfccf901..f17e6a49de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,7 +36,7 @@ Start with the root [README](../README.md) for architecture/build instructions a [collections](collections-api-v2.md), [ebook reader](ebook-reader-api-v2.md), [history](history-api-v2.md), [membership](membership-api-v2.md), [notifications](notifications-api-v2.md), [requests](requests-api-v2.md), - [subtitle AI](subtitle-ai-api-v2.md), + [shuffle](shuffle-api-v2.md), [subtitle AI](subtitle-ai-api-v2.md), [push registration](api-v2/android-push-registration.md) ## Folders diff --git a/docs/api-v2/android-migration-status.md b/docs/api-v2/android-migration-status.md index e3c12b3ed4..5b18b1ad58 100644 --- a/docs/api-v2/android-migration-status.md +++ b/docs/api-v2/android-migration-status.md @@ -23,6 +23,7 @@ and the server ledger `contracts/api/v2/migration.json` (silo-server `origin/api | Calendar | `/api/v2/calendar` | migrated | | Requests | `/api/v2/requests`, `/api/v2/requests/{id}`, `/api/v2/requests/{id}/cancel`, `/api/v2/requests/{mine,status,search,discover}`, `/api/v2/requests/discover/{section}`, `/api/v2/requests/detail/{media_type}/{tmdb_id}` | migrated (server-gated by `requests_enabled`) | | Playback | `/api/v2/playback/capabilities`, `/api/v2/playback/start`, `/api/v2/playback/route-events`, `/api/v2/playback/sessions/{id}/control/ws-ticket`, `/api/v2/playback/sessions/{id}/control/ws` | migrated (start was redesigned server-side; the client uses the v2 `PlaybackDecision` shape) | +| Shuffle | `/api/v2/shuffles`, `/api/v2/shuffles/capabilities`, `/api/v2/shuffles/{id}`, `/api/v2/shuffles/{id}/{advance,skip}` | v2 only (server-gated by the capability; see [shuffle](../shuffle-api-v2.md)) | | Subtitles | `/api/v2/subtitles/{media_file_id}`, `/api/v2/subtitles/{search,download}`, `/api/v2/subtitles/ai/{status,quota,translate,jobs}`, `/api/v2/subtitles/ai/jobs/{id}`, `/api/v2/subtitles/ai/jobs/{id}/cancel` | migrated | | Settings | `/api/v2/settings/contract/capabilities`, `/api/v2/settings/values/effective`, `/api/v2/settings/values/{key}`, `/api/v2/settings/overlay-config` | migrated | | Downloads | `/api/v2/downloads`, `/api/v2/downloads/{id}`, `/api/v2/capabilities/downloads` | migrated | diff --git a/docs/shuffle-api-v2.md b/docs/shuffle-api-v2.md new file mode 100644 index 0000000000..64c93a3af3 --- /dev/null +++ b/docs/shuffle-api-v2.md @@ -0,0 +1,55 @@ +# Shuffle + +Shuffle plays random movies and episodes from a library, a series, a season, or +a collection until the viewer stops. The server picks every item and applies +the profile's access and parental limits; the client never chooses or orders +picks. The contract is the server's `docs/playback-api.md` ("Shuffle"). + +## Client pieces + +- `ShufflesV2Api` (shared) wraps the six `/api/v2/shuffles` operations. Every + call is pinned to the profile that made it; mutations are single-attempt. +- `ShuffleFeatureStore` (shared) holds the capability. Entry points appear only + for scope kinds listed in `scope_kinds` while `state` is `available`. A `404` + from an older server hides them. Both shells reset and refresh it on every + server or profile change. +- `ShufflePlayback` (shared) is one running shuffle inside a player. It keeps + the last shuffle the server returned, and treats `409` from a read as "nothing + in the scope can play any more". Other failures keep the last pick. + +## Entry points + +| Scope | Phone | TV | +| --- | --- | --- | +| Library (movie, TV, mixed) | Shuffle chip before the library tabs | Shuffle pill in the Library tab's Sort/Filter row | +| Series | "Shuffle Series", first in the series overflow menu | "Shuffle Series", first in More Actions | +| Season | "Shuffle Season N" after it, shown with two or more playable episodes | Same, in season mode only | +| Server collection | Top-bar Shuffle button | Shuffle pill in the collection's control row | +| User collection | Top-bar Shuffle button | Shuffle pill beside the title | + +Neither app has a separate season screen: a season is the series page with that +season selected. Android TV has no screen for mixed libraries. + +## Player rules + +- The player route carries `shuffleId`. Every pick starts at `0.0` (the start + over position), and a multi-part item opens at its first part. +- While a shuffle plays, the player resolves no sequential next episode. The + phone tabletop Next button, the TV Up Next transport button, and the + SiloCast `hasNextEpisode` flag are off. +- The up-next card reuses the next-episode flow and countdown. It reads + "Shuffling " (a season reads " · "), shows a movie by + its title without an S·E line, and adds Pick Another and Stop shuffling. +- The card announces the `next` the server returned. When the card opens, the + player reads the shuffle again so the server can replace a pick that can no + longer play. When `next` equals the item playing, or the read answers `409`, + the card shows Finished. +- Play Now and the countdown call `advance` with the item that played, then + play the returned `current`. Pick Another calls `skip`; a new pick restarts + an at-end countdown. Stop shuffling deletes the shuffle and leaves the + player. +- A multi-part pick plays each later part in place before the card appears. +- Leaving the player with Back keeps the shuffle on the server. Playing an On + Deck item from the phone card leaves the shuffle. +- Starting a shuffle opens the local player; it is not launched on a + connected SiloCast target. diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt index 4dbcef369c..fe33468db6 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt @@ -40,6 +40,7 @@ val networkModule = module { single { SimilarCardsV2Api(get(), get(), get()) } single { TasteProfileV2Api(get(), get(), get()) } single { DiscoverV2Api(get(), get(), get()) } + single { ShufflesV2Api(get(), get(), get()) } single { RecommendationApi(get(), get(), get(), get()) } single { DefaultRequestsApi(get(), get(), get()) } single { DefaultMetadataAiApi(get(), get(), get()) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index 2358c59305..05642ada86 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -110,6 +110,7 @@ val repositoryModule = module { single { ActiveProfileStore(get()) } single { org.siloserver.silo.repository.MetadataAiRepository(get()) } single { org.siloserver.silo.model.feature.MetadataAiFeatureStore(get()) } + single { org.siloserver.silo.model.feature.ShuffleFeatureStore(get()) } single { org.siloserver.silo.repository.HomeRealtimeCoordinator(get(), get()) } single { SettingsRepository(get()) } // Profile-scoped canonical settings, shared by the phone and TV screens so diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStore.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStore.kt new file mode 100644 index 0000000000..a1d37fe793 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStore.kt @@ -0,0 +1,87 @@ +package org.siloserver.silo.model.feature + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.siloserver.silo.model.shuffle.Shuffle +import org.siloserver.silo.model.shuffle.ShuffleCapability +import org.siloserver.silo.model.shuffle.ShuffleScopeKind +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.apiv2.ShufflesV2Api + +/** + * Server-gated Shuffle capability, and the one place entry points start a + * shuffle from. + * + * Entry points start hidden; a successful `/api/v2/shuffles/capabilities` + * probe controls visibility per scope kind, transient failures keep the + * previous value, and reset hides the surface before a server or profile + * switch can reuse stale state. A server without shuffles answers `404`, + * which hides it too. + */ +class ShuffleFeatureStore( + private val api: ShufflesV2Api, +) { + private val _capability = MutableStateFlow(ShuffleCapability()) + val capability: StateFlow = _capability.asStateFlow() + + // Written on the UI dispatcher (reset) and read after IO resumption + // (refresh); Volatile gives the stale-response guard a happens-before. + @kotlin.concurrent.Volatile + private var generation: Int = 0 + + suspend fun refresh() { + val refreshGeneration = generation + val next = when (val result = api.capability()) { + is ApiResult.Success -> result.data + // The server answered: an older server has no shuffles. + is ApiResult.Error -> if (result.code == 404) ShuffleCapability() else return + is ApiResult.NetworkError -> return + } + if (refreshGeneration == generation) _capability.value = next + } + + fun reset() { + generation += 1 + _capability.value = ShuffleCapability() + started = null + } + + // The shuffle the last start returned, until its player takes it. The + // player then knows the first pick's `next` without another read. + @kotlin.concurrent.Volatile + private var started: Shuffle? = null + + /** Hands the player the shuffle [start] returned for [shuffleId], once. */ + fun takeStarted(shuffleId: String): Shuffle? = + started?.takeIf { it.id == shuffleId }?.also { started = null } + + /** The playback state of shuffle [shuffleId], seeded with what [start] returned. */ + fun playback(shuffleId: String): org.siloserver.silo.playback.ShufflePlayback = + org.siloserver.silo.playback.ShufflePlayback(api, shuffleId, takeStarted(shuffleId)) + + /** + * Starts a shuffle over a scope. On success play `current` from the + * beginning; on failure show [ShuffleStart.Failed.message]. + */ + suspend fun start(kind: ShuffleScopeKind, id: String): ShuffleStart = + when (val result = api.create(kind, id)) { + is ApiResult.Success -> { + started = result.data + ShuffleStart.Started(result.data) + } + is ApiResult.Error -> + ShuffleStart.Failed(if (result.code == 409) NOTHING_PLAYABLE else START_FAILED) + is ApiResult.NetworkError -> ShuffleStart.Failed(START_FAILED) + } + + companion object { + const val NOTHING_PLAYABLE = "Nothing here can be played." + const val START_FAILED = "Couldn't start shuffle." + } +} + +sealed interface ShuffleStart { + data class Started(val shuffle: Shuffle) : ShuffleStart + data class Failed(val message: String) : ShuffleStart +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/shuffle/ShuffleModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/shuffle/ShuffleModels.kt new file mode 100644 index 0000000000..d86f7ec901 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/shuffle/ShuffleModels.kt @@ -0,0 +1,69 @@ +package org.siloserver.silo.model.shuffle + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.siloserver.silo.model.section.SectionItem + +/** What a shuffle draws its random picks from. Wire values of `ShuffleScopeRequest.kind`. */ +enum class ShuffleScopeKind(val wire: String) { + LIBRARY("library"), + SERIES("series"), + SEASON("season"), + LIBRARY_COLLECTION("library_collection"), + USER_COLLECTION("user_collection"), +} + +/** + * `GET /api/v2/shuffles/capabilities`. Shuffle is offered only when [state] + * is `available` and the profile is [allowed]; [scopeKinds] lists the scopes + * `createShuffle` accepts. + */ +@Serializable +data class ShuffleCapability( + val state: String = "", + val allowed: Boolean = false, + @SerialName("scope_kinds") val scopeKinds: List = emptyList(), + val revision: String = "", +) { + val isAvailable: Boolean get() = allowed && state == STATE_AVAILABLE + + fun supports(kind: ShuffleScopeKind): Boolean = isAvailable && kind.wire in scopeKinds + + companion object { + const val STATE_AVAILABLE = "available" + } +} + +/** The scope a running shuffle plays from, named as it was when the shuffle started. */ +@Serializable +data class ShuffleScope( + val kind: String, + val id: String, + val title: String, + /** A season scope's series title; absent for other scopes. */ + @SerialName("parent_title") val parentTitle: String? = null, +) + +/** + * A running shuffle. [current] plays now and [next] after it; both are catalog + * item cards (movies or episodes). [next] equals [current] only when one item + * in the scope can play. + */ +@Serializable +data class Shuffle( + val id: String, + val scope: ShuffleScope, + val current: SectionItem, + val next: SectionItem, + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", +) + +private val SHUFFLE_LIBRARY_TYPES = setOf("movie", "movies", "series", "show", "shows", "tv", "mixed") + +/** Whether a library of [type] holds movies or episodes: movie, TV, and mixed libraries shuffle. */ +fun isShuffleLibraryType(type: String?): Boolean = type?.trim()?.lowercase() in SHUFFLE_LIBRARY_TYPES + +/** A season shuffles only when it has two or more playable episodes. */ +fun canShuffleSeason(episodes: List): Boolean = + episodes.count { it.files.isNotEmpty() } > 1 diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Api.kt new file mode 100644 index 0000000000..d79bfe730d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Api.kt @@ -0,0 +1,117 @@ +package org.siloserver.silo.network.apiv2 + +import io.ktor.client.HttpClient +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.siloserver.silo.model.shuffle.Shuffle +import org.siloserver.silo.model.shuffle.ShuffleCapability +import org.siloserver.silo.model.shuffle.ShuffleScopeKind +import org.siloserver.silo.network.* + +@Serializable +private data class ShuffleScopeRequestV2(val kind: String, val id: String) +@Serializable +private data class CreateShuffleRequestV2(val scope: ShuffleScopeRequestV2) +@Serializable +private data class AdvanceShuffleRequestV2(@SerialName("from_content_id") val fromContentId: String) +@Serializable +private data class SkipShuffleItemRequestV2(@SerialName("next_content_id") val nextContentId: String) + +/** + * `/api/v2/shuffles`: the server picks every item of a shuffle, so the client + * only starts one, moves it on, replaces its next pick, and stops it. + * Advance and skip name the item that holds the position, so a retry after a + * lost response returns the same shuffle unchanged. `404` means the profile + * cannot see the shuffle or its scope; `409` means nothing in it can play. + */ +class ShufflesV2Api( + private val client: HttpClient, + private val tokens: TokenManager, + private val gate: ApiV2Gate, +) { + suspend fun capability(): ApiResult = + exchange(HttpMethod.Get, "/api/v2/shuffles/capabilities", HttpStatusCode.OK) + + suspend fun create(kind: ShuffleScopeKind, id: String): ApiResult { + if (id.isBlank()) return ApiResult.Error(422, "validation_failed", "Invalid shuffle scope.") + return shuffleExchange(HttpMethod.Post, "/api/v2/shuffles", HttpStatusCode.Created) { + contentType(ContentType.Application.Json) + setBody(CreateShuffleRequestV2(ShuffleScopeRequestV2(kind.wire, id))) + } + } + + suspend fun get(shuffleId: String): ApiResult { + if (shuffleId.isBlank()) return invalidShuffle() + return shuffleExchange(HttpMethod.Get, path(shuffleId), HttpStatusCode.OK) + } + + /** Moves the shuffle past [fromContentId], the item that finished: `next` becomes `current`. */ + suspend fun advance(shuffleId: String, fromContentId: String): ApiResult { + if (shuffleId.isBlank() || fromContentId.isBlank()) return invalidShuffle() + return shuffleExchange(HttpMethod.Post, "${path(shuffleId)}/advance", HttpStatusCode.OK) { + contentType(ContentType.Application.Json) + setBody(AdvanceShuffleRequestV2(fromContentId)) + } + } + + /** Pick Another: replaces [nextContentId], the announced next item, with another pick. */ + suspend fun skip(shuffleId: String, nextContentId: String): ApiResult { + if (shuffleId.isBlank() || nextContentId.isBlank()) return invalidShuffle() + return shuffleExchange(HttpMethod.Post, "${path(shuffleId)}/skip", HttpStatusCode.OK) { + contentType(ContentType.Application.Json) + setBody(SkipShuffleItemRequestV2(nextContentId)) + } + } + + suspend fun delete(shuffleId: String): ApiResult { + if (shuffleId.isBlank()) return invalidShuffle() + return exchange(HttpMethod.Delete, path(shuffleId), HttpStatusCode.NoContent) + } + + private fun path(shuffleId: String) = "/api/v2/shuffles/${shuffleId.encodeURLPathPart()}" + + private fun invalidShuffle(): ApiResult.Error = ApiResult.Error(422, "validation_failed", "Invalid shuffle request.") + + private suspend inline fun shuffleExchange( + method: HttpMethod, + path: String, + expected: HttpStatusCode, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): ApiResult = exchange(method, path, expected, { + parameter("image_size", "large") + configure() + }) { shuffle -> + require(shuffle.id.isNotBlank() && shuffle.scope.id.isNotBlank()) { "The shuffle response has no identity." } + require(shuffle.current.contentId.isNotBlank() && shuffle.next.contentId.isNotBlank()) { + "The shuffle response has no pick." + } + shuffle + } + + private suspend inline fun exchange( + method: HttpMethod, + path: String, + expected: HttpStatusCode, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): ApiResult = exchange(method, path, expected, configure) { it } + + private suspend inline fun exchange( + method: HttpMethod, + path: String, + expected: HttpStatusCode, + noinline configure: HttpRequestBuilder.() -> Unit, + crossinline project: (T) -> R, + ): ApiResult { + val owner = tokens.captureProfileScope() ?: return identityChanged() + return ownedV2Call(gate, tokens, owner, OwnerPolicy.PROFILE, expected, { scope -> + client.request(path) { + this.method = method + authScope(scope!!); requireSiloAuth() + if (method != HttpMethod.Get) singleAttempt() + configure() + } + }) { project(it) } + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackParts.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackParts.kt new file mode 100644 index 0000000000..10d4228368 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackParts.kt @@ -0,0 +1,45 @@ +package org.siloserver.silo.playback + +import org.siloserver.silo.model.catalog.FileVersion + +private val SEQUENTIAL_PART_KINDS = setOf("multipart_movie", "split_episode") + +private fun FileVersion.isSequentialPart(): Boolean = + presentationKind in SEQUENTIAL_PART_KINDS && presentationPartIndex != null + +private fun FileVersion.isSamePresentationAs(other: FileVersion): Boolean = + presentationKind == other.presentationKind && + presentationGroupKey == other.presentationGroupKey && + editionKey == other.editionKey + +/** + * The file that plays after [currentFileId] when it is one part of a + * multi-part movie or split episode: the lowest-numbered later part of the + * same presentation and edition, preferring the current file's resolution. + * Null when [currentFileId] is not a part or is the last one. + */ +fun nextPlaybackPartFileId(versions: List, currentFileId: Int?): Int? { + val current = versions.firstOrNull { it.fileId == currentFileId } ?: return null + if (!current.isSequentialPart()) return null + val currentIndex = current.presentationPartIndex ?: return null + val later = versions.filter { + it.isSequentialPart() && it.isSamePresentationAs(current) && it.presentationPartIndex!! > currentIndex + } + val nextIndex = later.minOfOrNull { it.presentationPartIndex!! } ?: return null + val candidates = later.filter { it.presentationPartIndex == nextIndex } + return (candidates.firstOrNull { it.resolution == current.resolution } ?: candidates.first()).fileId +} + +/** + * The first part of [selected]'s presentation, for a start from the + * beginning: a multi-part item starts at its lowest-numbered part, preferring + * [selected]'s resolution. Anything that is not a later part is returned as is. + */ +fun firstPlaybackPart(versions: List, selected: FileVersion): FileVersion { + if (!selected.isSequentialPart()) return selected + val parts = versions.filter { it.isSequentialPart() && it.isSamePresentationAs(selected) } + val firstIndex = parts.minOfOrNull { it.presentationPartIndex!! } ?: return selected + if (selected.presentationPartIndex == firstIndex) return selected + val candidates = parts.filter { it.presentationPartIndex == firstIndex } + return candidates.firstOrNull { it.resolution == selected.resolution } ?: candidates.first() +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt new file mode 100644 index 0000000000..7b9df42a9c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt @@ -0,0 +1,153 @@ +package org.siloserver.silo.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.shuffle.Shuffle +import org.siloserver.silo.model.shuffle.ShuffleScope +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.apiv2.ShufflesV2Api + +/** A shuffle's next pick in the shape the post-roll renders. */ +data class ShuffleNextPick( + val contentId: String, + /** False for a movie, which has no series, season, or episode line. */ + val isEpisode: Boolean, + val title: String, + val seriesTitle: String?, + val seasonNumber: Int, + val episodeNumber: Int, + val stillUrl: String?, + val stillThumbhash: String?, + val overview: String?, + val runtimeMinutes: Int, +) + +/** Names what a shuffle draws from: "Movies", or "Breaking Bad · Season 2" for a season. */ +fun ShuffleScope.label(): String = + parentTitle?.takeIf { it.isNotBlank() }?.let { "$it · $title" } ?: title + +/** + * The pick that plays after [playingContentId], or null when the shuffle is + * finished: a scope with one playable item announces that item again, and + * playing it would only restart what just played. + */ +fun Shuffle.nextPickAfter(playingContentId: String): ShuffleNextPick? = + next.takeIf { it.contentId != playingContentId }?.toShuffleNextPick() + +internal fun SectionItem.toShuffleNextPick(): ShuffleNextPick { + val episode = type == "episode" + return ShuffleNextPick( + contentId = contentId, + isEpisode = episode, + title = title, + seriesTitle = seriesTitle.takeIf { episode }, + seasonNumber = if (episode) seasonNumber ?: 0 else 0, + episodeNumber = if (episode) episodeNumber ?: 0 else 0, + // An episode card's poster is its 16:9 still; a movie's poster is + // portrait, so the post-roll shows its backdrop instead. + stillUrl = (if (episode) posterUrl else null) ?: backdropUrl, + stillThumbhash = (if (episode) posterThumbhash else null) ?: backdropThumbhash, + overview = overview, + runtimeMinutes = runtime ?: 0, + ) +} + +/** + * The client half of one running shuffle, owned by a player. The server picks + * every item; this keeps the last shuffle it returned so the post-roll can + * announce `next`, and remembers when the server said nothing in the scope can + * play any more. + * + * A failed read keeps the last pick: advancing re-checks it on the server, + * which replaces a pick that can no longer play. + */ +class ShufflePlayback( + private val api: ShufflesV2Api, + val shuffleId: String, + initial: Shuffle? = null, +) { + /** The last shuffle the server returned. */ + var latest: Shuffle? = initial + private set + + /** The server answered `409`: nothing in the scope can play any more. */ + var exhausted: Boolean = false + private set + + /** The scope label once the shuffle has been read, else null. */ + val scopeLabel: String? get() = latest?.scope?.label() + + /** The pick after [playingContentId]; null when finished or not yet read. */ + fun nextPickAfter(playingContentId: String): ShuffleNextPick? = + if (exhausted) null else latest?.nextPickAfter(playingContentId) + + // Counts advance and skip calls, and how many are still waiting for the + // server. A read that overlaps a mutation may answer with the state that + // mutation replaced, so its answer is dropped. + private var mutations = 0 + private var mutationsInFlight = 0 + + /** + * Re-reads the shuffle; the server may replace a `next` that can no longer + * play. The answer is ignored when an advance or skip was in flight when + * the read started or started while it ran. + */ + suspend fun refresh(): ApiResult { + val startedAfter = mutations + val overlapped = mutationsInFlight > 0 + val result = api.get(shuffleId) + return if (!overlapped && mutations == startedAfter) record(result) else result + } + + /** + * Moves the shuffle past [fromContentId], the item that finished. Play the + * returned `current`. A retry for the same item changes nothing. + */ + suspend fun advance(fromContentId: String): ApiResult = + mutate { api.advance(shuffleId, fromContentId) } + + /** Pick Another: replaces the announced next item with another pick. */ + suspend fun pickAnother(): ApiResult { + val next = latest?.next ?: return ApiResult.Error(0, "not_loaded", "The shuffle has not loaded yet.") + return mutate { api.skip(shuffleId, next.contentId) } + } + + /** Stop shuffling. */ + suspend fun stop(): ApiResult = api.delete(shuffleId) + + /** + * Stop shuffling without waiting for the answer. The player leaves right + * away, so the request must outlive it; a failure only leaves an unused + * shuffle the server deletes later. + */ + fun stopInBackground() { + detachedScope.launch { stop() } + } + + private suspend fun mutate(call: suspend () -> ApiResult): ApiResult { + mutations++ + mutationsInFlight++ + try { + return record(call()) + } finally { + mutationsInFlight-- + } + } + + private fun record(result: ApiResult): ApiResult { + when (result) { + is ApiResult.Success -> { + latest = result.data + exhausted = false + } + is ApiResult.Error -> if (result.code == 409) exhausted = true + is ApiResult.NetworkError -> Unit + } + return result + } +} + +private val detachedScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStoreTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStoreTest.kt new file mode 100644 index 0000000000..3d2114d3ae --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/feature/ShuffleFeatureStoreTest.kt @@ -0,0 +1,95 @@ +package org.siloserver.silo.model.feature + +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.respondError +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.io.IOException +import org.siloserver.silo.model.shuffle.ShuffleScopeKind +import org.siloserver.silo.network.* +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.ShufflesV2Api +import org.siloserver.silo.network.apiv2.shuffleJson +import kotlin.test.* + +class ShuffleFeatureStoreTest { + private val owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + + private var answer: () -> Pair = { HttpStatusCode.OK to "{}" } + private var offline = false + private val client = HttpClient(MockEngine { + if (offline) throw IOException("offline") + val (status, body) = answer() + if (status.isSuccess()) { + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + } else { + respondError(status, """{"type":"https://siloserver.org/docs/api/v2/problems/x","title":"T","status":${status.value}}""", + headersOf(HttpHeaders.ContentType, "application/problem+json")) + } + }) { install(ContentNegotiation) { json(SiloJson) } } + private val store = ShuffleFeatureStore(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted)) + + @AfterTest fun close() = client.close() + + @Test fun capabilityStartsHiddenSurvivesTransientFailuresAndResets() = runTest { + assertFalse(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + answer = { HttpStatusCode.OK to """{"state":"available","allowed":true,"revision":"r","scope_kinds":["library"]}""" } + store.refresh() + assertTrue(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + + offline = true + store.refresh() + assertTrue(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + offline = false + + answer = { HttpStatusCode.InternalServerError to "" } + store.refresh() + assertTrue(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + + // A server without shuffles answers 404. + answer = { HttpStatusCode.NotFound to "" } + store.refresh() + assertFalse(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + + answer = { HttpStatusCode.OK to """{"state":"available","allowed":true,"revision":"r","scope_kinds":["library"]}""" } + store.refresh() + store.reset() + assertFalse(store.capability.value.supports(ShuffleScopeKind.LIBRARY)) + } + + @Test fun startSaysWhenNothingCanPlay() = runTest { + answer = { HttpStatusCode.Created to shuffleJson() } + assertEquals("sh1", assertIs(store.start(ShuffleScopeKind.SERIES, "show")).shuffle.id) + // The player of that shuffle takes the started pick once. + assertNull(store.takeStarted("other")) + assertEquals("mv-2", store.playback("sh1").nextPickAfter("ep-1")?.contentId) + assertNull(store.takeStarted("sh1")) + answer = { HttpStatusCode.Conflict to "" } + assertEquals(ShuffleFeatureStore.NOTHING_PLAYABLE, assertIs(store.start(ShuffleScopeKind.SERIES, "show")).message) + answer = { HttpStatusCode.NotFound to "" } + assertEquals(ShuffleFeatureStore.START_FAILED, assertIs(store.start(ShuffleScopeKind.SERIES, "show")).message) + offline = true + assertEquals(ShuffleFeatureStore.START_FAILED, assertIs(store.start(ShuffleScopeKind.SERIES, "show")).message) + } + + @Test fun seasonsShuffleWithTwoPlayableEpisodesAndOnlyVideoLibrariesShuffle() { + fun episode(n: Int, playable: Boolean) = org.siloserver.silo.model.catalog.EpisodeListItem( + contentId = "e$n", seasonNumber = 1, episodeNumber = n, + files = if (playable) listOf(org.siloserver.silo.model.catalog.EpisodeFile(fileId = n)) else emptyList(), + ) + assertFalse(org.siloserver.silo.model.shuffle.canShuffleSeason(listOf(episode(1, true), episode(2, false)))) + assertTrue(org.siloserver.silo.model.shuffle.canShuffleSeason(listOf(episode(1, true), episode(2, true)))) + assertTrue(org.siloserver.silo.model.shuffle.isShuffleLibraryType("movies")) + assertTrue(org.siloserver.silo.model.shuffle.isShuffleLibraryType("Mixed")) + assertTrue(org.siloserver.silo.model.shuffle.isShuffleLibraryType("series")) + assertFalse(org.siloserver.silo.model.shuffle.isShuffleLibraryType("audiobooks")) + assertFalse(org.siloserver.silo.model.shuffle.isShuffleLibraryType(null)) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Test.kt new file mode 100644 index 0000000000..d8c38209ce --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ShufflesV2Test.kt @@ -0,0 +1,160 @@ +package org.siloserver.silo.network.apiv2 + +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.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.* +import io.ktor.http.content.TextContent +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.shuffle.ShuffleScopeKind +import org.siloserver.silo.network.* +import kotlin.test.* + +internal fun shuffleJson( + id: String = "sh1", + current: String = """{"content_id":"ep-1","type":"episode","title":"Pilot","series_title":"Show","season_number":1,"episode_number":1}""", + next: String = """{"content_id":"mv-2","type":"movie","title":"Film","backdrop_url":"/art/mv-2.jpg"}""", + scope: String = """{"kind":"season","id":"show-S1","title":"Season 1","parent_title":"Show"}""", +) = """{"id":"$id","scope":$scope,"current":$current,"next":$next,"created_at":"2026-10-05T10:00:00.000Z","updated_at":"2026-10-05T10:00:00.000Z"}""" + +class ShufflesV2Test { + private var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + + private fun client(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData) = + HttpClient(MockEngine(handler)) { install(ContentNegotiation) { json(SiloJson) } } + + private fun MockRequestHandleScope.json(body: String, status: HttpStatusCode = HttpStatusCode.OK) = + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + + private fun MockRequestHandleScope.problem(status: HttpStatusCode, code: String) = respond( + """{"type":"https://siloserver.org/docs/api/v2/problems/$code","title":"T","status":${status.value},"detail":"Nothing here can be played."}""", + status, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + + @Test fun capabilityGatesOnAvailableAndListedScopeKinds() = runTest { + var body = """{"state":"available","allowed":true,"revision":"r","scope_kinds":["library","season"]}""" + val c = client { + assertEquals("/api/v2/shuffles/capabilities", it.url.encodedPath) + assertEquals(HttpMethod.Get, it.method) + assertEquals(owner, it.attributes[AuthScopeAttributeKey]) + json(body) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + val capability = assertIs>(api.capability()).data + assertTrue(capability.supports(ShuffleScopeKind.LIBRARY)) + assertTrue(capability.supports(ShuffleScopeKind.SEASON)) + assertFalse(capability.supports(ShuffleScopeKind.SERIES)) + body = """{"state":"disabled","allowed":true,"revision":"r","scope_kinds":["library"]}""" + assertFalse(assertIs>(api.capability()).data.supports(ShuffleScopeKind.LIBRARY)) + body = """{"state":"available","allowed":false,"revision":"r","scope_kinds":["library"]}""" + assertFalse(assertIs>(api.capability()).data.supports(ShuffleScopeKind.LIBRARY)) + } finally { + c.close() + } + } + + @Test fun createPostsTheScopeOnceAndExpectsCreated() = runTest { + var sends = 0 + var status = HttpStatusCode.Created + val c = client { + sends++ + assertEquals("/api/v2/shuffles", it.url.encodedPath) + assertEquals(HttpMethod.Post, it.method) + assertEquals("large", it.url.parameters["image_size"]) + assertTrue(it.attributes[SingleAttemptAttributeKey]) + assertTrue(it.attributes[RequireSiloAuthAttributeKey]) + assertEquals("""{"scope":{"kind":"user_collection","id":"c 1"}}""", (it.body as TextContent).text) + json(shuffleJson(), status) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + val shuffle = assertIs>( + api.create(ShuffleScopeKind.USER_COLLECTION, "c 1"), + ).data + assertEquals("sh1", shuffle.id) + assertEquals("ep-1", shuffle.current.contentId) + assertEquals("mv-2", shuffle.next.contentId) + assertEquals("Show", shuffle.scope.parentTitle) + // A 200 is not the contract's 201. + status = HttpStatusCode.OK + assertFalse(api.create(ShuffleScopeKind.USER_COLLECTION, "c 1") is ApiResult.Success) + assertEquals(2, sends) + // A blank scope never reaches the server. + assertEquals(422, assertIs(api.create(ShuffleScopeKind.LIBRARY, " ")).code) + assertEquals(2, sends) + } finally { + c.close() + } + } + + @Test fun createSurfacesNotVisibleAndNothingPlayable() = runTest { + var status = HttpStatusCode.Conflict + val c = client { problem(status, if (status == HttpStatusCode.Conflict) "conflict" else "not_found") } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + val conflict = assertIs(api.create(ShuffleScopeKind.SEASON, "show-S1")) + assertEquals(409, conflict.code) + assertEquals("conflict", conflict.error) + status = HttpStatusCode.NotFound + assertEquals(404, assertIs(api.create(ShuffleScopeKind.SEASON, "show-S1")).code) + } finally { + c.close() + } + } + + @Test fun advanceSkipReadAndDeleteNameTheShuffleAndItsItem() = runTest { + val seen = mutableListOf() + val c = client { + val body = (it.body as? TextContent)?.text.orEmpty() + seen += "${it.method.value} ${it.url.encodedPath} $body".trim() + if (it.method != HttpMethod.Get) assertTrue(it.attributes[SingleAttemptAttributeKey]) + if (it.method == HttpMethod.Delete) respond("", HttpStatusCode.NoContent) else json(shuffleJson(id = "a/b")) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertIs>(api.get("a/b")) + assertIs>(api.advance("a/b", "ep-1")) + assertIs>(api.skip("a/b", "mv-2")) + assertIs>(api.delete("a/b")) + assertEquals( + listOf( + "GET /api/v2/shuffles/a%2Fb", + """POST /api/v2/shuffles/a%2Fb/advance {"from_content_id":"ep-1"}""", + """POST /api/v2/shuffles/a%2Fb/skip {"next_content_id":"mv-2"}""", + "DELETE /api/v2/shuffles/a%2Fb", + ), + seen, + ) + } finally { + c.close() + } + } + + @Test fun rejectsShufflesWithoutPicksAndStaleOwners() = runTest { + var body = shuffleJson(next = """{"content_id":"","type":"movie","title":"Film"}""") + var late = false + val c = client { + if (late) owner = owner.copy(profileToken = "replacement") + json(body) + } + try { + val api = ShufflesV2Api(c, tokens, ApiV2Gate.Unrestricted) + assertEquals("invalid_response", assertIs(api.get("sh1")).error) + body = shuffleJson() + late = true + assertEquals("identity_changed", assertIs(api.get("sh1")).error) + } finally { + c.close() + } + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackPartsTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackPartsTest.kt new file mode 100644 index 0000000000..33a0fee4c4 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackPartsTest.kt @@ -0,0 +1,52 @@ +package org.siloserver.silo.playback + +import org.siloserver.silo.model.catalog.FileVersion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class PlaybackPartsTest { + private fun part(id: Int, index: Int?, resolution: String = "1080p", group: String = "Film", edition: String? = null, kind: String = "multipart_movie") = + FileVersion( + fileId = id, + resolution = resolution, + presentationKind = kind, + presentationGroupKey = group, + presentationPartIndex = index, + editionKey = edition, + ) + + @Test fun nextPartIsTheLowestLaterPartOfTheSamePresentation() { + val versions = listOf( + part(1, 1), part(2, 2), part(3, 3), + part(4, 2, resolution = "720p"), + part(5, 2, edition = "extended"), + part(6, 2, group = "Other"), + ) + assertEquals(2, nextPlaybackPartFileId(versions, 1)) + assertEquals(3, nextPlaybackPartFileId(versions, 2)) + assertNull(nextPlaybackPartFileId(versions, 3)) + // The current file's resolution wins when a part has several versions. + assertEquals(4, nextPlaybackPartFileId(listOf(part(7, 1, resolution = "720p"), part(2, 2), part(4, 2, resolution = "720p")), 7)) + } + + @Test fun ordinaryFilesAndSplitEpisodesFollowTheirOwnRules() { + val single = FileVersion(fileId = 9, resolution = "1080p") + assertNull(nextPlaybackPartFileId(listOf(single), 9)) + assertNull(nextPlaybackPartFileId(listOf(single), null)) + val split = listOf(part(1, 1, kind = "split_episode"), part(2, 2, kind = "split_episode")) + assertEquals(2, nextPlaybackPartFileId(split, 1)) + val audiobook = listOf(part(1, 1, kind = "audiobook_part"), part(2, 2, kind = "audiobook_part")) + assertNull(nextPlaybackPartFileId(audiobook, 1)) + } + + @Test fun aStartFromTheBeginningOpensTheFirstPart() { + val versions = listOf(part(2, 2), part(1, 1), part(3, 1, resolution = "720p"), part(4, 2, resolution = "720p")) + assertEquals(1, firstPlaybackPart(versions, versions[0]).fileId) + assertEquals(3, firstPlaybackPart(versions, versions[3]).fileId) + assertSame(versions[1], firstPlaybackPart(versions, versions[1])) + val single = FileVersion(fileId = 9, resolution = "1080p") + assertSame(single, firstPlaybackPart(listOf(single), single)) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt new file mode 100644 index 0000000000..5059df4d72 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt @@ -0,0 +1,211 @@ +package org.siloserver.silo.playback + +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.* +import io.ktor.http.content.TextContent +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.shuffle.ShuffleScope +import org.siloserver.silo.network.* +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.ShufflesV2Api +import org.siloserver.silo.network.apiv2.shuffleJson +import kotlin.test.* + +class ShufflePlaybackTest { + private val owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + + private val episodeCard = + """{"content_id":"ep-1","type":"episode","title":"Pilot","series_title":"Show","season_number":0,"episode_number":2,"poster_url":"/still.jpg","backdrop_url":"/backdrop.jpg","runtime":42,"overview":"O"}""" + private val movieCard = + """{"content_id":"mv-2","type":"movie","title":"Film","series_title":"Ignored","poster_url":"/poster.jpg","backdrop_url":"/backdrop.jpg","runtime":95}""" + + /** A session against a fake server whose next answer each test sets. */ + private class FakeServer { + var status: HttpStatusCode = HttpStatusCode.OK + var body: String = shuffleJson() + val requests = mutableListOf() + } + + private fun session(server: FakeServer): Pair { + val client = HttpClient(MockEngine { + server.requests += "${it.method.value} ${it.url.encodedPath} ${(it.body as? TextContent)?.text.orEmpty()}".trim() + if (server.status.isSuccess()) { + respond(server.body, server.status, headersOf(HttpHeaders.ContentType, "application/json")) + } else { + respond( + """{"type":"https://siloserver.org/docs/api/v2/problems/x","title":"T","status":${server.status.value},"detail":"d"}""", + server.status, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } + }) { install(ContentNegotiation) { json(SiloJson) } } + return ShufflePlayback(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted), "sh1") to client + } + + @Test fun scopeLabelNamesASeasonUnderItsSeries() { + assertEquals("Movies", ShuffleScope("library", "1", "Movies").label()) + assertEquals("Breaking Bad · Season 2", ShuffleScope("season", "s", "Season 2", "Breaking Bad").label()) + assertEquals("Specials", ShuffleScope("season", "s", "Specials", " ").label()) + } + + @Test fun nextPickShowsAnEpisodeWithItsStillAndAMovieWithItsTitleAndBackdrop() = runTest { + val server = FakeServer() + val (playback, client) = session(server) + try { + server.body = shuffleJson(current = movieCard, next = episodeCard) + assertIs>(playback.refresh()) + val episode = assertNotNull(playback.nextPickAfter("mv-2")) + assertTrue(episode.isEpisode) + assertEquals("Show", episode.seriesTitle) + // Specials are season 0 and still show their numbers. + assertEquals(0, episode.seasonNumber) + assertEquals(2, episode.episodeNumber) + assertEquals("http://localhost/still.jpg", episode.stillUrl) + assertEquals(42, episode.runtimeMinutes) + + server.body = shuffleJson(current = episodeCard, next = movieCard) + playback.refresh() + val movie = assertNotNull(playback.nextPickAfter("ep-1")) + assertFalse(movie.isEpisode) + assertEquals("Film", movie.title) + assertNull(movie.seriesTitle) + assertEquals(0, movie.seasonNumber) + assertEquals(0, movie.episodeNumber) + assertEquals("http://localhost/backdrop.jpg", movie.stillUrl) + assertEquals("Show · Season 1", playback.scopeLabel) + } finally { + client.close() + } + } + + @Test fun aScopeWithOnePlayableItemIsFinished() = runTest { + val server = FakeServer().apply { body = shuffleJson(current = movieCard, next = movieCard) } + val (playback, client) = session(server) + try { + playback.refresh() + assertNull(playback.nextPickAfter("mv-2")) + } finally { + client.close() + } + } + + @Test fun conflictMeansFinishedButOtherFailuresKeepTheLastPick() = runTest { + val server = FakeServer() + val (playback, client) = session(server) + try { + playback.refresh() + assertEquals("mv-2", playback.nextPickAfter("ep-1")?.contentId) + + server.status = HttpStatusCode.InternalServerError + assertIs(playback.refresh()) + assertFalse(playback.exhausted) + assertEquals("mv-2", playback.nextPickAfter("ep-1")?.contentId) + + server.status = HttpStatusCode.Conflict + assertEquals(409, assertIs(playback.refresh()).code) + assertTrue(playback.exhausted) + assertNull(playback.nextPickAfter("ep-1")) + + // A later successful read lifts the finished state. + server.status = HttpStatusCode.OK + playback.refresh() + assertFalse(playback.exhausted) + assertEquals("mv-2", playback.nextPickAfter("ep-1")?.contentId) + } finally { + client.close() + } + } + + @Test fun aReadThatPickAnotherOvertookCannotPutBackTheReplacedPick() = runTest { + val readGate = kotlinx.coroutines.CompletableDeferred() + var reads = 0 + val client = HttpClient(MockEngine { + val skipped = it.url.encodedPath.endsWith("/skip") + if (!skipped && reads++ == 1) readGate.await() + val next = if (skipped) """{"content_id":"mv-3","type":"movie","title":"Other"}""" else movieCard + respond(shuffleJson(current = episodeCard, next = next), HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(SiloJson) } } + try { + val playback = ShufflePlayback(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted), "sh1") + playback.refresh() + val slowRead = async { playback.refresh() } + kotlinx.coroutines.yield() + playback.pickAnother() + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + readGate.complete(Unit) + assertIs>(slowRead.await()) + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + } finally { + client.close() + } + } + + @Test fun aReadStartedDuringPickAnotherCannotPutBackTheReplacedPick() = runTest { + val skipGate = kotlinx.coroutines.CompletableDeferred() + val readGate = kotlinx.coroutines.CompletableDeferred() + var reads = 0 + val client = HttpClient(MockEngine { + val skipped = it.url.encodedPath.endsWith("/skip") + if (skipped) skipGate.await() else if (reads++ == 1) readGate.await() + val next = if (skipped) """{"content_id":"mv-3","type":"movie","title":"Other"}""" else movieCard + respond(shuffleJson(current = episodeCard, next = next), HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(SiloJson) } } + try { + val playback = ShufflePlayback(ShufflesV2Api(client, tokens, ApiV2Gate.Unrestricted), "sh1") + playback.refresh() + val skip = async { playback.pickAnother() } + kotlinx.coroutines.yield() + // The read starts while the skip waits for the server, which + // answers it with the pick the skip is replacing. + val slowRead = async { playback.refresh() } + kotlinx.coroutines.yield() + skipGate.complete(Unit) + skip.await() + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + readGate.complete(Unit) + assertIs>(slowRead.await()) + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + } finally { + client.close() + } + } + + @Test fun pickAnotherSkipsTheAnnouncedPickAndAdvanceNamesTheItemThatPlayed() = runTest { + val server = FakeServer() + val (playback, client) = session(server) + try { + // Nothing announced yet: Pick Another has nothing to replace. + assertIs(playback.pickAnother()) + assertTrue(server.requests.isEmpty()) + + playback.refresh() + server.body = shuffleJson(next = """{"content_id":"mv-3","type":"movie","title":"Other"}""") + assertIs>(playback.pickAnother()) + assertEquals("mv-3", playback.nextPickAfter("ep-1")?.contentId) + + server.body = shuffleJson(current = """{"content_id":"mv-3","type":"movie","title":"Other"}""", next = episodeCard) + val advanced = assertIs>(playback.advance("ep-1")).data + assertEquals("mv-3", advanced.current.contentId) + assertEquals("ep-1", playback.nextPickAfter("mv-3")?.contentId) + + assertEquals( + listOf( + "GET /api/v2/shuffles/sh1", + """POST /api/v2/shuffles/sh1/skip {"next_content_id":"mv-2"}""", + """POST /api/v2/shuffles/sh1/advance {"from_content_id":"ep-1"}""", + ), + server.requests, + ) + } finally { + client.close() + } + } +} From 9343ddebbf07a8a1be8ced91708a1dd09b19088d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:15:43 -0400 Subject: [PATCH 34/87] docs(contributing): require evidence for user-visible changes (#490) * docs(contributing): require evidence for user-visible changes Pull requests that change what users see, in layout or in which items appear and what they show, now need before-and-after screenshots. Adds a PR template with an Evidence section. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UVwKrQHvZe6KiFS7pHLSpd * docs(contributing): use the shared evidence wording and PR template Aligns the evidence rules, AGENTS.md bullets, and PR template structure with silo-server, silo-android, and silo-apple so the three repositories ask for the same thing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UVwKrQHvZe6KiFS7pHLSpd --------- Co-authored-by: Claude --- .github/PULL_REQUEST_TEMPLATE.md | 69 ++++++++++++++++++++++++++++++++ AGENTS.md | 13 ++++-- CONTRIBUTING.md | 37 +++++++++++++++-- 3 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..fea5c55850 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,69 @@ + + +## Problem + +Closes #NNN + +Related issue: #NNN + +Validation tasks: #NNN C1 + + +A short plain-language summary: what goes wrong, who it affects, and what this +change does about it. Details come after. + +## Approach + +What changed and why this way, and which surfaces and repositories it affects. +Mention an alternative only if a reviewer would ask about it. Do not walk through +the diff. + +## Validation + +One line per kind of check and its result; do not list tests by name. Name required +checks that were not run or did not pass. Include a short output excerpt only when it +explains a failure. + + +## Evidence + +| Before | After | +| ------ | ----- | +| | | + +Surface and build: + + +## Risks + +Migration, compatibility, security, operational, or release impact, or "None +identified". + +## Checklist + +- [ ] I read and can explain the complete diff. +- [ ] This pull request addresses one concern. +- [ ] The Evidence section shows every change a user can see, or says there is none. + +## AI Disclosure + +- Harness: exact agent harness or application, or "none" +- Tool(s): exact tool name(s), or "none" +- Model(s): exact model identifier(s) reported by each tool, or "n/a" +- Involvement: Fully AI-generated, human verified | AI-assisted | Human-written, AI-reviewed | No AI used +- Adversarial review: scope, method, findings, and resolutions, or "n/a" when this change does not require independent or adversarial review diff --git a/AGENTS.md b/AGENTS.md index 93efc11a97..6dc2b30111 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,15 @@ follow-up work. - Keep one concern per pull request. If an honest description needs the word "also," split the work. -- Include before-and-after images for UI changes. Include a short video when - motion or timing matters. -- Upload pull request evidence to GitHub. Never commit PR-only assets such as - `.github/pr-assets/`. +- Every pull request that changes what a user sees must include evidence, as + [Show visible changes](CONTRIBUTING.md#show-visible-changes) defines. That + covers UI and UX changes and changes to which items appear or what they show, + such as search results, home sections, recommendations, sorting, filtering, + metadata, or artwork. Use before-and-after captures of the same screen with the + same data, and a short recording when motion, timing, or focus matters. Write + `Evidence: none, no user-visible change` only when that is true. +- Check evidence media for private information before posting, and upload it to + GitHub. Never commit PR-only assets such as `.github/pr-assets/`. - When babysitting a pull request, poll checks and review comments created after the last push. Verify bot findings against the source, fix real issues, and dismiss false positives with a written reason. Remain quiet when nothing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 016d83b06d..ecbce949fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,13 +50,42 @@ same checks as CI: Then build every affected app with `:androidApp:assembleDebug` and/or `:androidTvApp:assembleDebug`. Do not regenerate a lint baseline to hide a new -finding. Exercise visible changes on the affected phone or TV surface and -include screenshots or a short recording when the result is visual. +finding. Exercise visible changes on each affected phone or TV surface. + +## Show visible changes + +A pull request that changes what a user sees must show the change in its +Evidence section, so reviewers can see it without building the branch. In this +repository that means Android phone and Android TV. A change is visible when it +alters any of these: + +- layout, styling, copy, navigation, focus, empty and error states; +- which items a screen shows, or in what order: search results, home sections, + recommendations, library browsing, collections, sorting, or filtering; +- what an item shows: titles, artwork, descriptions, ratings, badges, episode + grouping, or availability; +- playback behavior a user notices, such as default audio or subtitle tracks, + markers, controls, or resume position. + +Provide evidence that fits the change: + +- **Changes to a screen:** before-and-after screenshots of the same screen with + the same data, one pair per affected surface. Add a short recording when + motion, timing, focus movement, or a multi-step flow matters. +- Name the surface and the build or commit each capture came from. + +Capture against a test library or public-domain media where you can. Crop or +blur hostnames, URLs, account names, and personal library contents. When the +evidence cannot be made public, a maintainer may link an +`evidence.siloserver.org` page instead. If you could not capture evidence, +say why; the reviewer decides whether the pull request can merge without it. +Changes users cannot see write `Evidence: none, no user-visible change`. ## Open the pull request -Use a Conventional Commit title, explain which surfaces are affected, paste the -actual validation results, and call out any server or Apple coordination. Read +Use a Conventional Commit title, fill in the pull request template, explain +which surfaces are affected, paste the actual validation results, and call out +any server or Apple coordination. Read the [AI-assisted contribution policy](https://github.com/Silo-Server/silo-server/blob/main/docs/ai-contributions.md) and include its disclosure block. From 975df0d1d4ca3f4adbe8e565ac6a51a2b98f552e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:26:11 -0400 Subject: [PATCH 35/87] fix(settings): say connecting Tailscale keeps the Silo password (#492) * fix(settings): say connecting Tailscale keeps the Silo password Settings -> Sign-in told people that connecting a network sign-in provider such as Tailscale means signing in with it instead of their password. The server now keeps the account's password when a network identity is connected, so the Connect row and the password prompt for a network provider say the person can sign in with the provider or their password. OAuth and directory copy is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): keep the old Tailscale copy on servers that turn the password off Servers before the network_link_keeps_password capability still turn the account's password off when a network identity is connected, and they report the same network_sign_in capability. The shared external sign-in capabilities now read network_link_keeps_password (false when absent or when network sign-in isn't served), and the Connect row and password prompt for a network provider say the password stays only when the server reports it. Other servers get the original text. The tests now cover that gating and the capability's decode default instead of pinning the new sentences. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../ui/screens/settings/SignInSection.kt | 8 +++- .../settings/SignInSettingsViewModel.kt | 41 ++++++++++++++++--- .../ui/screens/settings/SignInSettingsTest.kt | 31 ++++++++++++-- .../silo/model/auth/ExternalSignInModels.kt | 6 +++ .../silo/network/api/ExternalSignInApi.kt | 2 + .../silo/network/apiv2/ApiV2ContractTest.kt | 15 +++++++ 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt index e7d28187e3..3285fc2868 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt @@ -94,7 +94,7 @@ fun SignInSection( state.network?.let { provider -> SettingsNavigationRow( label = "Connect ${provider.displayName}", - description = SignInSettingsViewModel.connectDescription(provider.displayName), + description = SignInSettingsViewModel.connectNetworkDescription(provider.displayName, state.networkLinkKeepsPassword), onClick = { viewModel.onConnectNetwork(provider) }, enabled = !state.busy, ) @@ -120,7 +120,11 @@ fun SignInSection( state.networkPrompt?.let { provider -> ConfirmPasswordDialog( providerName = provider.displayName, - body = SignInSettingsViewModel.connectNetworkPrompt(provider.displayName, provider.networkIdentity?.label), + body = SignInSettingsViewModel.connectNetworkPrompt( + provider.displayName, + provider.networkIdentity?.label, + state.networkLinkKeepsPassword, + ), confirmLabel = "Connect", error = state.passwordError, busy = state.busy, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt index 0d566edaeb..abfc00bc78 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsViewModel.kt @@ -52,6 +52,8 @@ data class SignInSettingsUiState( * the person who owns this device there. */ val network: SignInProvider? = null, + /** The server keeps the account's password when [network] is connected (`network_link_keeps_password`). */ + val networkLinkKeepsPassword: Boolean = false, /** Asking for the account password before connecting [network]. */ val networkPrompt: SignInProvider? = null, /** Asking for the local password before connecting this provider. */ @@ -128,6 +130,7 @@ class SignInSettingsViewModel( connectable = emptyList(), directory = null, network = null, + networkLinkKeepsPassword = false, ) } return @@ -147,6 +150,7 @@ class SignInSettingsViewModel( credentialsLinking = served?.credentialsLinking == true, canUnlink = listed?.canUnlink, networkSignIn = served?.networkSignIn == true, + networkLinkKeepsPassword = served?.networkLinkKeepsPassword == true, ) } val now = tokenManager.snapshotCurrentScope() @@ -165,6 +169,7 @@ class SignInSettingsViewModel( connectable = section.connectable, directory = section.directory, network = section.network, + networkLinkKeepsPassword = section.networkLinkKeepsPassword, ) } } @@ -417,6 +422,7 @@ class SignInSettingsViewModel( val directory: SignInProvider?, val canUnlink: Boolean? = null, val network: SignInProvider? = null, + val networkLinkKeepsPassword: Boolean = false, ) companion object { @@ -427,6 +433,17 @@ class SignInSettingsViewModel( fun connectDescription(providerName: String): String = "After connecting, you sign in with $providerName instead of your password. You'll confirm your password first." + /** + * "Connect " for a network provider. The account keeps its + * password only on a server that says so ([keepsPassword]). + */ + fun connectNetworkDescription(providerName: String, keepsPassword: Boolean): String = + if (keepsPassword) { + "After connecting, you can sign in with $providerName or your password. You'll confirm your password first." + } else { + connectDescription(providerName) + } + fun connectDirectoryDescription(providerName: String): String = "After connecting, you sign in with your $providerName username and password instead of this account's password." @@ -440,16 +457,22 @@ class SignInSettingsViewModel( /** * The password prompt for a network provider. [owner] is who the - * provider says owns this device, the person being connected. + * provider says owns this device, the person being connected. The + * account keeps its password only on a server that says so + * ([keepsPassword]). */ - fun connectNetworkPrompt(providerName: String, owner: String?): String { + fun connectNetworkPrompt(providerName: String, owner: String?, keepsPassword: Boolean): String { val who = owner?.let { "$it on $providerName" } ?: "the $providerName account this device uses" return "Enter this account's password to connect $who. " + - "After connecting, you sign in with $providerName instead of your password." + if (keepsPassword) { + "You can then sign in with $providerName or your password." + } else { + "After connecting, you sign in with $providerName instead of your password." + } } /** - * Linking turns the account's own password off, so Disconnect can't + * Linking can turn the account's own password off, so Disconnect can't * promise it unless the server said up front ([canUnlink]) that the * account keeps another way to sign in. */ @@ -486,6 +509,7 @@ class SignInSettingsViewModel( credentialsLinking: Boolean, canUnlink: Boolean? = null, networkSignIn: Boolean = false, + networkLinkKeepsPassword: Boolean = false, ): Section { if (!identitiesServed) return Section(emptyList(), emptyList(), null) val linked = identities.map { it.installationId }.toSet() @@ -494,7 +518,14 @@ class SignInSettingsViewModel( val connectable = if (handshake.linking) options.oauthProviders else emptyList() val directory = options.directoryProvider.takeIf { credentialsLinking } val network = options.networkProvider.takeIf { networkSignIn } - return Section(identities, connectable, directory, canUnlink.takeIf { identities.isNotEmpty() }, network) + return Section( + identities, + connectable, + directory, + canUnlink.takeIf { identities.isNotEmpty() }, + network, + networkLinkKeepsPassword = network != null && networkLinkKeepsPassword, + ) } fun providerLabel(identity: AccountIdentity): String = diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt index 60f74939da..70c95d280b 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSettingsTest.kt @@ -47,6 +47,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -114,9 +115,33 @@ class SignInSettingsTest { @Test fun theNetworkPromptNamesWhoIsConnected() { - assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", "Alice Example").contains("connect Alice Example on Tailscale")) - assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null).contains("the Tailscale account this device uses")) - assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null).contains("instead of your password")) + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", "Alice Example", false).contains("connect Alice Example on Tailscale")) + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null, false).contains("the Tailscale account this device uses")) + } + + /** + * Only a server that reports `network_link_keeps_password` keeps the + * password on a network link; on older servers the copy still says the + * provider replaces it. + */ + @Test + fun theNetworkCopyKeepsThePasswordOnlyWhereTheServerSaysSo() { + assertTrue(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null, keepsPassword = false).contains("instead of your password")) + assertFalse(SignInSettingsViewModel.connectNetworkPrompt("Tailscale", null, keepsPassword = true).contains("instead of your password")) + assertEquals( + SignInSettingsViewModel.connectDescription("Tailscale"), + SignInSettingsViewModel.connectNetworkDescription("Tailscale", keepsPassword = false), + ) + assertNotEquals( + SignInSettingsViewModel.connectDescription("Tailscale"), + SignInSettingsViewModel.connectNetworkDescription("Tailscale", keepsPassword = true), + ) + fun keeps(served: Boolean) = SignInSettingsViewModel.sectionOf( + true, emptyList(), listOf(local, tailscale), OAuthHandshakeCapabilities.None, + credentialsLinking = false, networkSignIn = true, networkLinkKeepsPassword = served, + ).networkLinkKeepsPassword + assertTrue(keeps(true)) + assertFalse(keeps(false)) } @Test diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt index 3e72df0e5d..c34f651ae2 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/auth/ExternalSignInModels.kt @@ -86,6 +86,12 @@ data class ExternalSignInCapabilities( * discovery, which lists a network provider only through its own network. */ val networkSignIn: Boolean = false, + /** + * Linking a network identity keeps the account's local password sign-in. + * False when network sign-in is not served; servers without the field + * turn the password off on such a link, as for other providers. + */ + val networkLinkKeepsPassword: Boolean = false, ) /** diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt index 3b04b8bf92..adf48e9958 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ExternalSignInApi.kt @@ -397,6 +397,7 @@ internal data class ExternalSignInCapabilitiesV2( val identities: Boolean = false, @SerialName("credentials_linking") val credentialsLinking: Boolean = false, @SerialName("network_sign_in") val networkSignIn: Boolean = false, + @SerialName("network_link_keeps_password") val networkLinkKeepsPassword: Boolean = false, ) { fun domain(): ExternalSignInCapabilities { val usable = available && (state == null || state == "available") @@ -404,6 +405,7 @@ internal data class ExternalSignInCapabilitiesV2( identities = usable && identities, credentialsLinking = usable && credentialsLinking, networkSignIn = usable && networkSignIn, + networkLinkKeepsPassword = usable && networkSignIn && networkLinkKeepsPassword, ) } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt index 813057a080..5f7b67d658 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ApiV2ContractTest.kt @@ -476,6 +476,21 @@ class ApiV2ContractTest { assertFalse(unsupported.networkSignIn) } + @Test + fun networkLinkKeepsPasswordDefaultsToFalseOnOlderServers() { + val fixture = ApiV2Fixtures.bodyObject("get_external_sign_in_capabilities_ok") + val keeps = ApiV2Fixtures.decode( + with(fixture, "network_link_keeps_password" to JsonPrimitive(true)), + ).domain() + assertTrue(keeps.networkLinkKeepsPassword) + val older = ApiV2Fixtures.decode(with(fixture, "network_link_keeps_password" to null)).domain() + assertFalse(older.networkLinkKeepsPassword, "absent network_link_keeps_password means a network link turns the password off") + val notServed = ApiV2Fixtures.decode( + with(fixture, "network_link_keeps_password" to JsonPrimitive(true), "network_sign_in" to JsonPrimitive(false)), + ).domain() + assertFalse(notServed.networkLinkKeepsPassword, "false while network sign-in isn't served") + } + // --- Network identity (signInWithNetworkIdentity, linkAccountIdentityWithNetwork) --- @Test From 3f8c867cbd23bea0f86c51fff779f076109bd860 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:22:47 -0400 Subject: [PATCH 36/87] docs(pr): offer the evidence site as an optional place for PR evidence (#496) Contributors can keep attaching screenshots on GitHub or publish them to evidence.siloserver.org, where only they and Silo maintainers can open them. Add .github/evidence.yml so the Evidence check asks for evidence on changes to the phone and TV app source. Co-authored-by: Claude Opus 5.5 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 10 ++++++---- .github/evidence.yml | 8 ++++++++ AGENTS.md | 11 +++++++++-- CONTRIBUTING.md | 34 ++++++++++++++++++++++++++------ 4 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 .github/evidence.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fea5c55850..5d87cb34d4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -44,10 +44,12 @@ item details a screen shows, such as search results, home sections, recommendati metadata, artwork, sorting, or filtering. See "Show visible changes" in CONTRIBUTING.md. Use before-and-after screenshots of the same screen and data for each affected surface, and a short recording when motion, timing, or focus matters. Crop or -blur private details. A maintainer may link -`Evidence: https://evidence.siloserver.org/r///` instead; that page -requires Silo-Server organization sign-in. When nothing visible changes, replace this -section's content with "Evidence: none, no user-visible change". --> +blur private details in anything attached here. To keep captures private instead, +publish them to evidence.siloserver.org, where only you and Silo maintainers can open +them, and replace the table with +`Evidence: https://evidence.siloserver.org/r/silo-android/pr-/`. When nothing +visible changes, replace this section's content with "Evidence: none, no user-visible +change". --> ## Risks diff --git a/.github/evidence.yml b/.github/evidence.yml new file mode 100644 index 0000000000..a4bc7bb9b9 --- /dev/null +++ b/.github/evidence.yml @@ -0,0 +1,8 @@ +# Pull requests that change the phone app, the TV app, or the Android code they share +# need evidence before the Evidence check passes. The check reads this file at a pull +# request's base commit. Maintainers override it with the evidence-required and +# evidence-not-needed labels. See "Show visible changes" in CONTRIBUTING.md. +require: + - androidApp/src/androidMain/** + - androidTvApp/src/androidMain/** + - android-shared/src/androidMain/** diff --git a/AGENTS.md b/AGENTS.md index 6dc2b30111..b35b5f181e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,15 @@ follow-up work. metadata, or artwork. Use before-and-after captures of the same screen with the same data, and a short recording when motion, timing, or focus matters. Write `Evidence: none, no user-visible change` only when that is true. -- Check evidence media for private information before posting, and upload it to - GitHub. Never commit PR-only assets such as `.github/pr-assets/`. +- Attach evidence on GitHub under the PR body's Evidence heading, or publish it + with `npx @silo-server/evidence publish --pr ` and put its + `Evidence:` link there. Only Silo maintainers and the pull request's author can + open a published page. GitHub has no API for attaching images to a pull + request, so an agent either publishes with the CLI or gives the developer the + captures to attach. Check media for private information before it goes on + GitHub. When publishing exits 4, ask the developer to run + `npx @silo-server/evidence login`; never approve that login or read the saved + key. Never commit PR-only assets such as `.github/pr-assets/`. - When babysitting a pull request, poll checks and review comments created after the last push. Verify bot findings against the source, fix real issues, and dismiss false positives with a written reason. Remain quiet when nothing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecbce949fb..156d77c226 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,12 +74,34 @@ Provide evidence that fits the change: motion, timing, focus movement, or a multi-step flow matters. - Name the surface and the build or commit each capture came from. -Capture against a test library or public-domain media where you can. Crop or -blur hostnames, URLs, account names, and personal library contents. When the -evidence cannot be made public, a maintainer may link an -`evidence.siloserver.org` page instead. If you could not capture evidence, -say why; the reviewer decides whether the pull request can merge without it. -Changes users cannot see write `Evidence: none, no user-visible change`. +Capture against a test library or public-domain media where you can, and keep +passwords, tokens, and API keys out of every capture. Then put the evidence in +one of two places: + +- **On GitHub:** attach the screenshots or recordings under the pull request's + Evidence heading. Everything on GitHub is public, so crop or blur hostnames, + URLs, account names, and personal library contents. +- **On [evidence.siloserver.org](https://evidence.siloserver.org/) (optional):** + only you and Silo maintainers can open what you publish there, after signing + in with GitHub, so captures need no cropping or blurring. Captions that start + with `Before:` and `After:` become a side-by-side comparison, and recordings + get a player. Upload from the Details link of the pull request's `Evidence` + check, or with the command line (Node.js 22 or later): + `npx @silo-server/evidence login` once on each computer, then + `npx @silo-server/evidence publish --pr `. The + [package README](https://www.npmjs.com/package/@silo-server/evidence) describes + the folder. Then write + `Evidence: https://evidence.siloserver.org/r/silo-android/pr-/` under + the Evidence heading. Until a pull request of yours has merged here, a + maintainer approves you once before your first upload. + +The pull request's `Evidence` check passes once evidence is published or +attached. It asks for evidence when the change touches the phone app, the TV app, or the Android code they share, +or when a maintainer adds the `evidence-required` label; a maintainer adds +`evidence-not-needed` when nothing visible changed. Changes users cannot see +write `Evidence: none, no user-visible change`. If you could not capture +evidence, say why; the reviewer decides whether the pull request can merge +without it. ## Open the pull request From 95d20a95b052580246647114aee9a97dee81eefe Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:42:34 -0400 Subject: [PATCH 37/87] feat(player): show the server quality menu on phone and align the TV picker (#495) * feat(player): show the server quality menu on phone and align the TV picker The phone player had no way to pick the server's playback qualities: its "Quality" sheet listed file versions instead. It now has a Quality menu built from the plan's available_qualities (Auto first, then each entry in server order with its bitrate and a check on the active one), shown whenever a plan is active, and the file list becomes a separate Version sheet. A failed quality change now says so instead of reporting a subtitle failure. The TV Quality picker gets the same Auto row, "Original" name, bitrates and check. On both, a single-entry plan lists Auto and Original, as the Apple clients do. Naming, bitrate formatting and active-entry resolution live in shared so both apps render the menu the same way. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(player): keep a picked quality on same-title reloads and mark the delivered entry Reloads of the same title (recovery restarts, retries, the next shuffle part) now start with the quality picked from the menu instead of the route's quality. The phone's Quality check, gear label and same-entry guard now read the plan's delivered recipe, so a stored resolution preference held lower by a bitrate cap marks the entry that plays, as the TV already does. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/common/player/PlaybackV3Session.kt | 1 + .../MobileSubtitleTransactionAdapter.kt | 11 ++ .../ui/screens/player/PlaybackQualitySheet.kt | 149 ++++++++++++++++++ .../ui/screens/player/PlayerControls.kt | 37 ++++- .../ui/screens/player/PlayerOverlay.kt | 62 ++++++-- .../ui/screens/player/PlayerSettingsSheet.kt | 26 ++- .../ui/screens/player/PlayerViewModel.kt | 77 ++++++++- ...{QualitySelector.kt => VersionSelector.kt} | 15 +- .../screens/player/MobileQualityMenuTest.kt | 69 ++++++++ .../MobileSubtitleTransactionAdapterTest.kt | 21 +++ ...erViewModelLoadOwnershipIntegrationTest.kt | 34 ++++ .../silo/tv/ui/screens/player/TvPlayerHud.kt | 73 +++++---- .../tv/ui/screens/player/TvPlayerScreen.kt | 14 +- .../tv/ui/screens/player/TvPlayerViewModel.kt | 31 +++- .../player/TvPlaybackQualityOptionsTest.kt | 67 ++++++-- docs/watch-party-implementation-plan.md | 2 +- .../silo/model/playback/PlaybackModels.kt | 5 + .../model/playback/PlaybackQualityMenu.kt | 120 ++++++++++++++ .../model/playback/PlaybackQualityMenuTest.kt | 136 ++++++++++++++++ 19 files changed, 843 insertions(+), 107 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt rename androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/{QualitySelector.kt => VersionSelector.kt} (92%) create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileQualityMenuTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenu.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenuTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt index 3f2c9a608c..5efde58ce4 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt @@ -180,6 +180,7 @@ internal fun PlaybackPlanV3.toSessionResponse( decisionTrace = listOf(decisionReason), requestedMediaFileId = requestedMediaFileId ?: mediaFileId, effectiveMediaFileId = effectiveFileId, + effectiveRecipe = effectiveRecipe, ) return PlaybackSessionResponse( sessionId = sessionId, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt index fea59eb8a2..1e3e66385b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt @@ -116,6 +116,8 @@ internal data class MobileSubtitleTransactionSnapshot( val pendingIdentity: SubtitleIdentity? = transition.pending?.identity, val localMountIdentity: SubtitleIdentity? = null, val failureMessage: String? = null, + /** The failure belongs to a quality change, not a subtitle or audio one. */ + val qualityChangeFailed: Boolean = false, ) { val committedIdentity: SubtitleIdentity get() = transition.committed.identity @@ -206,6 +208,7 @@ internal class MobileSubtitleTransactionAdapter( private var refreshGeneration = 0L private var subtitleIntentGeneration = 0L private var failureMessage: String? = null + private var qualityChangeFailed = false private var pendingLocalSelection: PendingLocalSelection? = null private var pendingLocalRestore: PendingLocalRestore? = null private var localMountGeneration = 0L @@ -225,6 +228,7 @@ internal class MobileSubtitleTransactionAdapter( pendingIdentity = queuedIdentity ?: localIdentity ?: transition.pending?.identity, localMountIdentity = if (queuedIdentity == null) localIdentity else null, failureMessage = failureMessage, + qualityChangeFailed = qualityChangeFailed && failureMessage != null, ) } @@ -986,6 +990,10 @@ internal class MobileSubtitleTransactionAdapter( } private fun fail(generation: Long, message: String) { + val failedPending = transition.pending?.takeIf { it.generation == generation } + val failedQualityChange = failedPending != null && + failedPending.qualityPreferenceSpecified && + failedPending.qualityPreference != transition.committed.qualityPreference val failedLocalOwner = pendingLocalSelection?.takeIf { owner -> owner.proposedState.pending?.generation == generation } @@ -1002,6 +1010,7 @@ internal class MobileSubtitleTransactionAdapter( } transition = failed.state failureMessage = message + qualityChangeFailed = failedQualityChange val priorIdentity = transition.committed.identity if ( failedLocalOwner?.mountedBeforeAdoption == true && @@ -1031,12 +1040,14 @@ internal class MobileSubtitleTransactionAdapter( } invalidateLocalMount() failureMessage = "Embedded subtitles couldn't load. Retrying with a sidecar." + qualityChangeFailed = false publish() onEmbeddedSubtitleFailure(failedIdentity.serverIndex) return } invalidateLocalMount() failureMessage = "The selected subtitle could not be mounted." + qualityChangeFailed = false publish() } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt new file mode 100644 index 0000000000..97b9b44e86 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt @@ -0,0 +1,149 @@ +package org.siloserver.silo.android.ui.screens.player + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +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.siloserver.silo.model.playback.PlaybackQualityOption +import org.siloserver.silo.model.playback.activePlaybackQualityId + +/** + * The [menu] row the committed preference selects, judged against what the + * plan actually delivers, so a stored "1080p" that a bitrate cap held to + * 720p marks the 720p entry. + */ +internal fun PlayerViewModel.PlayerUiState.activeQualityId(menu: List): String? = + activePlaybackQualityId(menu, committedQualityPreference, playbackPlan?.effectiveRecipe) + +/** + * Bottom sheet listing the plan's Quality menu: Auto, then the server's + * entries in its order, each with its bitrate and a check on the active one. + * A pick re-plans the same title on the server. The title's separate files + * are the Version sheet, [VersionSelector]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaybackQualitySheet( + options: List, + activeId: String?, + // A Watch Party keeps every member on the room's file; quality still + // applies to this device's stream. + inRoom: Boolean, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, + tabletopPaneHeight: Dp? = null, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + PlayerModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + tabletopPaneHeight = tabletopPaneHeight, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard) + .padding(bottom = 24.dp), + ) { + PlayerSheetHeader(title = "Quality", onDismiss = onDismiss) + PlayerSheetDivider() + if (inRoom) { + Text( + text = "Watch Party keeps everyone on the same version. " + + "You can adjust your streaming quality below.", + color = Color.White.copy(alpha = 0.5f), + fontSize = 12.sp, + lineHeight = 16.sp, + modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 4.dp), + ) + } + LazyColumn { + items(options, key = { it.id }, contentType = { "quality-option" }) { option -> + PlaybackQualityRow( + option = option, + isSelected = option.id == activeId, + onClick = { + onSelect(option.id) + onDismiss() + }, + ) + } + } + } + } +} + +/** Name on the left; bitrate and the selected check on the right. */ +@Composable +private fun PlaybackQualityRow( + option: PlaybackQualityOption, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics { selected = isSelected } + .heightIn(min = 46.dp) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = option.name, + color = Color.White, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + option.bitrateLabel?.let { bitrate -> + Text( + text = bitrate, + color = Color.White.copy(alpha = 0.55f), + fontSize = 13.sp, + maxLines = 1, + ) + } + if (isSelected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } else { + Spacer(modifier = Modifier.size(20.dp)) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt index 24bac871d4..8ce53b25d0 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt @@ -79,8 +79,9 @@ import org.siloserver.silo.android.ui.layout.useCompactPlayerToolbar * * Core rows: * - Top: Back (chevron) · title · orientation lock toggle · chapters (when - * present) · tracks (audio + subs) · quality (when multiple versions) · - * settings (gear) + * present) · tracks (audio + subs) · quality (whenever there is a + * playback plan) · settings (gear). Version (when the title has several files) + * lives in the gear sheet and the compact overflow. * - Center: Skip back · play/pause · skip forward * - Bottom: Seek bar with timestamps */ @@ -96,7 +97,9 @@ fun PlayerControls( hasChapters: Boolean, hasTracks: Boolean, // Quality lives on the HUD (chapters + tracks + quality product decision); - // hidden when the item has a single file version. + // hidden only when there is no playback plan (offline downloads). + hasQualityMenu: Boolean, + // The file-version picker; hidden for a single version and in a Watch Party. hasMultipleVersions: Boolean, chapters: List = emptyList(), intro: org.siloserver.silo.model.catalog.TimeRange? = null, @@ -131,6 +134,7 @@ fun PlayerControls( onOpenChapters: () -> Unit, onOpenTracks: () -> Unit, onOpenQuality: () -> Unit, + onOpenVersion: () -> Unit, onOpenSettings: () -> Unit, onSetPlaybackSpeed: (Double) -> Unit = {}, onPlayNextEpisode: () -> Unit = {}, @@ -195,12 +199,14 @@ fun PlayerControls( orientationLockSupported = orientationLockSupported, hasChapters = hasChapters, hasTracks = hasTracks, + hasQualityMenu = hasQualityMenu, hasMultipleVersions = hasMultipleVersions, onBack = onBack, onToggleOrientationLock = onToggleOrientationLock, onOpenChapters = onOpenChapters, onOpenTracks = onOpenTracks, onOpenQuality = onOpenQuality, + onOpenVersion = onOpenVersion, onOpenSettings = onOpenSettings, castSlot = castSlot, ) @@ -287,12 +293,14 @@ private fun PlayerToolbar( orientationLockSupported: Boolean, hasChapters: Boolean, hasTracks: Boolean, + hasQualityMenu: Boolean, hasMultipleVersions: Boolean, onBack: () -> Unit, onToggleOrientationLock: () -> Unit, onOpenChapters: () -> Unit, onOpenTracks: () -> Unit, onOpenQuality: () -> Unit, + onOpenVersion: () -> Unit, onOpenSettings: () -> Unit, castSlot: @Composable () -> Unit, ) { @@ -300,7 +308,7 @@ private fun PlayerToolbar( val trailingActionCount = 3 + (if (orientationLockSupported) 1 else 0) + (if (hasChapters) 1 else 0) + - (if (hasMultipleVersions) 1 else 0) + (if (hasQualityMenu) 1 else 0) val compact = useCompactPlayerToolbar( availableWidthDp = maxWidth.value, trailingActionCount = trailingActionCount, @@ -328,11 +336,13 @@ private fun PlayerToolbar( orientationLockSupported = orientationLockSupported, hasChapters = hasChapters, hasTracks = hasTracks, + hasQualityMenu = hasQualityMenu, hasMultipleVersions = hasMultipleVersions, onToggleOrientationLock = onToggleOrientationLock, onOpenChapters = onOpenChapters, onOpenTracks = onOpenTracks, onOpenQuality = onOpenQuality, + onOpenVersion = onOpenVersion, onOpenSettings = onOpenSettings, ) } else { @@ -341,7 +351,7 @@ private fun PlayerToolbar( orientationLockSupported = orientationLockSupported, hasChapters = hasChapters, hasTracks = hasTracks, - hasMultipleVersions = hasMultipleVersions, + hasQualityMenu = hasQualityMenu, onToggleOrientationLock = onToggleOrientationLock, onOpenChapters = onOpenChapters, onOpenTracks = onOpenTracks, @@ -612,7 +622,7 @@ private fun PlayerToolbarActions( orientationLockSupported: Boolean, hasChapters: Boolean, hasTracks: Boolean, - hasMultipleVersions: Boolean, + hasQualityMenu: Boolean, onToggleOrientationLock: () -> Unit, onOpenChapters: () -> Unit, onOpenTracks: () -> Unit, @@ -644,7 +654,7 @@ private fun PlayerToolbarActions( onClick = onOpenTracks, enabled = hasTracks, ) - if (hasMultipleVersions) { + if (hasQualityMenu) { ControlButton( icon = Icons.Default.HighQuality, contentDescription = "Quality", @@ -665,11 +675,13 @@ private fun PlayerToolbarOverflow( orientationLockSupported: Boolean, hasChapters: Boolean, hasTracks: Boolean, + hasQualityMenu: Boolean, hasMultipleVersions: Boolean, onToggleOrientationLock: () -> Unit, onOpenChapters: () -> Unit, onOpenTracks: () -> Unit, onOpenQuality: () -> Unit, + onOpenVersion: () -> Unit, onOpenSettings: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } @@ -712,7 +724,7 @@ private fun PlayerToolbarOverflow( onOpenTracks() }, ) - if (hasMultipleVersions) { + if (hasQualityMenu) { DropdownMenuItem( text = { Text("Quality") }, onClick = { @@ -721,6 +733,15 @@ private fun PlayerToolbarOverflow( }, ) } + if (hasMultipleVersions) { + DropdownMenuItem( + text = { Text("Version") }, + onClick = { + expanded = false + onOpenVersion() + }, + ) + } DropdownMenuItem( text = { Text("Playback settings") }, onClick = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index ad909a7da4..ae21abedb3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -46,6 +46,8 @@ import androidx.compose.ui.zIndex import org.siloserver.silo.common.ui.LanguageNames import org.siloserver.silo.common.player.SessionState import org.siloserver.silo.common.player.SleepTimerState +import org.siloserver.silo.model.playback.PlaybackQualityOption +import org.siloserver.silo.model.playback.playbackQualityMenu import org.siloserver.silo.model.watchtogether.MemberRole import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.watchtogether.RoomTransportIntent @@ -57,7 +59,7 @@ import org.siloserver.silo.playback.timingActionsFor * Full-screen overlay composable that layers gesture handling, transport controls, * and contextual buttons (skip intro, next episode) on top of the video surface. * - * Also manages bottom sheet display for subtitle, audio, and quality selection. + * Also manages bottom sheet display for subtitle, audio, quality, and version selection. */ @Composable fun PlayerOverlay( @@ -99,7 +101,8 @@ fun PlayerOverlay( // `rememberModalBottomSheetState`, so per-sheet bools are the natural // fit (and the sheets can't be nested anyway). var tracksSheetVisible by remember { mutableStateOf(false) } - var showQualitySelector by remember { mutableStateOf(false) } + var qualitySheetVisible by remember { mutableStateOf(false) } + var versionSheetVisible by remember { mutableStateOf(false) } var settingsSheetVisible by remember { mutableStateOf(false) } var subtitleStyleVisible by remember { mutableStateOf(false) } var sleepTimerVisible by remember { mutableStateOf(false) } @@ -121,6 +124,16 @@ fun PlayerOverlay( // the Skip pill as a room seek, and nobody else sees it (D8). val introPillAllowed = !inRoom || seekEnabled + // The plan's Quality menu, shown whenever there is a plan (a single entry + // still lists Auto + Original). A download playing offline has none. + val qualityOptions = remember(state.playbackPlan) { + playbackQualityMenu(state.playbackPlan?.availableQualities.orEmpty()) + } + val activeQualityId = state.activeQualityId(qualityOptions) + val hasQualityMenu = qualityOptions.isNotEmpty() + // A party plays exactly the room's file: no version picker. + val hasVersionMenu = state.versions.size > 1 && !inRoom + // In a party, Back opens the party panel (PlayerScreen); solo backs out. val handleBack: () -> Unit = onBack val gatedSeek: (Double) -> Unit = { pos -> if (seekEnabled) onSeek(pos) } @@ -386,8 +399,8 @@ fun PlayerOverlay( preview = state.preview, hasChapters = state.chapters.isNotEmpty(), hasTracks = state.subtitleTracks.isNotEmpty() || state.audioTracks.isNotEmpty(), - // A party plays exactly the room's file: no version picker. - hasMultipleVersions = state.versions.size > 1 && !inRoom, + hasQualityMenu = hasQualityMenu, + hasMultipleVersions = hasVersionMenu, isOrientationLocked = isOrientationLocked, orientationLockSupported = orientationLockSupported, tabletopMode = tabletopMode, @@ -413,7 +426,8 @@ fun PlayerOverlay( }, onOpenChapters = { chaptersSheetVisible = true }, onOpenTracks = { tracksSheetVisible = true }, - onOpenQuality = { showQualitySelector = true }, + onOpenQuality = { qualitySheetVisible = true }, + onOpenVersion = { versionSheetVisible = true }, onOpenSettings = { settingsSheetVisible = true }, onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, onPlayNextEpisode = viewModel::playUpNextNow, @@ -610,12 +624,23 @@ fun PlayerOverlay( ) } - if (showQualitySelector && !inRoom) { - QualitySelector( + if (qualitySheetVisible && hasQualityMenu) { + PlaybackQualitySheet( + options = qualityOptions, + activeId = activeQualityId, + inRoom = inRoom, + onSelect = viewModel::onSelectQuality, + onDismiss = { qualitySheetVisible = false }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + if (versionSheetVisible && hasVersionMenu) { + VersionSelector( versions = state.versions, selectedIndex = state.selectedVersionIndex, onSelect = onSelectVersion, - onDismiss = { showQualitySelector = false }, + onDismiss = { versionSheetVisible = false }, tabletopPaneHeight = tabletopPaneHeight, ) } @@ -640,11 +665,17 @@ fun PlayerOverlay( onSetDolbyVisionEnabled = viewModel::onSetDolbyVisionEnabled, // A party hides speed (session-only 1x) and the version picker. showPlaybackSpeed = !inRoom, - showQuality = !inRoom, - qualityLabel = playerQualityLabel(state.versions, state.selectedVersionIndex), + showQuality = hasQualityMenu, + qualityLabel = playerQualityLabel(qualityOptions, activeQualityId), onOpenQuality = { settingsSheetVisible = false - showQualitySelector = true + qualitySheetVisible = true + }, + showVersion = hasVersionMenu, + versionLabel = playerVersionLabel(state.versions, state.selectedVersionIndex), + onOpenVersion = { + settingsSheetVisible = false + versionSheetVisible = true }, audioLabel = playerAudioLabel(state.audioTracks, state.selectedAudioIndex), subtitleLabel = playerSubtitleLabel(state.subtitleTracks, state.selectedSubtitleIndex), @@ -787,11 +818,14 @@ internal fun mobileVideoGravityLabel(value: String): String = when (value) { } /** - * Root-list values for the gear menu. These mirror what the quality and - * tracks sheets show when opened, so the menu can state the current pick + * Root-list values for the gear menu. These mirror what the quality, version, + * and tracks sheets show when opened, so the menu can state the current pick * without the user having to open anything. */ -internal fun playerQualityLabel( +internal fun playerQualityLabel(options: List, activeId: String?): String = + options.firstOrNull { it.id == activeId }?.name ?: "Auto" + +internal fun playerVersionLabel( versions: List, selectedIndex: Int, ): String { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt index b54277e3db..8a5dbd930b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt @@ -54,8 +54,8 @@ import org.siloserver.silo.common.player.SleepTimerState /** * Where the sheet currently is. The gear opens [Root] — a flat list of the - * four things worth reading at a glance (quality, subtitles, audio) plus one - * door to everything else, the shape Plex uses. Sub-screens replace the sheet + * things worth reading at a glance (quality, version, subtitles, audio) plus + * one door to everything else, the shape Plex uses. Sub-screens replace the sheet * content in place rather than stacking new sheets, so there is only ever one * surface over the video. * @@ -111,15 +111,20 @@ fun PlayerSettingsSheet( onSetHdrEnabled: (Boolean) -> Unit, dolbyVisionEnabled: Boolean, onSetDolbyVisionEnabled: (Boolean) -> Unit, - // False in a Watch Party: speed is a session-only 1x and the version - // picker is off, since the room plays exactly its own file. + // False in a Watch Party: speed is a session-only 1x. showPlaybackSpeed: Boolean = true, + // Shown whenever there is a playback plan, in a Watch Party too. showQuality: Boolean = true, - // Root-list values. The gear now reports the same three things the - // toolbar buttons open, so the menu answers "what am I watching with?" - // without opening anything. + // Shown for a title with several files, except in a Watch Party, which + // plays exactly the room's file. + showVersion: Boolean = false, + // Root-list values. The gear reports the same things the toolbar + // buttons open, so the menu answers "what am I watching with?" without + // opening anything. qualityLabel: String = "", onOpenQuality: () -> Unit = {}, + versionLabel: String = "", + onOpenVersion: () -> Unit = {}, audioLabel: String = "", subtitleLabel: String = "", onOpenTracks: () -> Unit = {}, @@ -183,6 +188,13 @@ fun PlayerSettingsSheet( onClick = { leaveFor(onOpenQuality) }, ) } + if (showVersion) { + ValueRow( + label = "Version", + value = versionLabel, + onClick = { leaveFor(onOpenVersion) }, + ) + } ValueRow( label = "Subtitles", value = subtitleLabel, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 939f7f0aa5..65b023e2c3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -65,6 +65,7 @@ import org.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.model.playback.PlayMethod import org.siloserver.silo.model.playback.PlaybackDelivery import org.siloserver.silo.model.playback.PlaybackExecutionPlan +import org.siloserver.silo.model.playback.playbackQualityMenu import org.siloserver.silo.model.playback.executableMedia3ClientTransformations import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.model.playback.CommittedSubtitle @@ -437,6 +438,8 @@ class PlayerViewModel( val sessionId: String? = null, val playMethod: PlayMethod? = null, val playbackPlan: PlaybackExecutionPlan? = null, + /** The quality preference the playing plan was built for; null is Auto. */ + val committedQualityPreference: String? = null, val requestHeaders: Map = emptyMap(), val delivery: PlaybackDelivery? = null, val streamUrl: String? = null, @@ -1194,11 +1197,23 @@ class PlayerViewModel( normalizedRequestedQuality = normalizedPreferredQuality, preserveCurrent = preserveRouteIntent, ) + // A quality picked during this title's playback, or this room epoch, + // outlives reloads of it (recovery, retry, the next shuffle part), so + // they start with it rather than the route's quality. + val carriesPickedQuality = keepsPickedQuality( + contentId = contentId, + previousContentId = lastLoadArgs?.contentId, + routeNamesQuality = !preserveRouteIntent && normalizedPreferredQuality != null, + room = room, + currentRoom = currentRoomContext, + ) + if (room == null && !carriesPickedQuality) sessionQualityOverride = null + val loadQuality = sessionQualityOverride.takeIf { carriesPickedQuality } ?: effectivePreferredQuality loadJob?.cancel() val loadOwner = loadOwners.begin( contentId = contentId, preferredFileId = preferredFileId, - preferredQuality = effectivePreferredQuality, + preferredQuality = loadQuality, ) // 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). @@ -1276,7 +1291,7 @@ class PlayerViewModel( libraryId = libraryId, contentId = contentId, preferredFileId = preferredFileId, - preferredQualityOverride = effectivePreferredQuality, + preferredQualityOverride = loadQuality, roomId = room?.roomId, resumePositionOverride = resumePositionOverride, audioTrackIndex = initialAudioTrackIndex, @@ -3317,7 +3332,8 @@ class PlayerViewModel( private var roomObservedSessionId: String? = null private var roomLastUiSessionId: String? = null - // Quality chosen during this room epoch (the lower-quality offer). + // Quality chosen during this title's playback, or this room epoch's (the + // Quality menu or the lower-quality offer). private var sessionQualityOverride: String? = null /** @@ -3510,10 +3526,27 @@ class PlayerViewModel( return true } - private fun noteRoomQualityCommitted(committed: String?) { + /** + * Quality menu pick: replans at [id], a plan entry's label sent verbatim, + * or auto to hand the choice back to the server. The active row is a no-op. + */ + fun onSelectQuality(id: String) { + val state = _uiState.value + val menu = playbackQualityMenu(state.playbackPlan?.availableQualities.orEmpty()) + if (menu.none { it.id == id }) return + if (id == state.activeQualityId(menu)) return + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) + mobileSubtitleTransactions.selectQuality(id) + } + + /** + * Keeps a committed quality for later replans, seek recoveries and + * restarts of this title, which otherwise fall back to the route's. + */ + private fun noteQualityCommitted(committed: String?) { if (committed == null || committed == currentMobileQualityPreference()) return sessionQualityOverride = committed - _roomQualityChanges.tryEmit(Unit) + if (inRoom) _roomQualityChanges.tryEmit(Unit) } private fun roomMediaTransitioning(state: PlayerUiState): Boolean = @@ -3681,6 +3714,7 @@ class PlayerViewModel( pendingSubtitleIdentity = snapshot.pendingIdentity, localSubtitleMountIdentity = snapshot.localMountIdentity, subtitleApplying = snapshot.subtitleApplying, + committedQualityPreference = snapshot.transition.committed.qualityPreference, ) } val state = _uiState.value @@ -3692,9 +3726,15 @@ class PlayerViewModel( transactionActive = mobileSubtitleTransactions.hasActiveTransaction, ) snapshot.failureMessage?.let { - showVersionSwitchMessage("Couldn't apply subtitles — playback continues unchanged.") + showVersionSwitchMessage( + if (snapshot.qualityChangeFailed) { + "Couldn't change quality. Try again." + } else { + "Couldn't apply subtitles — playback continues unchanged." + }, + ) } - if (inRoom) noteRoomQualityCommitted(snapshot.transition.committed.qualityPreference) + noteQualityCommitted(snapshot.transition.committed.qualityPreference) if (!mobileSubtitleTransactions.hasActiveTransaction) { redriveQueuedInvalidationReplan() } @@ -5170,6 +5210,9 @@ class PlayerViewModel( routeIntentState.recoverVersionSelection(state.contentId) } else { routeIntentState.beginVersionSelection(state.contentId, version.fileId) + // Choosing a file drops a picked quality: a quality served by + // another version would move playback straight off this one. + sessionQualityOverride = null } val currentVersion = state.versions.getOrNull(state.selectedVersionIndex) val carriedAudioIndex = desiredAudio @@ -5739,3 +5782,23 @@ internal fun authoritativePlaybackSubtitleOrdinal( else -> playbackTracks.indexOfFirst { it.index == serverIndex } .takeIf { it >= 0 } } + +/** + * Whether a quality picked earlier carries into this load. It does for a + * reload of the same title outside a room, unless the route itself names a + * quality, and for a reload inside the same room epoch. Any other title or + * room epoch starts from the route's quality. + */ +internal fun keepsPickedQuality( + contentId: String, + previousContentId: String?, + routeNamesQuality: Boolean, + room: WatchPartyPlaybackContext?, + currentRoom: WatchPartyPlaybackContext?, +): Boolean = if (room == null) { + contentId == previousContentId && !routeNamesQuality +} else { + currentRoom != null && + currentRoom.roomId == room.roomId && + currentRoom.selectionRevision == room.selectionRevision +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/QualitySelector.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/VersionSelector.kt similarity index 92% rename from androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/QualitySelector.kt rename to androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/VersionSelector.kt index 49fc72509e..760c2260df 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/QualitySelector.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/VersionSelector.kt @@ -29,12 +29,13 @@ import androidx.compose.ui.unit.dp import org.siloserver.silo.model.catalog.FileVersion /** - * Bottom sheet for selecting a file version (quality/resolution). + * Bottom sheet for selecting which file version of the title plays. * Shows resolution, codec, HDR badge, and file size for each version. + * The server's streaming qualities are a separate menu, [PlaybackQualitySheet]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun QualitySelector( +fun VersionSelector( versions: List, selectedIndex: Int, onSelect: (Int) -> Unit, @@ -58,8 +59,8 @@ fun QualitySelector( .padding(bottom = 32.dp), ) { PlayerSheetHeader( - title = "Quality", - subtitle = "Choose a source version", + title = "Version", + subtitle = "Choose which file to play", onDismiss = onDismiss, ) @@ -68,7 +69,7 @@ fun QualitySelector( LazyColumn { itemsIndexed( versions, - contentType = { _, _ -> "quality-version" }, + contentType = { _, _ -> "file-version" }, ) { index, version -> val label = buildString { version.editionLabel?.let { append(it).append(" · ") } @@ -88,7 +89,7 @@ fun QualitySelector( } }.ifEmpty { null } - QualityOptionRow( + VersionOptionRow( label = label, detail = detail, isSelected = selectedIndex == index, @@ -104,7 +105,7 @@ fun QualitySelector( } @Composable -private fun QualityOptionRow( +private fun VersionOptionRow( label: String, detail: String?, isSelected: Boolean, diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileQualityMenuTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileQualityMenuTest.kt new file mode 100644 index 0000000000..0481c1edb7 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileQualityMenuTest.kt @@ -0,0 +1,69 @@ +package org.siloserver.silo.android.ui.screens.player + +import org.siloserver.silo.model.playback.PlaybackAvailableQualityV3 +import org.siloserver.silo.model.playback.PlaybackDelivery +import org.siloserver.silo.model.playback.PlaybackEffectiveRecipeV3 +import org.siloserver.silo.model.playback.PlaybackExecutionPlan +import org.siloserver.silo.model.playback.PlaybackRouteFamily +import org.siloserver.silo.model.playback.playbackQualityMenu +import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MobileQualityMenuTest { + private val ladder = listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), + ) + + private fun state(recipe: PlaybackEffectiveRecipeV3?) = PlayerViewModel.PlayerUiState( + playbackPlan = PlaybackExecutionPlan( + planId = "plan", + delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS, + routeFamily = PlaybackRouteFamily.SERVER_ADAPTIVE, + availableQualities = ladder, + effectiveRecipe = recipe, + ), + committedQualityPreference = "1080p", + ) + + @Test + fun storedResolutionPreferenceMarksTheEntryTheCappedPlanDelivers() { + val menu = playbackQualityMenu(ladder) + + val capped = state(PlaybackEffectiveRecipeV3(width = 1280, height = 720, bitrateKbps = 4_000)) + assertEquals("720p-medium", capped.activeQualityId(menu)) + + val uncapped = state(PlaybackEffectiveRecipeV3(width = 1920, height = 1080, bitrateKbps = 8_000)) + assertEquals("original", uncapped.activeQualityId(menu)) + } + + @Test + fun pickedQualityCarriesIntoSameTitleReloadsOnly() { + assertTrue(keepsPickedQuality("movie", "movie", routeNamesQuality = false, room = null, currentRoom = null)) + assertFalse(keepsPickedQuality("other", "movie", routeNamesQuality = false, room = null, currentRoom = null)) + assertFalse(keepsPickedQuality("movie", null, routeNamesQuality = false, room = null, currentRoom = null)) + // A route that names its own quality wins over an earlier pick. + assertFalse(keepsPickedQuality("movie", "movie", routeNamesQuality = true, room = null, currentRoom = null)) + } + + @Test + fun pickedQualityCarriesIntoRestartsOfTheSameRoomEpochOnly() { + val room = WatchPartyPlaybackContext( + roomId = "room", + selectionRevision = 3, + contentId = "movie", + fileId = 41, + libraryId = null, + positionSeconds = 0.0, + paused = true, + ) + + assertTrue(keepsPickedQuality("movie", "movie", false, room = room.copy(positionSeconds = 90.0), currentRoom = room)) + assertFalse(keepsPickedQuality("movie", "movie", false, room = room.copy(selectionRevision = 4), currentRoom = room)) + assertFalse(keepsPickedQuality("movie", "movie", false, room = room, currentRoom = null)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt index ab18e4eee4..c7b5b2e28d 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt @@ -747,6 +747,27 @@ class MobileSubtitleTransactionAdapterTest { assertEquals(listOf(SubtitleIdentity.Off), harness.persistence.persisted.map { it.identity }) } + @Test + fun `failed quality change is reported apart from subtitle failures`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.selectQuality("720p-medium") + runCurrent() + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("offline"))) + runCurrent() + + assertTrue(harness.adapter.snapshot.failureMessage != null) + assertTrue(harness.adapter.snapshot.qualityChangeFailed) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.failStage(ApiResult.NetworkError(IllegalStateException("offline"))) + runCurrent() + + assertTrue(harness.adapter.snapshot.failureMessage != null) + assertFalse(harness.adapter.snapshot.qualityChangeFailed) + } + @Test fun `missing sidecar and network failure retain committed selection and preference`() = runTest { val harness = harness(backgroundScope) diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index 3000402da3..a7a55f0ca0 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -255,6 +255,33 @@ class PlayerViewModelLoadOwnershipIntegrationTest { } } + @Test + fun sameTitleReloadsStartWithThePickedQualityAndAnotherTitleDropsIt() = runTest(dispatcher) { + val starter = DeferredNonCooperativeStarter() + val fixture = playerViewModel(starter, backgroundScope) + val store = ViewModelStore().also { it.put("player", fixture.viewModel) } + try { + val viewModel = fixture.viewModel + viewModel.loadContent(contentId = "movie", preferredFileId = 1) + starter.awaitRequestCount(1) + assertNull(starter.request(0).preferredQualityOverride) + + // What a committed Quality menu pick leaves behind. + viewModel.setPickedQuality("720p-medium") + starter.complete(0, VideoPlaybackStartResult.ServerUnreachable(contentId = "movie")) + viewModel.awaitState { it.serverUnreachable } + viewModel.playIgnoringServerReachability() + starter.awaitRequestCount(2) + assertEquals("720p-medium", starter.request(1).preferredQualityOverride) + + viewModel.loadContent(contentId = "other", preferredFileId = 2) + starter.awaitRequestCount(3) + assertNull(starter.request(2).preferredQualityOverride) + } finally { + store.clear() + } + } + @Test fun staleErrorCannotOverwriteCurrentReady() = runTest(dispatcher) { val starter = DeferredNonCooperativeStarter() @@ -690,6 +717,13 @@ class PlayerViewModelLoadOwnershipIntegrationTest { it.get(this) as MutableStateFlow } + private fun PlayerViewModel.setPickedQuality(quality: String) { + PlayerViewModel::class.java.getDeclaredField("sessionQualityOverride").let { + it.isAccessible = true + it.set(this, quality) + } + } + private fun PlayerViewModel.offerNextEpisode() { mutableUiState().update { it.copy(nextEpisode = PlayerViewModel.NextEpisodeInfo( contentId = "episode-b", seasonNumber = 1, episodeNumber = 2, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt index 9ba5160319..34c80362d9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt @@ -1065,7 +1065,9 @@ private fun HudVideoPane( // actually focusable. A disabled row is not focusable, so pointing the // requester at it would cancel the move in from the rail. val hasVersionRow = fileVersions.size > 1 - val hasQualityRow = videoQualities.size > 1 + // The quality menu exists whenever a plan does: even a single entry + // lists Auto + Original. + val hasQualityRow = videoQualities.isNotEmpty() val entryRow = when { hasVersionRow -> "version" hasQualityRow -> "quality" @@ -1084,14 +1086,6 @@ private fun HudVideoPane( .verticalScroll(rememberScrollState()), ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - // Quality — derived from the real per-format video variants - // (resolution / bitrate) flattened from the video group. This is - // a genuine Media3 track override (setOverrideForType on the - // video group), not a no-op. When there is only one real variant - // (Auto + 0 or 1 format) there is nothing to switch, so the row - // is shown disabled with an "Auto" value rather than faking it. - // (videoQualities, when present, always contains a synthetic - // "Auto" entry, so a genuine choice means size > 2.) // Version — the server's file versions (4K / 1080p encodes). // Switching restarts the session on that file at the current // position (QA 2026-07-08 / tvOS parity). @@ -1128,30 +1122,30 @@ private fun HudVideoPane( ) } - // The server-transcode quality ladder always offers at least - // Auto + Original (plus downscale rungs below the source), so the - // row is enabled whenever there is more than one option. - val hasQualityChoice = videoQualities.size > 1 - val selectedQuality = videoQualities.firstOrNull { it.isSelected } - val qualityValue = selectedQuality?.label ?: "Auto" - HudFocusedSettingRow( - label = "Quality", - value = qualityValue, - enabled = enabled && hasQualityChoice, - entryFocusRequester = entryFocusRequester.takeIf { entryRow == "quality" }, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Quality", - options = videoQualities.map { - HudPickerOption(id = it.id, label = it.label) - }, - selectedId = (selectedQuality?.id ?: VIDEO_QUALITY_AUTO_ID), - onSelect = { id -> onSelectVideoQuality(id) }, - ), - ) - }, - ) + // Quality — the plan's quality menu: Auto, then the server's + // entries in its order, each re-planned on the server when + // picked. Hidden only when there is no plan. + if (hasQualityRow) { + val selectedQuality = videoQualities.firstOrNull { it.isSelected } + HudFocusedSettingRow( + label = "Quality", + value = selectedQuality?.label ?: "Auto", + enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "quality" }, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Quality", + options = videoQualities.map { + HudPickerOption(id = it.id, label = it.label, trailing = it.bitrateLabel) + }, + selectedId = selectedQuality?.id.orEmpty(), + onSelect = { id -> onSelectVideoQuality(id) }, + ), + ) + }, + ) + } if (speedRowVisible) { HudFocusedSettingRow( @@ -2294,6 +2288,8 @@ internal data class HudPickerOption( val colorHex: String? = null, /** A short second line under the label, such as a subtitle's sync status. */ val detail: String? = null, + /** Secondary text at the row's end, before the check, such as a bitrate. */ + val trailing: String? = null, ) /** @@ -2658,6 +2654,14 @@ private fun HudPickerOptionRow( ) } } + option.trailing?.let { trailing -> + Text( + text = trailing, + color = fg.copy(alpha = 0.62f), + style = MaterialTheme.typography.bodyMedium.copy(fontSize = HudMetaTextSize), + maxLines = 1, + ) + } if (isSelected) { Icon( imageVector = Icons.Filled.Check, @@ -2665,6 +2669,9 @@ private fun HudPickerOptionRow( tint = fg, modifier = Modifier.size(10.dp), ) + } else if (option.trailing != null) { + // Keeps trailing text aligned with the selected row's. + Spacer(modifier = Modifier.size(10.dp)) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index e6dc7bf2a0..d4f145c919 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -3452,20 +3452,22 @@ private fun Format.subtitleCodecOrMime(): String? = } /** - * A selectable video quality variant. Unlike [extractTrackEntries] (which - * collapses every video group to a single group-level entry), this flattens the - * individual formats *inside* the video group(s) — the real resolution / bitrate - * variants of the stream — so the HUD Quality picker can surface genuine - * options. [id] encodes `":"`; the synthetic `"-1"` - * id means Auto (adaptive — clears any override). + * A row of the HUD Quality picker, built from the plan's quality menu by + * [authoritativePlaybackQualityOptions]. [id] is the `quality_preference` a + * pick sends: a server entry's label, or `auto`. */ data class VideoQualityOption( val id: String, val label: String, val isSelected: Boolean, val resolution: String? = null, + val bitrateLabel: String? = null, ) +/** + * Silo Cast's Auto id from before the menu carried its own `auto` row: still + * accepted from remotes, and reported when no row is selected. + */ internal const val VIDEO_QUALITY_AUTO_ID = "-1" internal fun resizeModeForVideoFillMode(mode: VideoFillMode): Int = when (mode) { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 11aeca9b74..1e0ec9df4f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -90,6 +90,9 @@ import org.siloserver.silo.model.playback.resolveAutoSubtitle import org.siloserver.silo.model.playback.selectedCandidate import org.siloserver.silo.model.playback.PlaybackDelivery import org.siloserver.silo.model.playback.PlaybackAvailableQualityV3 +import org.siloserver.silo.model.playback.PlaybackEffectiveRecipeV3 +import org.siloserver.silo.model.playback.activePlaybackQualityId +import org.siloserver.silo.model.playback.playbackQualityMenu import org.siloserver.silo.model.playback.PlayMethod import org.siloserver.silo.model.playback.ClientCodecCapabilities import org.siloserver.silo.model.playback.ClientPlaybackContext @@ -194,17 +197,26 @@ internal fun PlayerTrackEntry.toMountedAudioTrack(): MountedAudioTrack = Mounted label = displayLabel.ifBlank { label }, ) -/** Projects the protocol-v3 quality menu verbatim, preserving server order. */ +/** + * The protocol-v3 Quality menu: Auto, then the server's entries in server + * order, with the row [selectedLabel] resolves to marked selected. + */ internal fun authoritativePlaybackQualityOptions( available: List, selectedLabel: String?, -): List = available.map { quality -> - VideoQualityOption( - id = quality.label, - label = quality.displayName?.takeIf { it.isNotBlank() } ?: quality.label, - isSelected = quality.label == selectedLabel, - resolution = quality.height.takeIf { it > 0 }?.let { "${it}p" }, - ) + delivered: PlaybackEffectiveRecipeV3? = null, +): List { + val menu = playbackQualityMenu(available) + val activeId = activePlaybackQualityId(menu, selectedLabel, delivered) + return menu.map { option -> + VideoQualityOption( + id = option.id, + label = option.name, + isSelected = option.id == activeId, + resolution = option.height.takeIf { it > 0 }?.let { "${it}p" }, + bitrateLabel = option.bitrateLabel, + ) + } } internal fun clampTvScrubPreview(seconds: Double, duration: Double): Double = @@ -1851,6 +1863,7 @@ class TvPlayerViewModel( videoQualities = authoritativePlaybackQualityOptions( available = ready.plan.availableQualities, selectedLabel = adoption.committed.qualityPreference, + delivered = ready.plan.effectiveRecipe, ), container = ready.plan.stream.container ?: version?.container ?: state.container, duration = duration, @@ -2313,6 +2326,7 @@ class TvPlayerViewModel( selectedLabel = qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue, + delivered = result.playbackPlanV3?.effectiveRecipe, ), mediaFileId = result.mediaFileId, startPosition = result.startPositionSeconds, @@ -2811,6 +2825,7 @@ class TvPlayerViewModel( ?: qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue, + delivered = decision.plan.effectiveRecipe, ), container = effectiveContainer, duration = effectiveDuration, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt index fd4c6cd805..68f875982e 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt @@ -1,9 +1,9 @@ package org.siloserver.silo.tv.ui.screens.player import org.siloserver.silo.model.playback.PlaybackAvailableQualityV3 +import org.siloserver.silo.model.playback.PlaybackEffectiveRecipeV3 import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertTrue class TvPlaybackQualityOptionsTest { @@ -12,41 +12,76 @@ class TvPlaybackQualityOptionsTest { val options = authoritativePlaybackQualityOptions( listOf( PlaybackAvailableQualityV3("1080p-medium", displayName = "1080p Medium"), - PlaybackAvailableQualityV3("original", displayName = " "), + PlaybackAvailableQualityV3("original", displayName = " ", preservesSource = true), ), selectedLabel = "1080p-medium", ) - assertEquals(listOf("1080p-medium", "original"), options.map { it.id }) - assertEquals(listOf("1080p Medium", "original"), options.map { it.label }) - assertTrue(options.first().isSelected) + assertEquals(listOf("auto", "1080p-medium", "original"), options.map { it.id }) + assertEquals(listOf("Auto", "1080p Medium", "Original"), options.map { it.label }) + assertEquals(listOf("1080p-medium"), options.filter { it.isSelected }.map { it.id }) } @Test - fun authoritativeMenuPreservesServerMembershipOrderAndLabels() { + fun menuPutsAutoFirstThenServerEntriesWithBitrates() { val options = authoritativePlaybackQualityOptions( available = listOf( - PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), - PlaybackAvailableQualityV3("720p", 720, 4_000), + PlaybackAvailableQualityV3("original", 2160, 50_700, preservesSource = true), + PlaybackAvailableQualityV3("1080p", 1080, 12_000, displayName = "1080p"), + PlaybackAvailableQualityV3("720p-low", 720, 1_500, displayName = "720p Low"), ), - selectedLabel = "720p", + selectedLabel = "1080p", ) - assertEquals(listOf("original", "720p"), options.map { it.id }) - assertEquals(listOf("original", "720p"), options.map { it.label }) - assertFalse(options.first().isSelected) - assertTrue(options.last().isSelected) + assertEquals(listOf("auto", "original", "1080p", "720p-low"), options.map { it.id }) + assertEquals(listOf("Auto", "Original", "1080p", "720p Low"), options.map { it.label }) + assertEquals(listOf(null, "50.7 Mbps", "12 Mbps", "1.5 Mbps"), options.map { it.bitrateLabel }) + assertEquals(listOf(null, "2160p", "1080p", "720p"), options.map { it.resolution }) + assertEquals(listOf("1080p"), options.filter { it.isSelected }.map { it.id }) } @Test - fun autoIntentDoesNotInventASelectedServerRow() { + fun autoIntentSelectsTheAutoRowNotAServerEntry() { val options = authoritativePlaybackQualityOptions( available = listOf( PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), ), selectedLabel = "auto", ) - assertEquals(listOf("original"), options.map { it.id }) - assertFalse(options.single().isSelected) + assertEquals(listOf("auto"), options.filter { it.isSelected }.map { it.id }) + } + + @Test + fun storedResolutionPreferenceMarksTheEntryThePlannerUsed() { + val options = authoritativePlaybackQualityOptions( + available = listOf( + PlaybackAvailableQualityV3("original", 2160, 40_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), + ), + selectedLabel = "1080p", + delivered = PlaybackEffectiveRecipeV3(width = 1920, height = 1080, bitrateKbps = 6_000), + ) + + assertEquals(listOf("1080p-medium"), options.filter { it.isSelected }.map { it.id }) + } + + @Test + fun aSingleEntryListsAutoAndOriginal() { + val available = listOf(PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true)) + + val auto = authoritativePlaybackQualityOptions(available, selectedLabel = "auto") + assertEquals(listOf("auto", "original"), auto.map { it.id }) + assertEquals(listOf("Auto", "Original"), auto.map { it.label }) + assertEquals(listOf("auto"), auto.filter { it.isSelected }.map { it.id }) + + val pinned = authoritativePlaybackQualityOptions(available, selectedLabel = "720p-medium") + assertEquals(listOf("original"), pinned.filter { it.isSelected }.map { it.id }) + } + + @Test + fun noPlanEntriesMeansNoMenu() { + assertTrue(authoritativePlaybackQualityOptions(emptyList(), selectedLabel = "auto").isEmpty()) } } diff --git a/docs/watch-party-implementation-plan.md b/docs/watch-party-implementation-plan.md index 8ccf427d8f..ceb14c4615 100644 --- a/docs/watch-party-implementation-plan.md +++ b/docs/watch-party-implementation-plan.md @@ -467,7 +467,7 @@ dropped silently nor queued ([Apple #410](https://github.com/Silo-Server/silo-ap | SiloCast launch request | Replaces the party player with solo playback | D5 prompt | | Playback-control socket commands | Admin Stop is classified as transport and rejected | Transport follows the room; admin Stop and terminate end the local engagement (for a host, the 2-minute grace starts) | | `PlaybackDebugReceiver` play, pause, seek | Call Media3 directly | Fault injection only; never evidence of room behavior | -| Version and edition picker (phone Quality selector, TV HUD) | Enabled; picks another file | Disabled in rooms. Quality means the ladder of the same file | +| Version and edition picker (phone Version sheet, TV HUD) | Enabled; picks another file | Disabled in rooms. Quality means the ladder of the same file | | Speed menu and hold-to-2x | Saved speed applied in rooms | Hidden in rooms; session-only 1x; restore the preference on exit | | Autoplay, next-up, postroll | Autoplay already suppressed | Suppress; optional host Next Episode (Scope) | diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt index cda0f67850..72e4027afa 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackModels.kt @@ -249,6 +249,11 @@ data class PlaybackExecutionPlan( @SerialName("decision_trace") val decisionTrace: List = emptyList(), @SerialName("requested_media_file_id") val requestedMediaFileId: Int? = null, @SerialName("effective_media_file_id") val effectiveMediaFileId: Int? = null, + /** + * What the plan delivers (resolution, bitrate). The Quality menu reads it + * to mark the entry a stored resolution preference actually landed on. + */ + @SerialName("effective_recipe") val effectiveRecipe: PlaybackEffectiveRecipeV3? = null, ) /** diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenu.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenu.kt new file mode 100644 index 0000000000..1c4eaceca3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenu.kt @@ -0,0 +1,120 @@ +package org.siloserver.silo.model.playback + +/** The quality preference that hands the choice back to the server's planner. */ +const val PLAYBACK_QUALITY_AUTO = "auto" + +/** + * One row of the player's Quality menu. [id] is the `quality_preference` a + * pick sends back verbatim: a plan entry's own label, or + * [PLAYBACK_QUALITY_AUTO]. + */ +data class PlaybackQualityOption( + val id: String, + val name: String, + /** The entry's bitrate as the row's secondary text, or null when unknown. */ + val bitrateLabel: String? = null, + val height: Int = 0, + val bitrateKbps: Int = 0, + val preservesSource: Boolean = false, +) + +/** + * The plan's Quality menu: Auto, then every entry of + * [PlaybackPlanV3.availableQualities] in server order. The server owns the + * entries and the client never derives or filters them. Auto is added here + * because it is a preference, not an entry. A plan with a single entry still + * gets Auto + that entry, as the Apple clients show; only a plan with no + * entries (no plan at all) gets no menu. + */ +fun playbackQualityMenu(available: List): List { + val entries = available.map { quality -> + PlaybackQualityOption( + id = quality.label, + name = playbackQualityName(quality), + bitrateLabel = formatPlaybackQualityBitrate(quality.bitrateKbps), + height = quality.height, + bitrateKbps = quality.bitrateKbps, + preservesSource = quality.preservesSource, + ) + } + if (entries.isEmpty()) return entries + return listOf(PlaybackQualityOption(id = PLAYBACK_QUALITY_AUTO, name = "Auto")) + entries +} + +/** The entry's display name, else "Original" for the source entry, else its label. */ +fun playbackQualityName(quality: PlaybackAvailableQualityV3): String = + quality.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: if (quality.preservesSource) "Original" else quality.label + +/** + * A bitrate in Mbps to one decimal, dropping a zero decimal: "40 Mbps", + * "1.5 Mbps". Below 1 Mbps it stays in kbps. Null when the bitrate is unknown. + */ +fun formatPlaybackQualityBitrate(kbps: Int): String? { + if (kbps <= 0) return null + if (kbps < 1_000) return "$kbps kbps" + val tenths = (kbps + 50) / 100 + val fraction = tenths % 10 + return if (fraction == 0) "${tenths / 10} Mbps" else "${tenths / 10}.$fraction Mbps" +} + +/** + * The id of the [options] row that [preference] selects, or null when none + * does. A missing preference is Auto. When the plan has a sole server entry, + * any other preference selects it, since it is what plays. A stored + * resolution-only preference such as "1080p" resolves the way the planner + * applies it: to the source entry when the source fits under it and no + * bitrate cap reduced it ([delivered] shows that), otherwise to that + * resolution class's Medium entry. + */ +fun activePlaybackQualityId( + options: List, + preference: String?, + delivered: PlaybackEffectiveRecipeV3? = null, +): String? { + val normalized = preference?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: PLAYBACK_QUALITY_AUTO + options.firstOrNull { it.id.lowercase() == normalized }?.let { return it.id } + options.singleOrNull { it.id != PLAYBACK_QUALITY_AUTO }?.let { return it.id } + + val original = options.firstOrNull { it.preservesSource } + val aliasHeight = qualityPreferenceHeight(normalized) + if (aliasHeight == null) { + return if (normalized == "source" || normalized == "max") original?.id else null + } + + val deliveredKbps = delivered?.bitrateKbps?.takeIf { it > 0 } + val capped = deliveredKbps != null && deliveredKbps < (original?.bitrateKbps ?: 0) + if (original != null && !capped && original.height <= aliasHeight) return original.id + + val classHeight = ladderClassHeight(delivered?.width, delivered?.height) ?: aliasHeight + val rungId = when (classHeight) { + 480 -> "480p" + // A low bitrate cap can choose the 540p class, which has no menu entry. + 540 -> null + else -> "${classHeight}p-medium" + } + return options.firstOrNull { it.id == rungId }?.id +} + +private fun qualityPreferenceHeight(preference: String): Int? = when (preference) { + "2160p", "4k", "uhd" -> 2160 + "1080p", "fhd" -> 1080 + "720p", "hd" -> 720 + "480p", "sd" -> 480 + else -> null +} + +/** The server's bitrate ladder classes as (width, height) boxes, smallest first. */ +private val LADDER_CLASS_BOXES = listOf(854 to 480, 960 to 540, 1280 to 720, 1920 to 1080, 3840 to 2160) + +/** + * The ladder class an encoded frame belongs to: the smallest box that holds + * it, so a 1280x688 encode is 720p. A frame larger than every box is 2160p. + */ +private fun ladderClassHeight(width: Int?, height: Int?): Int? { + if (height == null || height <= 0) return null + val knownWidth = width?.takeIf { it > 0 } + return LADDER_CLASS_BOXES.firstOrNull { (boxWidth, boxHeight) -> + height <= boxHeight && (knownWidth == null || knownWidth <= boxWidth) + }?.second ?: 2160 +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenuTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenuTest.kt new file mode 100644 index 0000000000..32aff14dce --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/PlaybackQualityMenuTest.kt @@ -0,0 +1,136 @@ +package org.siloserver.silo.model.playback + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PlaybackQualityMenuTest { + private val ladder = listOf( + PlaybackAvailableQualityV3("original", 2160, 50_700, preservesSource = true), + PlaybackAvailableQualityV3("1080p", 1080, 12_000, displayName = "1080p"), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000, displayName = "1080p Medium"), + PlaybackAvailableQualityV3("720p-low", 720, 1_500, displayName = "720p Low"), + ) + + @Test + fun menuListsAutoThenEveryServerEntryInServerOrder() { + val menu = playbackQualityMenu(ladder) + + assertEquals(listOf("auto", "original", "1080p", "1080p-medium", "720p-low"), menu.map { it.id }) + assertEquals(listOf("Auto", "Original", "1080p", "1080p Medium", "720p Low"), menu.map { it.name }) + assertEquals( + listOf(null, "50.7 Mbps", "12 Mbps", "6 Mbps", "1.5 Mbps"), + menu.map { it.bitrateLabel }, + ) + } + + @Test + fun aSingleEntryStillGetsAutoAndNoEntriesGetNoMenu() { + val sole = listOf(PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true)) + + assertEquals(listOf("auto", "original"), playbackQualityMenu(sole).map { it.id }) + assertEquals(listOf("Auto", "Original"), playbackQualityMenu(sole).map { it.name }) + assertEquals(emptyList(), playbackQualityMenu(emptyList())) + } + + @Test + fun namePrefersTheServerDisplayNameThenOriginalThenTheLabel() { + assertEquals("4K High", playbackQualityName(PlaybackAvailableQualityV3("2160p-high", displayName = "4K High"))) + assertEquals("Original", playbackQualityName(PlaybackAvailableQualityV3("original", preservesSource = true))) + assertEquals("Original", playbackQualityName(PlaybackAvailableQualityV3("original", displayName = " ", preservesSource = true))) + assertEquals("480p", playbackQualityName(PlaybackAvailableQualityV3("480p"))) + } + + @Test + fun bitrateIsMbpsToOneDecimalAndOmittedWhenUnknown() { + assertEquals("40 Mbps", formatPlaybackQualityBitrate(40_000)) + assertEquals("6 Mbps", formatPlaybackQualityBitrate(6_000)) + assertEquals("1.5 Mbps", formatPlaybackQualityBitrate(1_500)) + assertEquals("50.7 Mbps", formatPlaybackQualityBitrate(50_712)) + assertEquals("2 Mbps", formatPlaybackQualityBitrate(1_960)) + assertEquals("800 kbps", formatPlaybackQualityBitrate(800)) + assertNull(formatPlaybackQualityBitrate(0)) + assertNull(formatPlaybackQualityBitrate(-1)) + } + + @Test + fun activeEntryMatchesThePreferenceExactly() { + val menu = playbackQualityMenu(ladder) + + assertEquals("1080p-medium", activePlaybackQualityId(menu, "1080p-medium")) + assertEquals("1080p", activePlaybackQualityId(menu, "1080p")) + assertEquals("original", activePlaybackQualityId(menu, "ORIGINAL")) + } + + @Test + fun noPreferenceAndAutoSelectTheAutoRow() { + val menu = playbackQualityMenu(ladder) + + assertEquals("auto", activePlaybackQualityId(menu, null)) + assertEquals("auto", activePlaybackQualityId(menu, " ")) + assertEquals("auto", activePlaybackQualityId(menu, "auto")) + } + + @Test + fun aSoleEntryIsActiveForAnyPreferenceButAuto() { + val menu = playbackQualityMenu(listOf(PlaybackAvailableQualityV3("original", preservesSource = true))) + + assertEquals("auto", activePlaybackQualityId(menu, null)) + assertEquals("auto", activePlaybackQualityId(menu, "auto")) + assertEquals("original", activePlaybackQualityId(menu, "720p-low")) + assertEquals("original", activePlaybackQualityId(menu, "1080p")) + assertNull(activePlaybackQualityId(emptyList(), "auto")) + } + + @Test + fun sourceAliasesSelectTheOriginalEntry() { + val menu = playbackQualityMenu(ladder) + + assertEquals("original", activePlaybackQualityId(menu, "max")) + assertEquals("original", activePlaybackQualityId(menu, "source")) + assertNull(activePlaybackQualityId(menu, "unknown")) + } + + @Test + fun aResolutionPreferenceAtOrAboveTheSourceSelectsOriginal() { + val menu = playbackQualityMenu( + listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000, displayName = "1080p Medium"), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000, displayName = "720p Medium"), + ), + ) + + assertEquals("original", activePlaybackQualityId(menu, "2160p")) + assertEquals("original", activePlaybackQualityId(menu, "1080p")) + assertEquals("720p-medium", activePlaybackQualityId(menu, "720p")) + } + + @Test + fun aBitrateCapMovesAResolutionPreferenceToTheDeliveredClass() { + val menu = playbackQualityMenu( + listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("1080p-medium", 1080, 6_000), + PlaybackAvailableQualityV3("720p-medium", 720, 4_000), + PlaybackAvailableQualityV3("480p", 480, 1_500), + ), + ) + + assertEquals( + "1080p-medium", + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 1920, height = 800, bitrateKbps = 6_000)), + ) + assertEquals( + "720p-medium", + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 1280, height = 688, bitrateKbps = 4_000)), + ) + assertEquals( + "480p", + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 854, height = 480, bitrateKbps = 1_500)), + ) + assertNull( + activePlaybackQualityId(menu, "1080p", PlaybackEffectiveRecipeV3(width = 960, height = 540, bitrateKbps = 2_000)), + ) + } +} From 69c6f2f09249ff1a266600bec8865c3b7c9173a4 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:44:54 -0400 Subject: [PATCH 38/87] fix(libraries): re-check the library list when the Libraries tab resumes (#487) * fix(libraries): re-check the library list when the Libraries tab resumes The Libraries view model fetched the library list once and outlives tab switches. When that one fetch came back short (a partial server response, or the cached fallback after a failed request), the phone showed a single library with no switcher until the app process was killed. Re-fetch the list on every resume of the Libraries screen: entering the tab and returning to the app. The re-check reloads the open library's content only when the selection has to change, and a failed re-check keeps the list already on screen. Co-Authored-By: Claude Opus 5.5 * fix(libraries): serialize library-list loads and reset state on re-pick Review follow-ups for the resume re-check: - Only the newest library-list request publishes. An older, possibly shorter response can no longer overwrite a newer one, and a superseded full refresh hands its content reload to the request that wins. The resume re-check no longer skips while a load is in flight, so a change made during that load is still picked up. - A failed re-check keeps the list on screen only for transient failures (network or 5xx). Auth failures clear it. - When a re-check changes the selection, apply the same reset as selectLibrary: drop the previous library's rows, filter vocabulary and letter, and restore the new library's saved filters. Co-Authored-By: Claude Opus 5.5 * test(libraries): cover a re-check that drops the selected library Co-Authored-By: Claude Opus 5.5 * fix(libraries): run library-list loads one at a time Request generations let parallel loads finish out of order: an older, shorter response still reached the offline cache, and a re-check that inherited a superseded full refresh's reload could clear the list on a transient failure while the refresh's successful response was dropped. Run one load at a time instead. A call made mid-load queues one follow-up pass, and each pass takes only the content reload owed when it starts. A successful load that recovers an emptied list also reloads the open library's content, so rows cleared by an earlier failure come back even when the selection is unchanged. Co-Authored-By: Claude Opus 5.5 * fix(libraries): confirm a shrinking library list before a re-check publishes it A background re-check that got the same short 200 response it exists to recover from would still replace a fuller list, hiding libraries until the next resume. When a re-check's list drops known libraries, read it once more and publish that result: a transient short list recovers, and a real removal repeats and still applies. Co-Authored-By: Claude Opus 5.5 * fix(libraries): confirm a list shrink with a fresh read, not the cache The repository caches every successful library list and falls back to that cache on a network error or 5xx. A shrink-confirming read that failed therefore returned the short list the first read had just cached, as a success, and the re-check published it. Add a fallbackToCache flag to listUserLibraries() (default unchanged) and turn it off for the confirming read. A transient failure there now keeps the list on screen, and an auth failure still clears it. Co-Authored-By: Claude Opus 5.5 * fix(libraries): cache only a confirmed library list on a background re-check The re-check's first read still went through listUserLibraries(), which caches every success. A transient short 200 therefore replaced the full offline cache before its confirmation failed, and after process recreation an offline load served that short list. Move the re-check into PersonalDataRepository.recheckUserLibraries(): it confirms a list missing known libraries with a second read, caches only the list it accepts, and returns failures without falling back to the cache so the caller keeps what it shows. With nothing on screen to protect it behaves like listUserLibraries(). This replaces the fallbackToCache flag. Co-Authored-By: Claude Opus 5.5 * fix(libraries): keep library re-checks network-only with an empty screen With nothing on screen (a 403 had cleared the list), recheckUserLibraries delegated to listUserLibraries, whose 5xx/network fallback served the pre-403 cached list as a success and republished libraries whose access was just denied. Re-checks now never fall back to the cache; the first load keeps its offline fallback. Co-Authored-By: Claude Opus 5.5 * fix(libraries): confirm re-check shrinks against hidden libraries too With Show Audiobooks off, the visible list excludes audiobook libraries, so a partial re-check that kept every visible library but dropped the audiobooks was accepted and cached. Turning audiobooks on offline then found none. Re-checks now compare against every library id the server last listed, hidden ones included. Co-Authored-By: Claude Opus 5.5 * fix(libraries): keep the re-check baseline through transient failures When every listed library is hidden (an audiobook-only server with audiobooks off), nothing is on screen, so a transient failure took the error path and cleared serverLibraryIds. A later partial response was then cached without confirmation. Clear the baseline only for non-transient errors such as 401/403. Co-Authored-By: Claude Opus 5.5 * fix(libraries): confirm full refreshes against the known library list A full refresh (the Show Audiobooks toggle) still used the plain load, so a transient short response was cached and replaced the confirmation baseline. Keep the last confirmed server list (hidden libraries included) instead of its ids. Once it is known, every load goes through the shrink-confirming re-check; only the first load may fall back to the offline cache. A transient failure re-shows the known list, re-filtered, so a Show Audiobooks change still applies offline. Auth failures still clear it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../ui/screens/libraries/LibrariesScreen.kt | 224 +++++++++++------ .../libraries/LibrariesViewModelTest.kt | 234 +++++++++++++++++- .../silo/repository/PersonalDataRepository.kt | 25 ++ 3 files changed, 399 insertions(+), 84 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index 91e87e1ea5..839e6c7240 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -69,7 +70,10 @@ 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.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewModelScope import org.siloserver.silo.android.ui.components.TopBarRowTopInset import org.siloserver.silo.android.ui.theme.siloPageBackdrop @@ -124,12 +128,14 @@ import org.siloserver.silo.network.apiv2.CatalogContinuationV2 import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.repository.PersonalDataRepository import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.repository.port.canServeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.isActive @@ -216,6 +222,17 @@ class LibrariesViewModel( private var catalogRequestGeneration = 0L private var catalogQueryGeneration = 0L private var collectionsRequestGeneration = 0L + // Library-list loads run one at a time, in order, so an older response + // can't overwrite a newer one (on screen or in the offline cache). A + // request made mid-load queues one follow-up pass. + private var librariesJob: Job? = null + private var librariesRecheckQueued = false + // The last library list the server confirmed, hidden audiobook + // libraries included. Later loads confirm a shrink against it, so a + // partial list can't hide libraries or reach the offline cache, and a + // transient failure re-shows it instead of the cache. + private var serverLibraries: List = emptyList() + private var pendingContentReload = false private val pageSize = 42 init { @@ -233,85 +250,112 @@ class LibrariesViewModel( private fun isHiddenAudiobookLibrary(library: UserLibrary): Boolean = !showAudiobooks && library.type.trim().lowercase() in setOf("audiobook", "audiobooks") - fun refresh() { - viewModelScope.launch { - _uiState.update { - it.copy( - isLoadingLibraries = true, - librariesError = null, - ) - } + fun refresh() = loadLibraries(reloadContent = true) + + /** + * Re-fetch the library list, reloading the open library's content only when + * the selection has to change. The screen calls this on every resume: this + * VM outlives tab switches, so a list that loaded short (a partial server + * response, or the cached fallback after a failed request) used to stay + * short — hiding the switcher and every other library — until the process + * died. + */ + fun refreshLibraryList() = loadLibraries(reloadContent = false) + + private fun loadLibraries(reloadContent: Boolean) { + if (reloadContent) pendingContentReload = true + if (librariesJob?.isActive == true) { + librariesRecheckQueued = true + return + } + librariesJob = viewModelScope.launch { + do { + librariesRecheckQueued = false + // Each pass takes the reload owed so far, so a background + // re-check never inherits a full refresh it didn't run. + val reload = pendingContentReload + pendingContentReload = false + loadLibrariesPass(reload) + } while (librariesRecheckQueued) + } + } - when (val result = personalDataRepository.listUserLibraries()) { - is ApiResult.Success -> { - // Libraries is the unified hub for every library type - // (video / audio / reading). The selector lists them all - // and ItemDetail routes each item to the right player or - // reader by its type. - val libraries = result.data - .filterNot(::isHiddenAudiobookLibrary) - .sortedBy { library -> library.sortOrder } - val previousLibraryId = _uiState.value.selectedLibraryId - val selectedLibraryId = previousLibraryId - ?.takeIf { currentId -> libraries.any { it.id == currentId } } - ?: libraries.firstOrNull()?.id - // When this resolves to a *new* library (first load, or the - // prior one vanished), restore its saved browse filter + - // preserve state — same as selectLibrary — so opening Browse - // on the default library isn't an unfiltered grid for a - // profile with saved filters. An unchanged selection keeps - // whatever filters are already active. - val restoreBrowsePrefs = - selectedLibraryId != null && selectedLibraryId != previousLibraryId - // Null when there is nothing to restore, so the branches - // below keep the live state untouched. - val restoredFilterState = if (restoreBrowsePrefs) { - browsePrefs?.savedState(selectedLibraryId) ?: CatalogFilterState() - } else { - null - } + private suspend fun loadLibrariesPass(reload: Boolean) { + _uiState.update { + it.copy( + isLoadingLibraries = true, + librariesError = null, + ) + } - _uiState.update { - it.copy( - isLoadingLibraries = false, - libraries = libraries, - selectedLibraryId = selectedLibraryId, - librariesError = null, - filterState = restoredFilterState ?: it.filterState, - // The sort lives inside the persisted filter state, - // so derive the chip from what was restored. - browseSort = restoredFilterState - ?.let(LibraryBrowseSort::fromFilterState) - ?: it.browseSort, - preserveFilters = if (restoreBrowsePrefs) - (browsePrefs?.preserveEnabled(selectedLibraryId) ?: true) - else it.preserveFilters, - ) - } + // Once a list is known, every load confirms a shrink before trusting + // it (a short list is the failure this screen recovers from); a real + // removal (revoked access, deleted library) repeats and applies. Only + // the first load may fall back to the offline cache. + val known = serverLibraries + val fetched = if (reload && known.isEmpty()) { + personalDataRepository.listUserLibraries() + } else { + personalDataRepository.recheckUserLibraries(known.mapTo(mutableSetOf()) { it.id }) + } + // A transient failure re-shows the known list, re-filtered so a Show + // Audiobooks change still applies. Auth failures fall through and clear it. + val result = if (fetched.canServeCache() && known.isNotEmpty()) ApiResult.Success(known) else fetched + when (result) { + is ApiResult.Success -> { + serverLibraries = result.data + // Libraries is the unified hub for every library type + // (video / audio / reading). The selector lists them all + // and ItemDetail routes each item to the right player or + // reader by its type. + val libraries = result.data + .filterNot(::isHiddenAudiobookLibrary) + .sortedBy { library -> library.sortOrder } + // Recovering from an emptied list (an earlier failure + // cleared it, sections included) owes a content reload too. + val recovering = _uiState.value.libraries.isEmpty() + val previousLibraryId = _uiState.value.selectedLibraryId + val selectedLibraryId = previousLibraryId + ?.takeIf { currentId -> libraries.any { it.id == currentId } } + ?: libraries.firstOrNull()?.id + // A *new* selection (first load, or the prior library + // vanished) gets the same reset as selectLibrary. An + // unchanged selection keeps its rows and active filters. + val selectionChanged = selectedLibraryId != previousLibraryId + if (selectionChanged) resetForLibrary(selectedLibraryId) + _uiState.update { + it.copy( + isLoadingLibraries = false, + libraries = libraries, + librariesError = null, + ) + } - if (selectedLibraryId != null) { - loadCurrentTab(selectedLibraryId, force = true) - } + if (selectedLibraryId != null && (reload || selectionChanged || recovering)) { + loadCurrentTab(selectedLibraryId, force = true) } - is ApiResult.Error -> { - _uiState.update { - it.copy( - isLoadingLibraries = false, - libraries = emptyList(), - sections = emptyList(), - librariesError = result.message.ifBlank { "Failed to load libraries" }, - ) - } + } + is ApiResult.Error -> { + // Revoked access drops the baseline; a transient failure keeps + // it, since hidden libraries can outlive an empty screen. + if (!result.canServeCache()) serverLibraries = emptyList() + _uiState.update { + it.copy( + isLoadingLibraries = false, + libraries = emptyList(), + sections = emptyList(), + librariesError = result.message.ifBlank { "Failed to load libraries" }, + ) } - is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoadingLibraries = false, - libraries = emptyList(), - sections = emptyList(), - librariesError = "Network error: ${result.exception.message ?: "unknown"}", - ) - } + } + is ApiResult.NetworkError -> { + _uiState.update { + it.copy( + isLoadingLibraries = false, + libraries = emptyList(), + sections = emptyList(), + librariesError = "Network error: ${result.exception.message ?: "unknown"}", + ) } } } @@ -319,13 +363,22 @@ class LibrariesViewModel( fun selectLibrary(libraryId: Int) { if (_uiState.value.selectedLibraryId == libraryId) return + resetForLibrary(libraryId) + loadCurrentTab(libraryId, force = true) + } + + /** + * Point the state at [libraryId], dropping the previous library's rows, + * filter vocabulary and letter. Restores this library's persisted + * filter/sort state (iOS parity) so a preserved selection doesn't flash the + * unfiltered grid; defaults to a clean filter — and therefore + * RecentlyAdded — when nothing is saved. + */ + private fun resetForLibrary(libraryId: Int?) { recommendedLoadedLibraryId = null browseLoadedLibraryId = null collectionsLoadedLibraryId = null - // Restore this library's persisted filter/sort state (iOS parity) so a - // preserved selection doesn't flash the unfiltered grid; default to a - // clean filter — and therefore RecentlyAdded — when nothing is saved. - val restoredFilterState = browsePrefs?.savedState(libraryId) ?: CatalogFilterState() + val restoredFilterState = libraryId?.let { browsePrefs?.savedState(it) } ?: CatalogFilterState() _uiState.update { it.copy( selectedLibraryId = libraryId, @@ -337,14 +390,13 @@ class LibrariesViewModel( filterState = restoredFilterState, browseSort = LibraryBrowseSort.fromFilterState(restoredFilterState), availableFilters = null, - preserveFilters = browsePrefs?.preserveEnabled(libraryId) ?: true, + preserveFilters = libraryId?.let { id -> browsePrefs?.preserveEnabled(id) } ?: true, selectedNamePrefix = null, catalogError = null, collections = emptyList(), collectionsError = null, ) } - loadCurrentTab(libraryId, force = true) } fun selectTab(tab: LibrariesSubtab) { @@ -819,6 +871,18 @@ fun LibrariesScreen( } } + // Re-check the library list on every resume: entering the tab (the observer + // replays ON_RESUME when added) and returning to the app. The VM loads once + // and outlives tab switches, so a short list otherwise stuck until restart. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshLibraryList() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + // Recommended tab scroll state — drives the chrome scrim opacity so the // header fades in its scrim once the user scrolls the rows underneath it. val recommendedListState = rememberLazyListState() diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt index dc9daf5d8a..2675f23242 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -52,6 +52,9 @@ import org.siloserver.silo.network.api.SectionApi import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.repository.PersonalDataRepository import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.repository.port.CatalogCachePort +import org.siloserver.silo.repository.port.NoOpCatalogCachePort +import org.siloserver.silo.model.personal.UserLibrary import kotlin.test.Test import kotlin.test.assertEquals @@ -306,6 +309,207 @@ class LibrariesViewModelTest { } } + @Test + fun libraryListRecheckRecoversShortOrClearedListsAndKeepsThemOnlyOnTransientFailure() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + fixture.librariesBody = """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel(catalogCache = InMemoryLibraryCache()) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 1 } + + // The server now returns the full list: the re-check picks it up + // and keeps the open library selected. + fixture.librariesBody = null + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + val recovered = viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 2 } + assertEquals(1, recovered.selectedLibraryId) + + // A failed re-check leaves the list on screen instead of an error. + fixture.librariesStatus = HttpStatusCode.InternalServerError + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + val afterFailure = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), afterFailure.libraries.map { it.id }) + assertEquals(null, afterFailure.librariesError) + + // Revoked access is not transient: the list clears. + fixture.librariesStatus = HttpStatusCode.Forbidden + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + val afterForbidden = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(emptyList(), afterForbidden.libraries) + + // A transient failure next must not resurrect the pre-403 list + // from the offline cache. + fixture.librariesStatus = HttpStatusCode.ServiceUnavailable + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + assertEquals(emptyList(), viewModel.uiState.first { !it.isLoadingLibraries }.libraries) + + // Access returns: the list recovers and the cleared rows reload, + // even though the selected library is unchanged. + fixture.librariesStatus = HttpStatusCode.OK + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + assertEquals(1, viewModel.uiState.first { it.libraries.size == 2 }.selectedLibraryId) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun libraryListRecheckThatDropsTheSelectedLibraryResetsToTheReplacement() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.selectLibrary(2) + fixture.awaitRequest("sections:2") + viewModel.selectNamePrefix("B") + viewModel.uiState.first { it.selectedNamePrefix != null } + + // The server stops listing the open library: the re-check falls + // back to the first library with none of library 2's browse state, + // and loads the replacement's content. + fixture.librariesBody = """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refreshLibraryList() + fixture.awaitRequest("sections:1") + val state = viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 1 } + assertEquals(1, state.selectedLibraryId) + assertEquals(null, state.selectedNamePrefix) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun libraryListRecheckConfirmsAShrinkBeforePublishingIt() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val cache = InMemoryLibraryCache() + val viewModel = fixture.viewModel(catalogCache = cache) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 2 } + + // One transient short response: the confirming read returns the + // full list again, so the switcher keeps both libraries. + fixture.librariesBodyQueue += """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + val state = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), state.libraries.map { it.id }) + + // A short response, then a failed confirming read: neither the + // screen nor the offline cache may take the unconfirmed short list. + fixture.librariesBodyQueue += """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + fixture.librariesStatusQueue += listOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable) + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + val afterFailedConfirm = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), afterFailedConfirm.libraries.map { it.id }) + assertEquals(listOf(1, 2), cache.libraries?.map { it.id }) + + // A full refresh (the Show Audiobooks toggle) confirms a shrink too. + fixture.librariesBodyQueue += """ + {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refresh() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + val afterRefresh = viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2), afterRefresh.libraries.map { it.id }) + assertEquals(listOf(1, 2), cache.libraries?.map { it.id }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun libraryListRecheckConfirmsAShrinkOfHiddenAudiobookLibraries() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + fixture.librariesBody = """ + {"items":[ + {"id":"1","name":"First","type":"movies","sort_order":0}, + {"id":"2","name":"Second","type":"movies","sort_order":1}, + {"id":"3","name":"Books","type":"audiobooks","sort_order":2} + ],"page":{"has_more":false}} + """.trimIndent() + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val cache = InMemoryLibraryCache() + val viewModel = fixture.viewModel(catalogCache = cache) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + // Audiobooks are hidden by default, so only 1 and 2 are on screen. + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.size == 2 } + + // A short response that keeps every visible library but drops the + // hidden audiobook library still needs confirming before it is + // cached; the confirming read returns all three. + fixture.librariesBodyQueue += """ + {"items":[ + {"id":"1","name":"First","type":"movies","sort_order":0}, + {"id":"2","name":"Second","type":"movies","sort_order":1} + ],"page":{"has_more":false}} + """.trimIndent() + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(1, 2, 3), cache.libraries?.map { it.id }) + + // Same with nothing visible to protect: an audiobook-only server + // keeps its baseline through a transient failure, so a later empty + // response is still confirmed before it reaches the cache. + fixture.librariesBody = """ + {"items":[{"id":"3","name":"Books","type":"audiobooks","sort_order":0}],"page":{"has_more":false}} + """.trimIndent() + viewModel.refresh() + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.isEmpty() } + fixture.librariesStatusQueue += HttpStatusCode.ServiceUnavailable + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries } + fixture.librariesBodyQueue += """{"items":[],"page":{"has_more":false}}""" + viewModel.refreshLibraryList() + fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") + viewModel.uiState.first { !it.isLoadingLibraries } + assertEquals(listOf(3), cache.libraries?.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] @@ -322,6 +526,13 @@ class LibrariesViewModelTest { error("Unreachable") } + /** A real library cache, so a failed read can fall back to the list it holds. */ + private class InMemoryLibraryCache : CatalogCachePort { + @Volatile var libraries: List? = null + override suspend fun cacheLibraries(libraries: List) { this.libraries = libraries } + override suspend fun getCachedLibraries() = libraries + } + /** BrowsePrefsStore persists nothing without an active server + profile. */ private class FakeServerRegistry : ServerRegistry { private val entry = ServerEntry( @@ -346,6 +557,13 @@ class LibrariesViewModelTest { private val deferredKeys: Set, ) { var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) + /** Overrides the immediate two-library list; [librariesStatus] fails it instead. */ + @Volatile var librariesBody: String? = null + /** One-shot bodies served, in order, ahead of [librariesBody]. */ + val librariesBodyQueue = java.util.concurrent.ConcurrentLinkedQueue() + /** One-shot statuses served, in order, ahead of [librariesStatus]. */ + val librariesStatusQueue = java.util.concurrent.ConcurrentLinkedQueue() + @Volatile var librariesStatus: HttpStatusCode = HttpStatusCode.OK private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = owner } private val requests = Channel(Channel.UNLIMITED) private val pendingRequests = mutableListOf() @@ -387,14 +605,22 @@ class LibrariesViewModelTest { } requests.send(key) val body = responses[key]?.await() ?: immediateBody(key) - respondJson(body) + val status = if (key == "libraries") librariesStatusQueue.poll() ?: librariesStatus else HttpStatusCode.OK + if (status != HttpStatusCode.OK) { + respond(content = "", status = status) + } else { + respondJson(body) + } }, ) { install(ContentNegotiation) { json(SiloJson) } } - fun viewModel(browsePrefs: BrowsePrefsStore? = null) = LibrariesViewModel( - personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + fun viewModel( + browsePrefs: BrowsePrefsStore? = null, + catalogCache: CatalogCachePort = NoOpCatalogCachePort, + ) = LibrariesViewModel( + personalDataRepository = PersonalDataRepository(PersonalDataApi(client), catalogCache = catalogCache), sectionRepository = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))), catalogRepository = CatalogRepository(CatalogApi(client)), browsePrefs = browsePrefs, @@ -422,7 +648,7 @@ class LibrariesViewModelTest { } private fun immediateBody(key: String): String = when { - key == "libraries" -> """ + key == "libraries" -> librariesBodyQueue.poll() ?: librariesBody ?: """ {"items":[ {"id":"1","name":"First","type":"movies","sort_order":0}, {"id":"2","name":"Second","type":"movies","sort_order":1} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt index 6cbbb0541b..b1c2368624 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt @@ -52,6 +52,31 @@ open class PersonalDataRepository( return result } + /** + * Background re-check of a library list already on screen ([knownIds]). + * A list missing any known library is read again before it is trusted, + * and only the accepted list is cached, so one transient short response + * can't replace the full list on screen or in the offline cache. A failed + * read returns the failure rather than the cache — even with nothing on + * screen, where the cache may hold a list whose access was since denied — + * so the caller keeps what it shows. + */ + suspend fun recheckUserLibraries(knownIds: Set): ApiResult> { + val requestIdentityGeneration = identityTransitions.generation.value + val first = personalDataApi.listUserLibraries() + val result = if (first is ApiResult.Success && !first.data.map { it.id }.containsAll(knownIds)) { + personalDataApi.listUserLibraries() + } else { + first + } + if (result is ApiResult.Success) { + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheLibraries(result.data, cacheWriteLease) + } + } + return result + } + // -- Favorites -- suspend fun isFavorite(itemId: String): ApiResult = From b89b5383c42f5e05b5c88eb4f102b0ce7e50361a Mon Sep 17 00:00:00 2001 From: Suspense Date: Wed, 7 Oct 2026 15:46:05 +0200 Subject: [PATCH 39/87] fix(profiles): manage household profiles as the primary profile (#493) * fix(profiles): manage household profiles as the primary profile The server now requires household profile management to come from the account's primary profile, verified with its PIN when it has one. Android TV sent its Manage calls with no profile and the phone sent the last-used profile, so add, edit and delete failed with 403 on admin households that have a restricted profile. Manage and Add profile now find the primary, ask for its PIN when needed, and send the primary's profile and proof on create, update and delete for that session only. A refused proof prompts for the PIN again and retries once. PIN checks no longer carry the selected profile's possibly stale proof, which also fixes the phone rejecting a correct PIN in that case. * fix(profiles): end household management on cancel or identity change Cancel on the primary's PIN re-prompt now ends the session at once, so a verification already in flight cannot renew the token and retry the call. end() always closes a re-prompt that is about to open. A re-prompt or the initial manage-mode PIN whose account or server changed during verification no longer starts or renews the session. --- .../android/ui/navigation/AppNavigation.kt | 1 + .../screens/profiles/HouseholdReverifyHost.kt | 29 +++ .../ui/screens/profiles/PINEntryOverlay.kt | 3 +- .../profiles/ProfileSelectionScreen.kt | 10 +- .../profiles/ProfileSelectionViewModel.kt | 113 ++++++++++- .../silo/tv/ui/components/TvPinEntryDialog.kt | 3 +- .../silo/tv/ui/navigation/TvAppNavigation.kt | 1 + .../profiles/TvHouseholdReverifyHost.kt | 29 +++ .../profiles/TvProfileSelectionScreen.kt | 13 +- .../profiles/TvProfileSelectionViewModel.kt | 116 +++++++++++- .../silo/model/profile/ProfileModels.kt | 7 + .../siloserver/silo/network/api/ProfileApi.kt | 94 +++++++++- .../repository/HouseholdManagementSession.kt | 177 ++++++++++++++++++ .../silo/repository/ProfileRepository.kt | 24 ++- .../network/apiv2/ProfileHouseholdV2Test.kt | 73 ++++++++ .../HouseholdManagementSessionTest.kt | 111 +++++++++++ 16 files changed, 765 insertions(+), 39 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/HouseholdReverifyHost.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvHouseholdReverifyHost.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/HouseholdManagementSession.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/repository/HouseholdManagementSessionTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index ed56ae87dc..625b2296fa 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -1688,6 +1688,7 @@ fun AppNavigation( org.siloserver.silo.android.ui.screens.pairing.CompanionPairingHost( enabled = currentRoute != null && currentRoute !in companionHiddenRoutes, ) + org.siloserver.silo.android.ui.screens.profiles.HouseholdReverifyHost() } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/HouseholdReverifyHost.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/HouseholdReverifyHost.kt new file mode 100644 index 0000000000..187e7efb53 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/HouseholdReverifyHost.kt @@ -0,0 +1,29 @@ +package org.siloserver.silo.android.ui.screens.profiles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.koin.compose.koinInject +import org.siloserver.silo.repository.ProfileRepository + +/** + * Asks for the primary profile's PIN again when a manage-mode call finds its + * token stale (see [org.siloserver.silo.repository.HouseholdManagementSession]). + * App-wide because the call can come from the picker, add, or edit screen. + */ +@Composable +fun HouseholdReverifyHost(profileRepository: ProfileRepository = koinInject()) { + val session = profileRepository.householdManagement + val prompt by session.reverify.collectAsState() + prompt?.let { + PINEntryOverlay( + profile = it.profile, + isVerifying = it.isVerifying, + error = it.error, + errorCount = it.errorCount, + onPinComplete = session::submitPin, + onDismiss = session::cancelReverify, + prompt = "Enter this PIN to manage profiles", + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt index 120fd08b4b..6c0bd11e9c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryOverlay.kt @@ -82,6 +82,7 @@ fun PINEntryOverlay( errorCount: Int, onPinComplete: (String) -> Unit, onDismiss: () -> Unit, + prompt: String = "Enter your PIN", ) { // Deliberately NOT rememberSaveable: saved-instance state is serialized by // the OS across configuration change and process death, which would put the @@ -147,7 +148,7 @@ fun PINEntryOverlay( when { error != null -> Text(error, color = MarqueeColors.Error, fontSize = 15.sp, textAlign = TextAlign.Center) isVerifying -> CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) - else -> Text("Enter your PIN", color = MarqueeColors.InkSecondary, fontSize = 15.sp) + else -> Text(prompt, color = MarqueeColors.InkSecondary, fontSize = 15.sp) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt index 76d047d02a..a13ace5025 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt @@ -157,6 +157,13 @@ fun ProfileSelectionScreen( DisposableEffect(Unit) { onDispose { MarqueeScene.personalTint = null } } // Navigate after a profile is selected. + LaunchedEffect(state.openAddProfile) { + if (state.openAddProfile) { + viewModel.onAddProfileConsumed() + onNavigateToCreateProfile() + } + } + LaunchedEffect(state.selectedProfileId) { if (state.selectedProfileId != null) { viewModel.onProfileSelectedConsumed() @@ -234,7 +241,7 @@ fun ProfileSelectionScreen( if (state.isManageMode) onNavigateToEditProfile(profile.id) else viewModel.onProfileTapped(profile) }, onProfileDelete = viewModel::requestDeleteProfile, - onAddProfile = onNavigateToCreateProfile, + onAddProfile = viewModel::requestAddProfile, onChangeServer = onChangeServer, onSignOut = onSignOut, onToggleManage = viewModel::toggleManageMode, @@ -254,6 +261,7 @@ fun ProfileSelectionScreen( errorCount = state.pinErrorCount, onPinComplete = viewModel::onPinEntered, onDismiss = viewModel::dismissPinDialog, + prompt = if (state.pinForManagement) "Enter this PIN to manage profiles" else "Enter your PIN", ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt index 3d8fc9a4bb..a63cb990d2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.siloserver.silo.model.profile.Profile import org.siloserver.silo.model.profile.authorizedProfileToken +import org.siloserver.silo.model.profile.householdPrimary import org.siloserver.silo.model.server.ServerContract import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot @@ -25,6 +26,8 @@ data class ProfileSelectionUiState( val isManageMode: Boolean = false, /** Non-null when a PIN-protected profile was tapped and the dialog should show. */ val pinDialogProfile: Profile? = null, + /** The open PIN prompt is the primary profile's, unlocking manage mode rather than selecting it. */ + val pinForManagement: Boolean = false, val pinIsVerifying: Boolean = false, val pinError: String? = null, /** Bumped on each rejected PIN so the dots shake and the entry clears. */ @@ -40,6 +43,8 @@ data class ProfileSelectionUiState( /** The profile this session is signed in as — deleting it needs a * stronger warning and clears the local selection first. */ val activeProfileId: String? = null, + /** Manage mode just started for "Add profile"; the screen opens the add form and consumes this. */ + val openAddProfile: Boolean = false, ) class ProfileSelectionViewModel( @@ -63,9 +68,24 @@ class ProfileSelectionViewModel( */ private var skipsSingleProfile: Boolean? = null + /** "Add profile" asked for manage mode; open the add form once it starts. */ + private var addAfterUnlock = false + init { loadProfiles() reloadWhenUpdateRequiredLifts() + leaveManageModeWhenSessionEnds() + } + + /** Cancel on a re-prompt for the primary's PIN ends the session; manage mode goes with it. */ + private fun leaveManageModeWhenSessionEnds() { + viewModelScope.launch { + profileRepository.householdManagement.isActive.collect { active -> + if (!active && _uiState.value.isManageMode) { + _uiState.update { it.copy(isManageMode = false, deleteDialogProfile = null) } + } + } + } } /** @@ -125,6 +145,7 @@ class ProfileSelectionViewModel( // Leaving a scope behind for an empty grid is stale metadata // that a later selection could be qualified against. gridScope = null + profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, @@ -150,6 +171,7 @@ class ProfileSelectionViewModel( // picker after signing in: open it, once. val only = result.data.singleOrNull()?.takeIf { skipSingle && !it.hasPin } skipsSingleProfile = false + if (!isAdmin) profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, @@ -183,9 +205,68 @@ class ProfileSelectionViewModel( } } + /** + * Entering manage mode acts as the household's primary profile, the only + * one the server lets manage the others, whichever profile this device + * last used. A PIN-locked primary is verified first; Cancel there leaves + * manage mode off. The primary is never selected or remembered. + */ fun toggleManageMode() { - if (!_uiState.value.canManageProfiles) return - _uiState.update { it.copy(isManageMode = !it.isManageMode) } + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode) { + profileRepository.householdManagement.end() + _uiState.update { it.copy(isManageMode = false) } + return + } + enterManageMode(thenAdd = false) + } + + /** + * "Add profile" creates a household profile, so it needs the same primary + * profile step as Manage; the add form opens once manage mode is on. + */ + fun requestAddProfile() { + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode && profileRepository.householdManagement.isActive.value) { + _uiState.update { it.copy(openAddProfile = true) } + } else { + enterManageMode(thenAdd = true) + } + } + + fun onAddProfileConsumed() { + _uiState.update { it.copy(openAddProfile = false) } + } + + private fun enterManageMode(thenAdd: Boolean) { + val primary = _uiState.value.profiles.householdPrimary() + if (primary == null) { + _uiState.update { it.copy(error = "Couldn't find the primary profile needed to manage profiles.") } + return + } + pinAttempt++ + addAfterUnlock = thenAdd + if (primary.hasPin) { + _uiState.update { + it.copy(pinDialogProfile = primary, pinForManagement = true, pinIsVerifying = false, pinError = null) + } + return + } + profileRepository.householdManagement.begin(primary, profileToken = null, scope = gridScope) + onManageModeStarted() + } + + private fun onManageModeStarted() { + val add = addAfterUnlock + addAfterUnlock = false + _uiState.update { it.copy(isManageMode = true, openAddProfile = add) } + } + + override fun onCleared() { + profileRepository.householdManagement.end() + super.onCleared() } /** @@ -214,6 +295,7 @@ class ProfileSelectionViewModel( _uiState.update { it.copy( pinDialogProfile = profile, + pinForManagement = false, pinIsVerifying = false, pinError = null, ) @@ -231,6 +313,7 @@ class ProfileSelectionViewModel( */ fun onPinEntered(pin: String) { val profile = _uiState.value.pinDialogProfile ?: return + val forManagement = _uiState.value.pinForManagement val attempt = ++pinAttempt viewModelScope.launch { @@ -249,7 +332,19 @@ class ProfileSelectionViewModel( when (result) { is ApiResult.Success -> { val token = result.data.authorizedProfileToken() - if (token != null) { + if (token != null && forManagement) { + // An account or server change during the round trip + // voids this answer; the reload shows the new identity. + if (!profileRepository.identityScopeUnchanged(scope)) { + dismissPinDialog() + loadProfiles() + return@launch + } + // Kept for manage mode only; the device's selection is untouched. + profileRepository.householdManagement.begin(profile, token, scope) + _uiState.update { it.copy(pinIsVerifying = false, pinDialogProfile = null, pinForManagement = false) } + onManageModeStarted() + } else if (token != null) { _uiState.update { it.copy(pinIsVerifying = false, pinDialogProfile = null) } selectProfile(profile.id, token, scope) } else { @@ -279,11 +374,12 @@ class ProfileSelectionViewModel( } fun dismissPinDialog() { + addAfterUnlock = false // Bump the generation so an in-flight verification for the dismissed // profile can no longer commit. pinAttempt++ _uiState.update { - it.copy(pinDialogProfile = null, pinIsVerifying = false, pinError = null) + it.copy(pinDialogProfile = null, pinForManagement = false, pinIsVerifying = false, pinError = null) } } @@ -301,10 +397,11 @@ class ProfileSelectionViewModel( val profile = _uiState.value.deleteDialogProfile ?: return _uiState.update { it.copy(deleteDialogProfile = null) } viewModelScope.launch { - // Order matters: the DELETE itself is authorized by the signed-in - // profile's X-Profile-Id/X-Profile-Token headers, so credentials - // must stay intact until the server has answered. Only after a - // successful delete of the signed-in profile do we clear the local + // Order matters: the DELETE is authorized by the household + // manager's (primary profile's) headers, but the signed-in + // profile's credentials stay intact until the server has + // answered. Only after a successful delete of the signed-in + // profile do we clear the local // selection — BEFORE reloading, so the list refresh doesn't ride // the now-invalidated profile token (previously that errored and // rendered an empty list — "all profiles gone"). diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt index d68e9d9112..4d93f417db 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPinEntryDialog.kt @@ -74,6 +74,7 @@ fun TvPinEntryDialog( onDismiss: () -> Unit, errorMessage: String? = null, isVerifying: Boolean = false, + prompt: String = "Enter your PIN", ) { var pin by remember { mutableStateOf("") } val fiveFocusRequester = remember { FocusRequester() } @@ -126,7 +127,7 @@ fun TvPinEntryDialog( when { latestError != null -> Text(latestError, fontSize = 14.sp, color = MarqueeColors.Error) isVerifying -> Text("Checking…", fontSize = 14.sp, color = MarqueeColors.InkSecondary) - else -> Text("Enter your PIN", fontSize = 14.sp, color = MarqueeColors.InkSecondary) + else -> Text(prompt, fontSize = 14.sp, color = MarqueeColors.InkSecondary) } } Spacer(modifier = Modifier.height(15.dp)) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 0726fc141f..1a69b704dd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -1580,6 +1580,7 @@ fun TvAppNavigation( Modifier.align(androidx.compose.ui.Alignment.BottomCenter).fillMaxWidth(), ) + org.siloserver.silo.tv.ui.screens.profiles.TvHouseholdReverifyHost(profileRepository) siloCastStandby?.let { state -> TvSiloCastStandbyView( state = state, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvHouseholdReverifyHost.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvHouseholdReverifyHost.kt new file mode 100644 index 0000000000..f38f5243c5 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvHouseholdReverifyHost.kt @@ -0,0 +1,29 @@ +package org.siloserver.silo.tv.ui.screens.profiles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.koin.compose.koinInject +import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.tv.ui.components.TvPinEntryDialog + +/** + * Asks for the primary profile's PIN again when a manage-mode call finds its + * token stale (see [org.siloserver.silo.repository.HouseholdManagementSession]). + * App-wide because the call can come from the picker, add, or edit screen. + */ +@Composable +fun TvHouseholdReverifyHost(profileRepository: ProfileRepository = koinInject()) { + val session = profileRepository.householdManagement + val prompt by session.reverify.collectAsState() + prompt?.let { + TvPinEntryDialog( + profile = it.profile, + onPinEntered = session::submitPin, + onDismiss = session::cancelReverify, + errorMessage = it.error, + isVerifying = it.isVerifying, + prompt = "Enter this PIN to manage profiles", + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt index 9b4cb3c250..1c3e5f38e8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt @@ -148,6 +148,13 @@ fun TvProfileSelectionScreen( } DisposableEffect(Unit) { onDispose { MarqueeScene.personalTint = null } } + LaunchedEffect(state.openAddProfile) { + if (state.openAddProfile) { + viewModel.onAddProfileConsumed() + onAddProfile() + } + } + LaunchedEffect(state.selectedProfileId) { if (state.selectedProfileId != null) { viewModel.onSelectionConsumed() @@ -260,7 +267,7 @@ fun TvProfileSelectionScreen( onProfileSelected = viewModel::onProfileSelected, onEditProfile = { onEditProfile(it.id) }, onDeleteProfile = viewModel::requestDelete, - onAddProfile = onAddProfile, + onAddProfile = viewModel::requestAddProfile, ) if (state.error != null) { @@ -306,13 +313,15 @@ fun TvProfileSelectionScreen( } } - // PIN entry — shown when a PIN-protected profile is selected. + // PIN entry — shown when a PIN-protected profile is selected, or for the + // primary profile's PIN before manage mode. val pinProfile = state.pinProfile if (pinProfile != null) { TvPinEntryDialog( profile = pinProfile, errorMessage = state.pinError, isVerifying = state.isVerifyingPin, + prompt = if (state.pinForManagement) "Enter this PIN to manage profiles" else "Enter your PIN", onPinEntered = viewModel::onPinEntered, onDismiss = { viewModel.onPinDialogDismissed() }, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt index 67c92fba0d..6d2296a5bd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.siloserver.silo.model.profile.Profile import org.siloserver.silo.model.profile.authorizedProfileToken +import org.siloserver.silo.model.profile.householdPrimary import org.siloserver.silo.model.server.ServerContract import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot @@ -27,11 +28,15 @@ data class TvProfileSelectionUiState( val isManageMode: Boolean = false, // PIN entry flow. val pinProfile: Profile? = null, + /** The open PIN prompt is the primary profile's, unlocking manage mode rather than selecting it. */ + val pinForManagement: Boolean = false, val isVerifyingPin: Boolean = false, val pinError: String? = null, // Profile pending delete confirmation (manage mode). val deleteCandidate: Profile? = null, val isDeleting: Boolean = false, + /** Manage mode just started for "Add profile"; the screen opens the add form and consumes this. */ + val openAddProfile: Boolean = false, /** The signed-in account's username, shown under the title; null until read. */ val accountName: String? = null, /** A one-profile household is being opened straight after sign-in; the picker stays hidden. */ @@ -64,9 +69,24 @@ class TvProfileSelectionViewModel( */ private var skipsSingleProfile: Boolean? = null + /** "Add profile" asked for manage mode; open the add form once it starts. */ + private var addAfterUnlock = false + init { loadProfiles() reloadWhenUpdateRequiredLifts() + leaveManageModeWhenSessionEnds() + } + + /** Cancel on a re-prompt for the primary's PIN ends the session; manage mode goes with it. */ + private fun leaveManageModeWhenSessionEnds() { + viewModelScope.launch { + profileRepository.householdManagement.isActive.collect { active -> + if (!active && _uiState.value.isManageMode) { + _uiState.update { it.copy(isManageMode = false, deleteCandidate = null) } + } + } + } } /** @@ -117,6 +137,7 @@ class TvProfileSelectionViewModel( if (!profileRepository.identityScopeUnchanged(scope)) { // The displayed grid is gone, so its scope must go with it. gridScope = null + profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, @@ -139,6 +160,7 @@ class TvProfileSelectionViewModel( // picker after signing in: open it, once. val only = result.data.singleOrNull()?.takeIf { skipSingle && !it.hasPin } skipsSingleProfile = false + if (!isAdmin) profileRepository.householdManagement.end() _uiState.update { it.copy( isLoading = false, @@ -169,17 +191,75 @@ class TvProfileSelectionViewModel( } } + /** + * Entering manage mode acts as the household's primary profile, the only + * one the server lets manage the others: the picker has no selected profile + * on TV. A PIN-locked primary is verified first; Cancel there leaves manage + * mode off. The primary is never selected, so watching still needs a pick. + */ fun toggleManageMode() { - if (!_uiState.value.canManageProfiles) return - _uiState.update { - it.copy( - isManageMode = !it.isManageMode, - // Leaving manage mode clears any pending delete prompt. - deleteCandidate = if (it.isManageMode) null else it.deleteCandidate, - ) + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode) { + exitManageMode() + return + } + enterManageMode(thenAdd = false) + } + + /** + * "Add profile" creates a household profile, so it needs the same primary + * profile step as Manage; the add form opens once manage mode is on. + */ + fun requestAddProfile() { + val state = _uiState.value + if (!state.canManageProfiles) return + if (state.isManageMode && profileRepository.householdManagement.isActive.value) { + _uiState.update { it.copy(openAddProfile = true) } + } else { + enterManageMode(thenAdd = true) } } + fun onAddProfileConsumed() { + _uiState.update { it.copy(openAddProfile = false) } + } + + private fun enterManageMode(thenAdd: Boolean) { + val primary = _uiState.value.profiles.householdPrimary() + if (primary == null) { + _uiState.update { it.copy(error = "Couldn't find the primary profile needed to manage profiles.") } + return + } + pinAttempt++ + addAfterUnlock = thenAdd + if (primary.hasPin) { + _uiState.update { + it.copy(pinProfile = primary, pinForManagement = true, pinError = null, isVerifyingPin = false) + } + return + } + profileRepository.householdManagement.begin(primary, profileToken = null, scope = gridScope) + onManageModeStarted() + } + + private fun onManageModeStarted() { + val add = addAfterUnlock + addAfterUnlock = false + _uiState.update { it.copy(isManageMode = true, openAddProfile = add) } + } + + private fun exitManageMode() { + profileRepository.householdManagement.end() + // Leaving manage mode clears any pending delete prompt. + _uiState.update { it.copy(isManageMode = false, deleteCandidate = null) } + } + + override fun onCleared() { + profileRepository.householdManagement.end() + super.onCleared() + } + /** * Called when a profile is activated on the selection grid. In manage mode * the screen routes to edit instead (it inspects [TvProfileSelectionUiState.isManageMode]), @@ -203,7 +283,7 @@ class TvProfileSelectionViewModel( if (profile.hasPin) { // Open the PIN dialog; actual selection happens in onPinEntered. _uiState.update { - it.copy(pinProfile = profile, pinError = null, isVerifyingPin = false) + it.copy(pinProfile = profile, pinForManagement = false, pinError = null, isVerifyingPin = false) } return } @@ -214,15 +294,17 @@ class TvProfileSelectionViewModel( } fun onPinDialogDismissed() { + addAfterUnlock = false // Abandon any in-flight verification for the dismissed profile. pinAttempt++ _uiState.update { - it.copy(pinProfile = null, pinError = null, isVerifyingPin = false) + it.copy(pinProfile = null, pinForManagement = false, pinError = null, isVerifyingPin = false) } } fun onPinEntered(pin: String) { val profile = _uiState.value.pinProfile ?: return + val forManagement = _uiState.value.pinForManagement val attempt = ++pinAttempt _uiState.update { it.copy(isVerifyingPin = true, pinError = null) } viewModelScope.launch { @@ -243,7 +325,19 @@ class TvProfileSelectionViewModel( // gate on the issued token (matches phone) — never commit on // a bare 200, nor on a valid=true carrying no proof. val token = r.data.authorizedProfileToken() - if (token != null) { + if (token != null && forManagement) { + // An account or server change during the round trip + // voids this answer; the reload shows the new identity. + if (!profileRepository.identityScopeUnchanged(scope)) { + onPinDialogDismissed() + loadProfiles() + return@launch + } + // Kept for manage mode only; the device's selection is untouched. + profileRepository.householdManagement.begin(profile, token, scope) + _uiState.update { it.copy(pinProfile = null, pinForManagement = false, isVerifyingPin = false) } + onManageModeStarted() + } else if (token != null) { commitSelection(profile, token, scope) _uiState.update { it.copy(pinProfile = null, isVerifyingPin = false) } } else { @@ -283,12 +377,14 @@ class TvProfileSelectionViewModel( profiles = emptyList(), selectedProfileId = null, pinProfile = null, + pinForManagement = false, isVerifyingPin = false, pinError = null, deleteCandidate = null, isManageMode = false, ) } + profileRepository.householdManagement.end() loadProfiles() return@launch } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ProfileModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ProfileModels.kt index 75035acb48..fa24c43240 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ProfileModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ProfileModels.kt @@ -123,3 +123,10 @@ data class VerifyPinResponse( */ fun VerifyPinResponse.authorizedProfileToken(): String? = profileToken?.takeIf { valid && it.isNotBlank() } + +/** + * The household's primary profile, the only one the server lets manage the + * other profiles. A one-profile household is its own primary, as on Apple. + */ +fun List.householdPrimary(): Profile? = + firstOrNull { it.isPrimary } ?: singleOrNull() diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ProfileApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ProfileApi.kt index 587513dc24..354cd80dc9 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ProfileApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/ProfileApi.kt @@ -29,17 +29,38 @@ class ProfileApi( // Profile headers remain optional: the picker and first-profile bootstrap // run before selection. When present, retain the captured manager PIN proof. + // [actingAs] replaces only this request's profile headers; the device's + // selected profile is untouched. private suspend inline fun exchange( path: String, method: HttpMethod, status: HttpStatusCode, nonRetryable: Boolean = false, + actingAs: HouseholdManager? = null, + withoutProfile: Boolean = false, noinline configure: HttpRequestBuilder.() -> Unit = {}, ): ApiResult { val scope = tokens?.snapshotCurrentScope() if (tokens != null && scope == null) return identityChanged() - return ownedV2Call(apiV2Gate, tokens, scope, OwnerPolicy.IDENTITY, status, { owner -> + // A manager proof belongs to the account it was verified under; never + // present it on another account's request. + if (actingAs?.scope != null && !actingAs.scope.isSameAccountAs(scope)) return identityChanged() + // The pinned path rewrites profile headers from the scope, so the acting + // profile goes into the pinned scope rather than onto the request. + val pinned = when { + scope == null -> null + actingAs != null -> scope.copy(profileId = actingAs.profileId, profileToken = actingAs.profileToken) + withoutProfile -> scope.copy(profileId = null, profileToken = null) + else -> scope + } + return ownedV2Call(apiV2Gate, tokens, pinned, OwnerPolicy.IDENTITY, status, { owner -> client.request(path) { this.method = method owner?.let { authScope(it) } + if (owner == null && actingAs != null) { + headers.remove(PROFILE_ID_HEADER) + headers.remove(PROFILE_TOKEN_HEADER) + header(PROFILE_ID_HEADER, actingAs.profileId) + actingAs.profileToken?.let { header(PROFILE_TOKEN_HEADER, it) } + } requireSiloAuth() if (nonRetryable) singleAttempt() configure() @@ -51,8 +72,11 @@ class ProfileApi( exchange("/api/v2/profiles", HttpMethod.Get, HttpStatusCode.OK) .map { ProfilesResponse(it.items.map { profile -> profile.toProfile() }) } - suspend fun createProfile(request: CreateProfileRequest): ApiResult = - exchange("/api/v2/profiles", HttpMethod.Post, HttpStatusCode.Created, nonRetryable = true) { + suspend fun createProfile( + request: CreateProfileRequest, + actingAs: HouseholdManager? = null, + ): ApiResult = + exchange("/api/v2/profiles", HttpMethod.Post, HttpStatusCode.Created, nonRetryable = true, actingAs = actingAs) { contentType(ContentType.Application.Json) // Create does not accept null; library identifiers are v2 strings. val fields = SiloJson.encodeToJsonElement(CreateProfileRequest.serializer(), request).jsonObject @@ -66,29 +90,81 @@ class ProfileApi( // PATCH v2 only; a failed mutation is never replayed. suspend fun updateProfile( id: String, - request: UpdateProfileRequest - ): ApiResult = updateProfile(id, request.toProfileUpdate()) + request: UpdateProfileRequest, + actingAs: HouseholdManager? = null, + ): ApiResult = updateProfile(id, request.toProfileUpdate(), actingAs) suspend fun updateProfile( id: String, update: ProfileUpdate, + actingAs: HouseholdManager? = null, ): ApiResult = - exchange("/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Patch, HttpStatusCode.OK, nonRetryable = true) { + exchange( + "/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Patch, HttpStatusCode.OK, + nonRetryable = true, actingAs = actingAs, + ) { contentType(ContentType.Application.Json) setBody(update.toJsonObject()) }.map { profile -> profile.toProfile() } - suspend fun deleteProfile(id: String): ApiResult = - exchange("/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Delete, HttpStatusCode.NoContent, nonRetryable = true) + suspend fun deleteProfile(id: String, actingAs: HouseholdManager? = null): ApiResult = + exchange( + "/api/v2/profiles/${id.encodeURLPathPart()}", HttpMethod.Delete, HttpStatusCode.NoContent, + nonRetryable = true, actingAs = actingAs, + ) + /** + * Sent with no profile headers, on the account only. Declaring the device's + * selected profile would make the server check that profile's stored token + * first, and a stale one (its PIN or the account's access policy changed + * since) turns every correct PIN into 403 `profile_verification_required`. + */ suspend fun verifyPin(id: String, pin: String): ApiResult = - exchange("/api/v2/profiles/${id.encodeURLPathPart()}/verify-pin", HttpMethod.Post, HttpStatusCode.OK) { + exchange( + "/api/v2/profiles/${id.encodeURLPathPart()}/verify-pin", HttpMethod.Post, HttpStatusCode.OK, + withoutProfile = true, + ) { contentType(ContentType.Application.Json) setBody(VerifyPinRequest(pin)) } } +/** + * The profile household management acts as: the account's primary profile, + * plus the token `verify-pin` issued for it when it has a PIN. The server + * lets only the primary profile create, edit, or delete household profiles. + * + * [scope] is the identity the proof was obtained under. A call made after the + * account or server changed fails with `identity_changed` instead of carrying + * this account's profile proof onto another account's request. + */ +class HouseholdManager( + val profileId: String, + val profileToken: String?, + val scope: AuthScopeSnapshot?, +) { + override fun toString(): String = "HouseholdManager(profileId=, profileToken=, scope=)" +} + +/** + * Same signed-in account on the same server as [current], whichever profile + * the device has selected. The picker clears the selection when the selected + * profile is deleted, and that profile switch must not void the manager's + * proof; a server switch, sign-in, sign-out, or remote-playback overlay does. + */ +internal fun AuthScopeSnapshot.isSameAccountAs(current: AuthScopeSnapshot?): Boolean { + if (current == null || current.serverId != serverId) return false + if (current.credentialGenerationId != credentialGenerationId) return false + // Only a saved account's stamped epoch tells two sign-ins apart. Without + // one (an overlay, or an unstamped scope), keep the full identity check. + if (credentialGenerationId != null || credentialEpoch == 0L) return isSameIdentityAs(current) + return current.credentialEpoch == credentialEpoch +} + +private const val PROFILE_ID_HEADER = "X-Profile-Id" +private const val PROFILE_TOKEN_HEADER = "X-Profile-Token" + /** * v1 request semantics on the v2 PATCH: a null member is omitted (unchanged); * an empty string on a clearable member is the v1 clearing form and becomes a diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HouseholdManagementSession.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HouseholdManagementSession.kt new file mode 100644 index 0000000000..77f34b38d3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HouseholdManagementSession.kt @@ -0,0 +1,177 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.model.profile.VerifyPinResponse +import org.siloserver.silo.model.profile.authorizedProfileToken +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.api.HouseholdManager +import org.siloserver.silo.network.api.isSameAccountAs + +/** + * The profile picker's manage mode: household management acting as the + * account's primary profile, which is the only profile the server lets create, + * edit, or delete the others. + * + * The primary's profile token is held in memory for the session only. It is + * never selected or persisted, and the PIN itself is never stored. + * + * A profile token is valid only for the account's access-policy revision it was + * issued under, and the server bumps that revision whenever an update touches a + * PIN, child flag, rating, advisory, library, or quality limit. So the first + * save of that kind makes the held token stale. [run] answers the resulting + * `profile_verification_required` by asking for the primary's PIN again + * ([reverify]), replacing the token, and retrying the call once. + */ +class HouseholdManagementSession internal constructor( + private val verifyPin: suspend (profileId: String, pin: String) -> ApiResult, + private val captureScope: suspend () -> AuthScopeSnapshot?, +) { + /** The PIN prompt shown while a stale primary token is being replaced. */ + data class Reverify( + val profile: Profile, + val isVerifying: Boolean = false, + val error: String? = null, + /** Bumped on each rejected PIN so the entry clears. */ + val errorCount: Int = 0, + ) + + private class Held(val primary: Profile, val manager: HouseholdManager) + + private val held = MutableStateFlow(null) + private val _reverify = MutableStateFlow(null) + private val pinEntries = Channel(Channel.CONFLATED) + private val renewLock = Mutex() + + private val _isActive = MutableStateFlow(false) + + /** Whether a management session is running; it drops to false when the user cancels a re-prompt. */ + val isActive: StateFlow = _isActive.asStateFlow() + + /** Non-null while the primary's PIN is needed again; render the PIN prompt for it. */ + val reverify: StateFlow = _reverify.asStateFlow() + + /** The manager management calls act as, or null outside a session. */ + val manager: HouseholdManager? get() = held.value?.manager + + /** + * Starts a session as [primary]. [profileToken] is the token `verify-pin` + * issued for it (null when it has no PIN); [scope] is the identity that + * verification ran under. + */ + fun begin(primary: Profile, profileToken: String?, scope: AuthScopeSnapshot?) { + setHeld(Held(primary, HouseholdManager(primary.id, profileToken, scope))) + } + + /** + * Ends the session, abandoning any open re-prompt. The null entry is sent + * unconditionally: a re-prompt that is about to open (its held check + * already passed) reads it and closes instead of waiting for input. + */ + fun end() { + setHeld(null) + pinEntries.trySend(null) + } + + /** A PIN typed into the [reverify] prompt. */ + fun submitPin(pin: String) { + if (_reverify.value?.isVerifying == false) pinEntries.trySend(pin) + } + + /** + * Cancel on the [reverify] prompt: ends the session without retrying. The + * session ends at once, so a verification already in flight cannot renew + * the manager and retry the call after the user cancelled. + */ + fun cancelReverify() { + if (_reverify.value != null) end() + } + + /** + * Runs one management [call] as the held manager. When the server says the + * primary's token is no longer valid, asks for the PIN again and retries + * the call once with the new token. Cancel there ends the session and + * returns [cancelledError]. + */ + suspend fun run(call: suspend (HouseholdManager?) -> ApiResult): ApiResult { + val used = manager + val first = call(used) + if (used == null || !first.needsProfileVerification()) return first + val renewed = renew(used) ?: return cancelledError() + return call(renewed) + } + + private suspend fun renew(failed: HouseholdManager): HouseholdManager? = renewLock.withLock { + // Drain before the held check: an end() after this point leaves its + // null in the channel, so the prompt below closes on it. + while (pinEntries.tryReceive().isSuccess) Unit + val current = held.value ?: return@withLock null + // Another call already asked for the PIN while this one waited. + if (current.manager !== failed) return@withLock current.manager + _reverify.value = Reverify(current.primary) + try { + askUntilVerified(current) + } finally { + _reverify.value = null + } + } + + private suspend fun askUntilVerified(current: Held): HouseholdManager? { + while (true) { + val pin = pinEntries.receive() + if (pin == null) { + if (held.value === current) setHeld(null) + return null + } + _reverify.update { it?.copy(isVerifying = true, error = null) } + val scope = captureScope() + if (!current.isSameAccountAs(scope)) return endFor(current) + val result = verifyPin(current.primary.id, pin) + if (held.value !== current) return null + // The new token must come from the account the session began on. + if (!current.isSameAccountAs(captureScope())) return endFor(current) + val token = (result as? ApiResult.Success)?.data?.authorizedProfileToken() + if (token != null) { + val renewed = Held(current.primary, HouseholdManager(current.primary.id, token, scope)) + setHeld(renewed) + return renewed.manager + } + val message = when (result) { + is ApiResult.Success -> "Wrong PIN. Try again." + is ApiResult.Error -> result.message.ifBlank { "Incorrect PIN" } + is ApiResult.NetworkError -> "Network error. Please try again." + } + _reverify.update { it?.copy(isVerifying = false, error = message, errorCount = it.errorCount + 1) } + } + } + + private fun Held.isSameAccountAs(scope: AuthScopeSnapshot?): Boolean = + manager.scope?.isSameAccountAs(scope) ?: true + + private fun endFor(current: Held): HouseholdManager? { + if (held.value === current) setHeld(null) + return null + } + + private fun setHeld(value: Held?) { + held.value = value + _isActive.value = value != null + } + + companion object { + private val VerificationCodes = setOf("profile_verification_required", "profile_unverified") + + internal fun ApiResult<*>.needsProfileVerification(): Boolean = + this is ApiResult.Error && code == 403 && error in VerificationCodes + + internal fun cancelledError(): ApiResult.Error = + ApiResult.Error(403, "profile_verification_required", "Enter the primary profile's PIN to manage profiles.") + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt index 5e579e1880..4ebfe95b1d 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt @@ -39,14 +39,24 @@ open class ProfileRepository( open suspend fun listProfiles(): ApiResult> = profileApi.listProfiles().map { it.profiles } - /** Creates a new profile. */ + /** + * The picker's manage mode: management calls act as the household's primary + * profile while it runs. Held in memory only; see [HouseholdManagementSession]. + */ + val householdManagement = HouseholdManagementSession( + verifyPin = { id, pin -> profileApi.verifyPin(id, pin) }, + captureScope = { tokenManager.snapshotCurrentScope() }, + ) + + /** Creates a new profile, as the household manager while manage mode is on. */ open suspend fun createProfile(request: CreateProfileRequest): ApiResult = - profileApi.createProfile(request) + householdManagement.run { profileApi.createProfile(request, it) } - /** Updates an existing profile. */ + /** Updates an existing profile, as the household manager while manage mode is on. */ open suspend fun updateProfile(id: String, request: UpdateProfileRequest): ApiResult = - profileApi.updateProfile(id, request) + householdManagement.run { profileApi.updateProfile(id, request, it) } + /** Updates the selected profile as itself, even while manage mode is on. */ suspend fun updateActiveProfile(request: UpdateProfileRequest): ApiResult { val profileId = getActiveProfileId() ?: return ApiResult.Error( @@ -54,12 +64,12 @@ open class ProfileRepository( error = "bad_request", message = "No active profile selected", ) - return updateProfile(profileId, request) + return profileApi.updateProfile(profileId, request) } - /** Deletes a profile by ID. */ + /** Deletes a profile by ID, as the household manager while manage mode is on. */ suspend fun deleteProfile(id: String): ApiResult = - profileApi.deleteProfile(id) + householdManagement.run { profileApi.deleteProfile(id, it) } /** * Verifies a profile's PIN. diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ProfileHouseholdV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ProfileHouseholdV2Test.kt index 5c7ce4d5dd..06a42bd47e 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ProfileHouseholdV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/ProfileHouseholdV2Test.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.* import org.siloserver.silo.model.profile.* import org.siloserver.silo.network.* +import org.siloserver.silo.network.api.HouseholdManager import org.siloserver.silo.network.api.ProfileApi import kotlin.test.* @@ -79,4 +80,76 @@ class ProfileHouseholdV2Test { assertFalse(api.deleteProfile("42") is ApiResult.Success) } finally { c.close() } } + + @Test fun managementActsAsPrimaryWithoutChangingSelection() = runTest { + scope = scope.copy(profileId = "kid", profileToken = "kid-proof") + val manager = HouseholdManager("primary", "primary-proof", scope) + var sends = 0 + val c = client { + sends++ + val pinned = it.attributes[AuthScopeAttributeKey] + assertEquals("primary", pinned.profileId) + assertEquals("primary-proof", pinned.profileToken) + if (it.method == HttpMethod.Delete) respond("", HttpStatusCode.NoContent) + else respond(profile, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted, tokens) + assertIs>(api.updateProfile("42", UpdateProfileRequest(pin = "1234"), manager)) + assertIs>(api.deleteProfile("42", manager)) + assertEquals("kid", tokens.snapshotCurrentScope()?.profileId) + // A proof verified under another identity is never sent. + scope = scope.copy(identityGeneration = 2) + assertEquals("identity_changed", assertIs(api.deleteProfile("42", manager)).error) + assertEquals(2, sends) + } finally { c.close() } + } + + @Test fun managementWithoutScopeSetsExplicitHeaders() = runTest { + val c = client { + assertFalse(it.attributes.contains(AuthScopeAttributeKey)) + assertEquals("primary", it.headers["X-Profile-Id"]) + assertNull(it.headers["X-Profile-Token"]) + respond(profile, HttpStatusCode.Created, headersOf(HttpHeaders.ContentType, "application/json")) + } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted) + assertIs>(api.createProfile(CreateProfileRequest("Reader"), HouseholdManager("primary", null, null))) + } finally { c.close() } + } + + @Test fun managerProofSurvivesTheDevicesProfileSwitchButNotASignIn() = runTest { + scope = scope.copy(profileId = "kid", profileToken = "kid-proof", credentialEpoch = 3) + val manager = HouseholdManager("primary", "primary-proof", scope) + var sends = 0 + val c = client { sends++; respond("", HttpStatusCode.NoContent) } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted, tokens) + // The picker cleared the deleted, selected profile: same account. + scope = scope.copy(profileId = null, profileToken = null, identityGeneration = scope.identityGeneration + 1) + assertIs>(api.deleteProfile("42", manager)) + scope = scope.copy(credentialEpoch = 4) + assertEquals("identity_changed", assertIs(api.deleteProfile("42", manager)).error) + scope = scope.copy(credentialEpoch = 3, serverId = "other") + assertEquals("identity_changed", assertIs(api.deleteProfile("42", manager)).error) + assertEquals(1, sends) + } finally { c.close() } + } + + @Test fun verifyPinNeverDeclaresTheSelectedProfile() = runTest { + scope = scope.copy(profileId = "1", profileToken = "stale-proof") + val c = client { + val pinned = it.attributes[AuthScopeAttributeKey] + assertNull(pinned.profileId) + assertNull(pinned.profileToken) + assertEquals(scope.serverId, pinned.serverId) + assertEquals(scope.identityGeneration, pinned.identityGeneration) + respond("""{"valid":true,"profile_token":"fresh","expires_at":null}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + try { + val api = ProfileApi(c, ApiV2Gate.Unrestricted, tokens) + assertEquals("fresh", assertIs>(api.verifyPin("1", "1234")).data.profileToken) + assertEquals("1", tokens.snapshotCurrentScope()?.profileId) + } finally { c.close() } + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HouseholdManagementSessionTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HouseholdManagementSessionTest.kt new file mode 100644 index 0000000000..1b666e3bdf --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HouseholdManagementSessionTest.kt @@ -0,0 +1,111 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.model.profile.VerifyPinResponse +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HouseholdManagementSessionTest { + private val primary = Profile(id = "1", name = "Parent", isPrimary = true, hasPin = true) + private val stale = ApiResult.Error(403, "profile_verification_required", "") + private val pins = mutableListOf() + private val session = HouseholdManagementSession( + verifyPin = { _, pin -> + pins += pin + ApiResult.Success( + if (pin == "1234") VerifyPinResponse(valid = true, profileToken = "fresh") else VerifyPinResponse(valid = false), + ) + }, + captureScope = { null }, + ) + + @Test fun staleTokenReasksPinAndRetriesOnce() = runTest { + session.begin(primary, "old", null) + val tokensSent = mutableListOf() + val result = async { + session.run { manager -> + tokensSent += manager?.profileToken + if (manager?.profileToken == "old") stale else ApiResult.Success(Unit) + } + } + runCurrent() + assertEquals(primary, assertNotNull(session.reverify.value).profile) + session.submitPin("0000") + runCurrent() + assertEquals("Wrong PIN. Try again.", session.reverify.value?.error) + session.submitPin("1234") + runCurrent() + assertIs>(result.await()) + assertEquals>(listOf("old", "fresh"), tokensSent) + assertEquals(listOf("0000", "1234"), pins) + assertEquals("fresh", session.manager?.profileToken) + assertNull(session.reverify.value) + assertTrue(session.isActive.value) + } + + @Test fun cancelEndsTheSessionWithoutRetrying() = runTest { + session.begin(primary, "old", null) + var calls = 0 + val result = async { session.run { calls++; stale } } + runCurrent() + session.cancelReverify() + runCurrent() + assertEquals("profile_verification_required", assertIs(result.await()).error) + assertEquals(1, calls) + assertFalse(session.isActive.value) + assertNull(session.manager) + assertNull(session.reverify.value) + } + + @Test fun cancelWhileCheckingDoesNotRenewOrRetry() = runTest { + val answer = CompletableDeferred>() + val slow = HouseholdManagementSession(verifyPin = { _, _ -> answer.await() }, captureScope = { null }) + slow.begin(primary, "old", null) + var calls = 0 + val result = async { slow.run { calls++; stale } } + runCurrent() + slow.submitPin("1234") + runCurrent() + assertTrue(assertNotNull(slow.reverify.value).isVerifying) + slow.cancelReverify() + answer.complete(ApiResult.Success(VerifyPinResponse(valid = true, profileToken = "fresh"))) + runCurrent() + assertIs(result.await()) + assertEquals(1, calls) + assertFalse(slow.isActive.value) + assertNull(slow.reverify.value) + } + + @Test fun accountChangeDuringVerificationEndsTheSession() = runTest { + val accountA = AuthScopeSnapshot(serverId = "s1", profileId = null, serverUrl = "https://a", profileToken = null) + var current: AuthScopeSnapshot? = accountA + val switching = HouseholdManagementSession( + verifyPin = { _, _ -> + current = accountA.copy(serverId = "s2", serverUrl = "https://b") + ApiResult.Success(VerifyPinResponse(valid = true, profileToken = "fresh")) + }, + captureScope = { current }, + ) + switching.begin(primary, "old", accountA) + var calls = 0 + val result = async { switching.run { calls++; stale } } + runCurrent() + switching.submitPin("1234") + runCurrent() + assertIs(result.await()) + assertEquals(1, calls) + assertFalse(switching.isActive.value) + assertNull(switching.reverify.value) + } +} From b6a4a9a40d33d3e9a5cc485ee6f6908020386f5f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:54:39 -0400 Subject: [PATCH 40/87] docs: ask for issues instead of unsolicited pull requests (#504) Pull request volume from outside the team has outgrown what maintainers can review carefully. Say that a clear, accurate issue is the most helpful contribution right now, keep pull requests open to contributors with a merged pull request in a Silo repository or a maintainer's request, and close other pull requests without review. CONTRIBUTING.md explains why and lists what a useful issue includes. Co-authored-by: Claude Opus 5.5 (1M context) --- CONTRIBUTING.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 12 +++++++++--- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 156d77c226..c6ff325490 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,57 @@ # Contributing to Silo Android +> [!IMPORTANT] +> The most helpful way to contribute to Silo right now is a clear, accurate +> issue. Pull requests are welcome from contributors who've had one merged in a +> Silo repository, or when a maintainer asks for one. We close other pull +> requests without review. + +## Why we're asking for issues + +Pull requests now arrive faster than we can review them carefully. Reviewing a +change properly means reading every line, checking it against work already in +flight, re-running validation, and owning the result after it merges. Writing +the change ourselves from a precise issue takes less time, and it keeps every +change on one workflow with the same tests and the same review. + +This is about review capacity, not the quality of anyone's work, and it applies +whether or not you used AI. We'll revisit it as Silo approaches 1.0. Thank you +for taking the time to write things up well. + +## Write a useful issue + +Open an [issue](https://github.com/Silo-Server/silo-android/issues) for problems +in the Android phone and TV apps. Server and API problems belong in +[`silo-server`](https://github.com/Silo-Server/silo-server/issues/new/choose). +Search first; if an issue already covers the problem, add what's new there. + +- One problem or proposal per issue. +- Describe what you observed before any theory about the cause. +- Give exact steps to reproduce, expected and actual behavior, the app version, + your device (phone or TV) and Android version, and the Silo server version. +- Paste raw logs rather than a summary. Redact credentials, tokens, personal + data, and private media details, mark each redaction, and leave the rest + untouched. +- For a feature, describe the problem it solves and who it affects. +- If you found the cause or have a fix in mind, add it under a Technical notes + heading, apart from what you observed. Point to the files involved; a short + code excerpt is fine. We may implement it differently. +- Disclose AI use, as described in the + [Silo contribution guide](https://github.com/Silo-Server/.github/blob/main/CONTRIBUTING.md#ai-assisted-contributions). + +Report security vulnerabilities privately with **Report a vulnerability** on +the repository's Security tab, not in a public issue. + +## Pull requests + +Pull requests are welcome from contributors who've had a pull request merged in +a Silo repository, and from anyone a maintainer has asked for one, usually in +an issue comment. The rest of this guide applies to those pull requests. + +We close other pull requests without review. That isn't a judgment of the +work. If the problem still matters, open an issue for it and link the closed +pull request; we may use the code as a reference. + The [Silo contribution guide](https://github.com/Silo-Server/.github/blob/main/CONTRIBUTING.md) covers project-wide coordination, focused changes, evidence, AI disclosure, and pull request expectations. Those requirements apply here; this guide adds the diff --git a/README.md b/README.md index 75962270fe..c8f638beba 100644 --- a/README.md +++ b/README.md @@ -226,9 +226,15 @@ On first launch, point the app at your Silo server URL, sign in, and pick a prof ## Contributing -Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Features, -navigation or behavior changes, large refactors, and shared contract changes -should start as an issue. +Right now, the most helpful way to contribute is a clear, accurate issue. We +implement and review changes through our own workflow, and pull request volume +has outgrown what we can review carefully, so a good issue usually helps us +more than a pull request. + +Pull requests are still welcome from contributors who've had one merged in a +Silo repository, or when a maintainer asks for one. We close other pull +requests without review. [CONTRIBUTING.md](CONTRIBUTING.md) explains what makes +an issue useful. --- From 27f30c397c65d45c3a21873a460796862be1909b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:29:26 -0400 Subject: [PATCH 41/87] docs(issues): add bug report and feature request forms (#505) The repository had no issue forms. The bug form asks for the device, app version and build, install source, server version, player stats, and the diagnostics reference, and says where to find each in the app. Contact links route server problems, questions, and security reports, and CONTRIBUTING.md points to the troubleshooting skill. Co-authored-by: Claude Opus 5.5 (1M context) --- .github/ISSUE_TEMPLATE/bug-report.yml | 154 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 20 +++ .github/ISSUE_TEMPLATE/feature-request.yml | 97 +++++++++++++ CONTRIBUTING.md | 5 + 4 files changed, 276 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000000..43999f16db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,154 @@ +name: Bug report +description: Report something broken in the Android phone or TV app +title: "[bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Report behavior you reproduced yourself. Keep what you observed separate from any theory about the cause. Redact credentials, tokens, personal data, server addresses, and private media details, and mark each redaction. + + [Reporting a problem](https://siloserver.org/docs/report-a-problem) explains where to find versions, stats, and diagnostics. Using Claude Code or Codex? The [Silo troubleshooting skill](https://github.com/Silo-Server/silo-troubleshooting-skill) helps you collect the details and drafts this report, then has independent reviewers check it before you post. Problems that also happen in the web app usually come from the server and belong in [silo-server](https://github.com/Silo-Server/silo-server/issues/new/choose). Problems in the Apple apps belong in [silo-apple](https://github.com/Silo-Server/silo-apple/issues/new/choose). + + Fabricated or AI-hallucinated reports are an immediate block. Read [CONTRIBUTING.md](https://github.com/Silo-Server/silo-android/blob/main/CONTRIBUTING.md) first. + - type: textarea + id: what-happened + attributes: + label: What happened + description: What did you actually observe? Include how often it happens (for example "3 of 3 attempts") and what still works, such as the same item in the web app. + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce + description: Exact steps you ran, from a stated starting point. + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What should have happened, and where that expectation comes from. + validations: + required: true + - type: dropdown + id: device-type + attributes: + label: Device + multiple: true + options: + - "Phone" + - "Tablet" + - "Android TV / Google TV" + - "Other" + validations: + required: true + - type: input + id: device-details + attributes: + label: Device model and OS version + placeholder: NVIDIA SHIELD TV Pro, Android TV 11 + validations: + required: true + - type: input + id: app-version + attributes: + label: App version and build + description: "**Settings > About > Version** on phones and tablets, or **Settings > Server > About > App Version** on TVs." + placeholder: "0.3.11 (5)" + validations: + required: true + - type: dropdown + id: install-source + attributes: + label: Installed from + options: + - "Google Play" + - "APK from GitHub Releases" + - "Built from source" + - "Other" + validations: + required: true + - type: input + id: server-version + attributes: + label: Silo server version + description: The **Build** value at the bottom of the server's admin sidebar, for example `412 · 1a2b3c4d`. Ask your server admin if that is not you. + validations: + required: true + - type: textarea + id: playback-details + attributes: + label: Playback details + description: "For playback problems. On phones, **Playback settings** > **Playback stats**. On TVs, the **Stats** tab; the **Audio** tab shows whether audio is passed through or decoded to PCM. Include the delivery or route, container, video, audio, and dynamic range, plus the subtitle format and your audio setup (TV speakers, soundbar, receiver)." + - type: input + id: diagnostics-reference + attributes: + label: Diagnostics report reference + description: "In the app, open **Settings > Support > Diagnostics** (TV: **Settings > Diagnostics**), send to **Silo Diagnostics**, and choose **Copy reference ID**. **Start Diagnostic Capture** records while you reproduce the problem." + placeholder: SILO-XXXXXXXX + - type: textarea + id: screenshots + attributes: + label: Screenshots or recording + description: Optional. Drag files here. Crop or blur server addresses, account names, and anything else private first. + - type: textarea + id: relevant-logs + attributes: + label: Relevant server logs + description: Optional. Raw lines from the server's **Admin > Logs**, filtered by playback session when you can. + render: shell + - type: textarea + id: technical-notes + attributes: + label: Technical notes + description: Optional suspected root cause or related observations. Keep this separate from what you observed above. + - type: input + id: ai-harness + attributes: + label: AI harness + description: Enter the exact agent harness or application, or "none". + validations: + required: true + - type: input + id: ai-tools + attributes: + label: AI tool(s) + description: Enter the exact tool name(s), or "none". + validations: + required: true + - type: input + id: ai-models + attributes: + label: AI model(s) + description: Enter the exact model identifier(s) reported by each tool, or "n/a". + validations: + required: true + - type: dropdown + id: ai-involvement + attributes: + label: AI involvement + options: + - No AI used + - Human-written, AI-reviewed + - AI-assisted + - Fully AI-generated, human verified + validations: + required: true + - type: textarea + id: ai-review + attributes: + label: Independent or adversarial review + description: Summarize the scope, method, findings, and resolutions, or enter "n/a" when this issue does not require independent or adversarial review. + validations: + required: true + - type: checkboxes + id: confirmations + attributes: + label: Confirmations + options: + - label: I searched open and recently closed issues and pull requests, and this is not already reported + required: true + - label: I reproduced this myself on the device and app version above + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..f2d7772e6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,20 @@ +blank_issues_enabled: false +contact_links: + - name: Problem with the server or web app + url: https://github.com/Silo-Server/silo-server/issues/new/choose + about: Report it in silo-server, especially if it also happens in the web app. + - name: Problem in the iOS, tvOS, or macOS app + url: https://github.com/Silo-Server/silo-apple/issues/new/choose + about: Report it in silo-apple. + - name: Questions and setup help + url: https://discord.gg/siloserver + about: Ask how to set up or use Silo in the Silo Discord. + - name: Report a security vulnerability + url: https://github.com/Silo-Server/silo-android/security/advisories/new + about: Report it privately. Do not open a public issue. + - name: How to report a problem + url: https://siloserver.org/docs/report-a-problem + about: Where to find versions, player stats, and diagnostics, and what to include. + - name: Troubleshoot with an AI agent + url: https://github.com/Silo-Server/silo-troubleshooting-skill + about: Use Claude Code or Codex to collect the details and draft a reviewed report. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000000..0780feb8f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,97 @@ +name: Feature request +description: Suggest an improvement to the Android phone or TV app +title: "[feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + A request is most useful when it explains the problem you hit, with a concrete example, rather than prescribing a solution. One request per issue. Requests that need new server behavior belong in [silo-server](https://github.com/Silo-Server/silo-server/issues/new/choose). + + Silo will not accept Live TV, OTA/DVB tuners, IPTV, EPG/XMLTV guides, DVR, or `.strm` remote-stream files; [Project non-goals](https://github.com/Silo-Server/silo-server/blob/main/docs/non-goals.md) explains why. + - type: textarea + id: problem + attributes: + label: Problem + description: What you are trying to do, and what stops you today. Give a concrete example. + validations: + required: true + - type: dropdown + id: device-type + attributes: + label: Device + multiple: true + options: + - "Phone" + - "Tablet" + - "Android TV / Google TV" + - "Other" + validations: + required: true + - type: textarea + id: current-workaround + attributes: + label: Current workaround + description: What you do instead today, or "none". + validations: + required: true + - type: textarea + id: proposed-behavior + attributes: + label: Proposed behavior + description: What you would like the app to do. Describe the outcome, not an implementation. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches, including other apps, and why they fall short. + - type: input + id: ai-harness + attributes: + label: AI harness + description: Enter the exact agent harness or application, or "none". + validations: + required: true + - type: input + id: ai-tools + attributes: + label: AI tool(s) + description: Enter the exact tool name(s), or "none". + validations: + required: true + - type: input + id: ai-models + attributes: + label: AI model(s) + description: Enter the exact model identifier(s) reported by each tool, or "n/a". + validations: + required: true + - type: dropdown + id: ai-involvement + attributes: + label: AI involvement + options: + - No AI used + - Human-written, AI-reviewed + - AI-assisted + - Fully AI-generated, human verified + validations: + required: true + - type: textarea + id: ai-review + attributes: + label: Independent or adversarial review + description: Summarize the scope, method, findings, and resolutions, or enter "n/a" when this issue does not require independent or adversarial review. + validations: + required: true + - type: checkboxes + id: confirmations + attributes: + label: Confirmations + options: + - label: I searched open and closed issues, including ones closed as not planned, and this is not already requested + required: true + - label: This is not one of Silo's non-goals + required: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6ff325490..337b0e4633 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,11 @@ in the Android phone and TV apps. Server and API problems belong in [`silo-server`](https://github.com/Silo-Server/silo-server/issues/new/choose). Search first; if an issue already covers the problem, add what's new there. +Using Claude Code or Codex? The +[Silo troubleshooting skill](https://github.com/Silo-Server/silo-troubleshooting-skill) +helps you collect these details and drafts the issue, then has independent +reviewers check it against your evidence before you post it. + - One problem or proposal per issue. - Describe what you observed before any theory about the cause. - Give exact steps to reproduce, expected and actual behavior, the app version, From 8389e5ca432179a370423cbe578f9c24196740c0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:52:01 -0400 Subject: [PATCH 42/87] feat(ui): restyle the player, menus and dialogs on phone and TV (#502) * feat(android): restyle the phone player, menus and dialogs The phone player's controls and track sheets used stock Material components and looked plain next to the iOS player. Player menus now dock on the right in landscape and use a bottom sheet in portrait. They are built on shared player chrome, with Inter for the player, icons in menus, and track rows that split language, chips and details. Scrubbing shows a time bubble with the chapter and offset. Every phone AlertDialog now uses SiloDialog. Every stock dropdown now uses SiloDropdownMenu, with a header, icons and destructive styling. The profile menu shows the active profile. The player gets a Picture in Picture button. Playback results carry the release year for the title line. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(tv): restyle the player options panel, dialogs and menus The TV player's options panel, pickers and confirmation dialogs were translucent and plain next to tvOS. The options panel now sits under a capsule tab rail on a smoked panel, and long lists fade at the edges. - Subtitles: tracks are listed inline. The style sample is cut into the panel so it sits on the live frame. Appearance and Timing open as pages, and Back returns to the row that opened them. - Chapters: a table that opens on the current chapter. - Pickers: an opaque card with a leading check column. TvDialog replaces the settings, Watch Party and request confirmations. Focus starts on the safe action, and destructive actions fill red when focused. Selector menus, the More menu and the profile menu are opaque, show details on their own line, and give actions icons instead of checks. The 11sp eyebrow and 9sp chip styles join the typography floor's approved exceptions. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ui): style the shuffle menu rows and the browse sort menu After the rebase onto the shuffle change, the phone series menu's Shuffle rows were stock Material rows among Silo rows, and the TV More menu's Shuffle rows had no icon beside actions that do. The browse sort menu was the last stock dropdown on phone. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(player): address review findings in the restyled players Phone: - Scrubbing measures the offset and chapter from the current position, not the one the gesture handler started with. - A cancelled scrub ends cleanly and brings the controls back. - Quality no longer hides the controls in a watch party, where it is not drawn. - The last chapter falls back to the title's length when the server gives no end time, and one chapter reads "1 chapter". TV: - The end time accounts for playback speed. - The Subtitles button shows just the track's language. - Left and Right cross the gap between the transport groups instead of jumping to the scrubber. Both: - The "MOVIE" title line reads the catalog item type instead of guessing from the content ID. - The subtitle offsets for a docked menu (phone) and visible controls (TV) are recomputed whenever the player moves or resizes the subtitle canvas. - The joined subtitle label is built from the parsed label parts rather than repeating the parsing. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(player): address review comments on the restyled phone player - Player discs carry their name for screen readers; their glyphs no longer announce on their own. - The left action pills shrink before Quality and the settings disc, so long track or chapter names ellipsize instead of pushing them off. - The subtitle color swatches scroll when a narrow panel cannot fit all nine. - The controls stay up while the seek bar is dragged, so the auto-hide can no longer cancel a scrub and drop its seek. - A disabled dialog button dims its fill as well as its label, and a disabled menu row dims its label. - The "MOVIE" line falls back to the playback state's content type when the watch detail is missing. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- THIRD_PARTY_NOTICES.md | 4 + .../pip/SiloPictureInPictureCoordinator.kt | 27 + .../video/VideoPlaybackSessionCoordinator.kt | 2 + .../player/video/VideoPlaybackStartResult.kt | 4 + .../common/player/video/VideoPlayerUiState.kt | 4 + .../android/cast/CastSubtitleMenuButton.kt | 10 +- .../silo/android/cast/SiloCastButton.kt | 140 +- .../android/ui/components/MediaCardActions.kt | 8 +- .../silo/android/ui/components/ProfileMenu.kt | 41 +- .../ui/components/SiloConfirmDialog.kt | 230 +- .../silo/android/ui/components/SiloMenu.kt | 223 +- .../ui/components/SortFilterControls.kt | 6 +- .../android/ui/components/TopBarActions.kt | 1 + .../components/marquee/MarqueeServerChip.kt | 9 +- .../ui/layout/ResponsiveControlLayout.kt | 16 - .../ui/screens/auth/DevicePairingScreen.kt | 32 +- .../ui/screens/auth/InviteClaimScreen.kt | 22 +- .../android/ui/screens/auth/LoginScreen.kt | 21 +- .../ui/screens/auth/ServerSetupScreen.kt | 29 +- .../android/ui/screens/browse/BrowseScreen.kt | 6 +- .../ui/screens/cast/SiloCastRemoteScreen.kt | 68 +- .../screens/detail/DetailSharedComponents.kt | 54 +- .../android/ui/screens/detail/EpisodeList.kt | 8 +- .../ui/screens/detail/ItemDetailScreen.kt | 42 +- .../ui/screens/detail/MovieDetailContent.kt | 6 +- .../ui/screens/detail/SeriesDetailContent.kt | 18 +- .../ui/screens/downloads/DownloadsScreen.kt | 80 +- .../android/ui/screens/home/HomeScreen.kt | 12 +- .../ui/screens/player/AiTranslateSheet.kt | 50 +- .../ui/screens/player/ChaptersSheet.kt | 294 +- .../ui/screens/player/PlaybackQualitySheet.kt | 125 +- .../ui/screens/player/PlaybackStatsSheet.kt | 130 +- .../android/ui/screens/player/PlayerChrome.kt | 875 ++++++ .../ui/screens/player/PlayerControls.kt | 688 +++-- .../android/ui/screens/player/PlayerMenu.kt | 332 +++ .../ui/screens/player/PlayerOverlay.kt | 613 +++-- .../ui/screens/player/PlayerProgressBar.kt | 482 ++-- .../android/ui/screens/player/PlayerScreen.kt | 50 +- .../ui/screens/player/PlayerSettingsSheet.kt | 578 ++-- .../ui/screens/player/PlayerSheetSupport.kt | 73 +- .../ui/screens/player/PlayerTrackLabels.kt | 114 + .../ui/screens/player/PlayerViewModel.kt | 17 + .../ui/screens/player/SleepTimerSheet.kt | 187 +- .../ui/screens/player/SubtitleSearchSheet.kt | 61 +- .../ui/screens/player/SubtitleStyleSheet.kt | 610 ++--- .../android/ui/screens/player/TracksSheet.kt | 572 ++-- .../ui/screens/player/VersionSelector.kt | 154 +- .../screens/profiles/CreateProfileScreen.kt | 4 +- .../profiles/ProfileSelectionScreen.kt | 37 +- .../ui/screens/requests/MyRequestsScreen.kt | 8 +- .../requests/RequestApprovalsScreen.kt | 22 +- .../screens/requests/RequestDetailScreen.kt | 22 +- .../ui/screens/requests/RequestRows.kt | 8 +- .../ui/screens/servers/ServerListScreen.kt | 92 +- .../ui/screens/settings/SettingsComponents.kt | 2 +- .../ui/screens/settings/SignInSection.kt | 156 +- .../settings/diagnostics/DiagnosticsPrompt.kt | 89 +- .../diagnostics/DiagnosticsReportScreen.kt | 33 +- .../diagnostics/DiagnosticsSettingsScreen.kt | 26 +- .../silo/android/ui/theme/Spacing.kt | 4 +- .../siloserver/silo/android/ui/theme/Type.kt | 14 + .../src/androidMain/res/font/inter_bold.otf | Bin 0 -> 631880 bytes .../src/androidMain/res/font/inter_medium.otf | Bin 0 -> 627988 bytes .../androidMain/res/font/inter_regular.otf | Bin 0 -> 609600 bytes .../androidMain/res/font/inter_semibold.otf | Bin 0 -> 629912 bytes .../ui/layout/ResponsiveControlLayoutTest.kt | 9 - .../ui/components/TvAnchoredSelectorMenu.kt | 99 +- .../silo/tv/ui/components/TvDialog.kt | 238 ++ .../silo/tv/ui/components/TvOptionDialog.kt | 164 +- .../ui/components/TvSelectorRowVisualState.kt | 2 +- .../tv/ui/components/TvSkylinePanelChrome.kt | 11 +- .../ui/screens/detail/TvExpandableSynopsis.kt | 13 +- .../ui/screens/detail/TvItemDetailScreen.kt | 20 +- .../tv/ui/screens/detail/TvMediaInfoDialog.kt | 5 +- .../ui/screens/people/TvPersonDetailScreen.kt | 8 +- .../tv/ui/screens/player/TvPlayerChrome.kt | 86 + .../silo/tv/ui/screens/player/TvPlayerHud.kt | 2354 +++++++++-------- .../tv/ui/screens/player/TvPlayerScreen.kt | 183 +- .../tv/ui/screens/player/TvPlayerScrubber.kt | 231 +- .../player/TvPlayerTransportCluster.kt | 128 +- .../tv/ui/screens/player/TvPlayerViewModel.kt | 4 + .../ui/screens/player/TvSkipSeekIndicator.kt | 12 +- .../screens/player/TvVideoPlaybackStarter.kt | 2 + .../tv/ui/screens/requests/TvRequestsPage.kt | 75 +- .../ui/screens/settings/TvSettingsScreen.kt | 129 +- .../diagnostics/TvDiagnosticsSettingsPane.kt | 24 +- .../watchparty/TvWatchPartyComponents.kt | 95 +- .../TvHudPickerFocusWiringSourceTest.kt | 2 +- .../ui/theme/TvTypographyReadabilityTest.kt | 4 + .../silo/player/SubtitleTrackDisplayLabel.kt | 66 +- 90 files changed, 6287 insertions(+), 5322 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerChrome.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerMenu.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerTrackLabels.kt create mode 100644 androidApp/src/androidMain/res/font/inter_bold.otf create mode 100644 androidApp/src/androidMain/res/font/inter_medium.otf create mode 100644 androidApp/src/androidMain/res/font/inter_regular.otf create mode 100644 androidApp/src/androidMain/res/font/inter_semibold.otf create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvDialog.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerChrome.kt diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 38a329a6e6..90679eabf2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,6 +22,10 @@ The Dolby Vision bridge AAR is built from the `dolby_vision` crate in [`quietvoi `android-shared/src/androidMain/res/drawable/tmdb_logo.xml` is TMDB's approved "alt short" logo from , converted to a VectorDrawable without changing its shape or colours. It is a trademark of TMDB and is not covered by this repository's license. Silo shows it next to TMDB scores; keep it unmodified and less prominent than Silo's own branding. +## Fonts + +The phone and TV apps bundle [Inter](https://github.com/rsms/inter) (`res/font/inter_*`, including Inter Tight) and [Outfit](https://github.com/Outfitio/Outfit-Fonts) (`res/font/outfit_*.ttf`). Both are distributed under the SIL Open Font License 1.1. + ## Gradle Wrapper The Gradle wrapper scripts retain their upstream Apache-2.0 license. diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pip/SiloPictureInPictureCoordinator.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pip/SiloPictureInPictureCoordinator.kt index 0da1781950..53a77b04ca 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pip/SiloPictureInPictureCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/pip/SiloPictureInPictureCoordinator.kt @@ -121,6 +121,33 @@ class SiloPictureInPictureCoordinator( } } + /** + * Whether the player may show its own Picture in Picture button. Unlike + * auto-enter on leaving the app, a viewer who asks for PiP gets it while + * paused too, so playing is not required here. + */ + fun canOfferPictureInPicture(activity: Activity, surface: SiloPictureInPictureSurface): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + val state = stateBySurface[surface] ?: return false + return siloCanEnterPictureInPicture( + surface = surface, + sdkInt = Build.VERSION.SDK_INT, + deviceSupportsPictureInPicture = activity.supportsPictureInPicture(), + enabled = state.enabled, + videoActive = state.videoActive, + isPlaying = true, + ) + } + + /** Enters PiP because the viewer pressed the player's PiP button. */ + fun enterPictureInPictureOnRequest(activity: Activity, surface: SiloPictureInPictureSurface): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + if (!canOfferPictureInPicture(activity, surface)) return false + val state = stateBySurface[surface] ?: return false + activity.enterPictureInPictureMode(buildParams(activity, surface, state)) + return true + } + fun enterPictureInPictureIfEligible( activity: Activity, surface: SiloPictureInPictureSurface, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt index 44c228c4df..82a604051b 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackSessionCoordinator.kt @@ -47,6 +47,8 @@ class VideoPlaybackSessionCoordinator( chapters = result.chapters, seriesId = result.seriesId, seriesTitle = result.seriesTitle, + year = result.year, + contentType = result.contentType, seasonNumber = result.seasonNumber, episodeNumber = result.episodeNumber, resolvedEpisodeSelection = result.resolvedEpisodeSelection, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt index 93282f7a43..6853545681 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlaybackStartResult.kt @@ -56,6 +56,10 @@ sealed interface VideoPlaybackStartResult { /** TV's target-catalog resolution of [VideoPlaybackStartRequest.episodeSelectionHandoff]. */ val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null, val seriesTitle: String? = null, + /** Release year, for the player's "MOVIE · 2026" line. */ + val year: Int? = null, + /** Catalog item type ("movie", "episode", …); the same line names a film. */ + val contentType: String? = null, ) : VideoPlaybackStartResult data class Error( diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt index 4d2219bb76..7e880ace5b 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/VideoPlayerUiState.kt @@ -86,6 +86,10 @@ sealed interface VideoPlayerUiState { /** Target-catalog decision for the one-shot episode-selection handoff. */ val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null, val seriesTitle: String? = null, + /** Release year, for the player's "MOVIE · 2026" line. */ + val year: Int? = null, + /** Catalog item type ("movie", "episode", …); the same line names a film. */ + val contentType: String? = null, ) : VideoPlayerUiState { override val hasPlayableMedia: Boolean = true diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/CastSubtitleMenuButton.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/CastSubtitleMenuButton.kt index 7a6cdb94ee..800c8d1390 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/CastSubtitleMenuButton.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/CastSubtitleMenuButton.kt @@ -1,12 +1,12 @@ package org.siloserver.silo.android.cast +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.material.icons.outlined.ClosedCaptionOff -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -49,8 +49,8 @@ fun CastSubtitleMenuButton( tint = if (tint == Color.Unspecified) LocalContentColor.current else tint, modifier = Modifier.size(iconSize), ) - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( + SiloDropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + SiloDropdownMenuItem( text = { Text("Off") }, leadingIcon = { if (activeId == null) Icon(Icons.Default.Check, contentDescription = null) @@ -61,7 +61,7 @@ fun CastSubtitleMenuButton( }, ) options.forEach { option -> - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(option.label) }, leadingIcon = { if (activeId == option.id) Icon(Icons.Default.Check, contentDescription = null) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastButton.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastButton.kt index 38fad14ed1..0814c8ee4a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastButton.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastButton.kt @@ -1,5 +1,19 @@ package org.siloserver.silo.android.cast +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.material.icons.rounded.Tv +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Cast +import org.siloserver.silo.android.ui.theme.SiloForeground +import org.siloserver.silo.android.ui.components.SiloDialogActionStyle +import org.siloserver.silo.android.ui.components.SiloDialogAction +import org.siloserver.silo.android.ui.components.SiloDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,13 +24,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.CastConnected import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState @@ -68,81 +80,77 @@ fun SiloCastButton( } if (showPicker) { - AlertDialog( + SiloDialog( + title = "Cast to", + message = if (routes.isEmpty()) { + "No cast devices found. Make sure your Chromecast is on the same Wi-Fi network." + } else { + null + }, onDismissRequest = { showPicker = false }, - title = { Text("Cast to") }, - text = { - if (routes.isEmpty()) { - Text( - text = "No cast devices found. Make sure your Chromecast is on the same Wi-Fi network.", - color = MaterialTheme.colorScheme.onSurfaceVariant, + icon = Icons.Rounded.Cast, + actions = buildList { + if (castState.isConnected) { + add( + SiloDialogAction( + label = "Stop casting", + style = SiloDialogActionStyle.Destructive, + onClick = { + castManager.disconnect() + showPicker = false + }, + ), ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - routes.forEachIndexed { index, route -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ) - } - TextButton( - onClick = { + } + add(SiloDialogAction("Close", { showPicker = false })) + }, + ) { + if (routes.isNotEmpty()) { + Column { + routes.forEach { route -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (route.isSelected) Color.White.copy(alpha = 0.07f) else Color.Transparent) + .clickable { // Stage the cast stream, then join the route. onStartCast() castManager.selectRoute(route.id) showPicker = false - }, - modifier = Modifier.fillMaxWidth(), + } + .heightIn(min = 52.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Tv, + contentDescription = null, + tint = SiloForeground.copy(alpha = 0.78f), + modifier = Modifier.size(22.dp), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 14.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = route.name, - color = MaterialTheme.colorScheme.onSurface, - ) - if (route.isConnecting) { - Text( - text = "Connecting…", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - } - if (route.isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - ) - } + Text(text = route.name, color = SiloForeground, fontSize = 15.sp) + if (route.isConnecting) { + Text(text = "Connecting…", fontSize = 12.sp) } } + if (route.isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "Selected", + tint = SiloForeground, + modifier = Modifier.size(20.dp), + ) + } } } } - }, - confirmButton = { - if (castState.isConnected) { - TextButton( - onClick = { - castManager.disconnect() - showPicker = false - }, - ) { Text("Stop casting") } - } else { - TextButton(onClick = { showPicker = false }) { Text("Close") } - } - }, - dismissButton = { - if (castState.isConnected) { - TextButton(onClick = { showPicker = false }) { Text("Close") } - } - }, - ) + } + } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCardActions.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCardActions.kt index 03599cffc6..81bde93d6a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCardActions.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCardActions.kt @@ -8,8 +8,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,7 +34,7 @@ data class MediaCardActions( /** * Long-press context menu for [MediaCard] and [BackdropCard]. Renders as a - * Material 3 [DropdownMenu] anchored to the card. Each item invokes the + * [SiloDropdownMenu] anchored to the card. Each item invokes the * matching callback in [actions] then closes the menu. */ @Composable @@ -50,7 +48,7 @@ fun MediaCardContextMenu( ) { if (actions.isEmpty) return - DropdownMenu( + SiloDropdownMenu( expanded = expanded, onDismissRequest = onDismiss, ) { @@ -99,7 +97,7 @@ fun MediaCardContextMenu( @Composable private fun MenuRow(text: String, icon: ImageVector, onClick: () -> Unit) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(text) }, leadingIcon = { Icon(icon, contentDescription = null) }, onClick = onClick, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt index 24e07a007f..5cc6c677e6 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/ProfileMenu.kt @@ -5,7 +5,21 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import org.siloserver.silo.android.ui.theme.SiloDestructive +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Logout +import androidx.compose.material.icons.rounded.Dns +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.Inbox +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.SwitchAccount +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject +import org.siloserver.silo.android.ui.screens.profiles.ProfileAvatar +import org.siloserver.silo.common.ui.components.avatarRef +import org.siloserver.silo.common.ui.marquee.ServerBranding +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.network.ServerRegistry /** * The profile-avatar dropdown, in one place. @@ -36,8 +50,12 @@ fun ProfileMenu( onSignOutClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, onWatchPartyClick: (() -> Unit)? = null, + // Who is signed in, for the header. Null hides the header. + activeProfile: Profile? = null, ) { var confirmSignOut by rememberSaveable { mutableStateOf(false) } + val serverRegistry: ServerRegistry = koinInject() + val activeServer by serverRegistry.activeEntry.collectAsState() // Whether anything sits above the account actions. // @@ -54,9 +72,22 @@ fun ProfileMenu( expanded = expanded, onDismissRequest = onDismissRequest, ) { + if (activeProfile != null) { + SiloMenuHeader( + title = activeProfile.name, + detail = activeServer?.let { server -> + listOf(server.displayName, ServerBranding.hostLabel(server.url)).distinct().joinToString(" · ") + }, + leading = { + ProfileAvatar(avatar = activeProfile.avatarRef(), name = activeProfile.name, size = 40.dp) + }, + ) + SiloMenuDivider() + } if (onRequestsClick != null) { SiloMenuItem( label = "Requests", + icon = Icons.Rounded.Inbox, onClick = { onDismissRequest() onRequestsClick() @@ -66,6 +97,7 @@ fun ProfileMenu( if (onWatchPartyClick != null) { SiloMenuItem( label = "Watch Party", + icon = Icons.Rounded.Groups, onClick = { onDismissRequest() onWatchPartyClick() @@ -74,6 +106,7 @@ fun ProfileMenu( } SiloMenuItem( label = "Settings", + icon = Icons.Rounded.Settings, showDivider = hasFeatureGroup, onClick = { onDismissRequest() @@ -82,6 +115,7 @@ fun ProfileMenu( ) SiloMenuItem( label = "Switch profile", + icon = Icons.Rounded.SwitchAccount, onClick = { onDismissRequest() onSwitchProfileClick() @@ -89,6 +123,7 @@ fun ProfileMenu( ) SiloMenuItem( label = "Switch server", + icon = Icons.Rounded.Dns, onClick = { onDismissRequest() onSwitchServerClick() @@ -96,7 +131,9 @@ fun ProfileMenu( ) SiloMenuItem( label = "Sign out", - labelColor = SiloDestructive, + icon = Icons.AutoMirrored.Rounded.Logout, + destructive = true, + showDivider = true, onClick = { onDismissRequest() confirmSignOut = true diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloConfirmDialog.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloConfirmDialog.kt index 23af56a4ee..1c60e5e311 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloConfirmDialog.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloConfirmDialog.kt @@ -1,29 +1,189 @@ package org.siloserver.silo.android.ui.components +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.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Logout +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.siloserver.silo.android.ui.theme.SettingsDimens +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +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.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import org.siloserver.silo.android.ui.theme.SiloDestructive import org.siloserver.silo.android.ui.theme.SiloForeground -import org.siloserver.silo.android.ui.theme.SiloMutedText -import org.siloserver.silo.android.ui.theme.SiloSurfaceContainer + +/** How a dialog button reads: the safe default, the one recommended action, or a destructive one. */ +enum class SiloDialogActionStyle { Default, Primary, Destructive } + +data class SiloDialogAction( + val label: String, + val onClick: () -> Unit, + val style: SiloDialogActionStyle = SiloDialogActionStyle.Default, + val enabled: Boolean = true, +) + +/** The dialog card's palette: one raised surface over a dim, Paper and red for actions. */ +internal object SiloDialogColors { + val Surface = Color(0xFF1C1C1F) + val Stroke = Color.White.copy(alpha = 0.06f) + val Body = SiloForeground.copy(alpha = 0.62f) + val DefaultButton = Color.White.copy(alpha = 0.08f) + val Paper = Color(0xFFEDEDED) + val Ink = Color(0xFF0B0B0C) +} /** - * The one confirmation dialog. + * The one dialog. A 28dp card with an optional tinted icon, a title, a body, + * and full-width buttons stacked in the order given — the recommended or + * destructive action first, the safe way out last. Stock M3 puts small text + * buttons in the bottom corner; these are easy to reach with a thumb and say + * how dangerous they are by their fill. * - * Extracted from the "Remove all downloads?" dialog that was inline in - * `SettingsScreen`, because sign-out now needs the identical gate from two - * unrelated places and a third hand-rolled `AlertDialog` is how a surface ends - * up with three reds and three button orders. + * [content] carries anything beyond a sentence of body copy: text fields, a + * short list. It scrolls when a small window cannot fit it. + */ +@Composable +fun SiloDialog( + title: String, + onDismissRequest: () -> Unit, + actions: List, + modifier: Modifier = Modifier, + message: String? = null, + icon: ImageVector? = null, + iconTint: Color = SiloForeground, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val shape = RoundedCornerShape(28.dp) + Column( + modifier = modifier + .padding(horizontal = 22.dp, vertical = 24.dp) + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(shape) + .background(SiloDialogColors.Surface) + .border(1.dp, SiloDialogColors.Stroke, shape) + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 20.dp), + ) { + if (icon != null) { + Box( + modifier = Modifier + .padding(bottom = 16.dp) + .size(44.dp) + .clip(CircleShape) + .background(iconTint.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon(imageVector = icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp)) + } + } + Text( + text = title, + color = SiloForeground, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Medium, + ) + if (!message.isNullOrBlank()) { + Text( + text = message, + color = SiloDialogColors.Body, + fontSize = 15.sp, + lineHeight = 22.sp, + modifier = Modifier.padding(top = 10.dp), + ) + } + if (content != null) { + CompositionLocalProvider( + LocalContentColor provides SiloDialogColors.Body, + LocalTextStyle provides TextStyle(fontSize = 15.sp, lineHeight = 22.sp), + ) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } + } + if (actions.isNotEmpty()) { + Column( + modifier = Modifier.padding(top = 22.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + actions.forEach { action -> SiloDialogButton(action) } + } + } + } + } +} + +@Composable +private fun SiloDialogButton(action: SiloDialogAction) { + val (fill, textColor) = when (action.style) { + SiloDialogActionStyle.Primary -> SiloDialogColors.Paper to SiloDialogColors.Ink + SiloDialogActionStyle.Destructive -> SiloDestructive to Color.White + SiloDialogActionStyle.Default -> SiloDialogColors.DefaultButton to SiloForeground + } + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + // Before the fill, so a disabled button dims as a whole, not just its label. + .alpha(if (action.enabled) 1f else 0.4f) + .clip(RoundedCornerShape(24.dp)) + .background(fill) + .clickable(enabled = action.enabled, role = Role.Button, onClick = action.onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = action.label, + color = textColor, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + } +} + +/** + * The one confirmation dialog: a question, what it does, and two buttons. * - * Cancel is the safe choice and sits where M3 puts the dismissive action; the - * confirm button carries [SiloDestructive] when the action destroys or - * discards something. + * Cancel is the safe choice and sits last, under the action. The action is + * filled red when it destroys or discards something, Paper otherwise. */ @Composable fun SiloConfirmDialog( @@ -36,37 +196,24 @@ fun SiloConfirmDialog( dismissLabel: String = "Cancel", destructive: Boolean = true, confirmEnabled: Boolean = true, + icon: ImageVector? = null, ) { - AlertDialog( + SiloDialog( + title = title, + message = body, onDismissRequest = onDismiss, modifier = modifier, - shape = RoundedCornerShape(SettingsDimens.cardRadius), - containerColor = SiloSurfaceContainer, - titleContentColor = SiloForeground, - textContentColor = SiloMutedText, - title = { Text(title) }, - text = { Text(body) }, - confirmButton = { - TextButton( - enabled = confirmEnabled, + icon = icon, + iconTint = if (destructive) SiloDestructive else SiloForeground, + actions = listOf( + SiloDialogAction( + label = confirmLabel, onClick = onConfirm, - colors = ButtonDefaults.textButtonColors( - contentColor = if (destructive) SiloDestructive else SiloForeground, - disabledContentColor = (if (destructive) SiloDestructive else SiloForeground) - .copy(alpha = SettingsDimens.disabledAlpha), - ), - ) { - Text(confirmLabel) - } - }, - dismissButton = { - TextButton( - onClick = onDismiss, - colors = ButtonDefaults.textButtonColors(contentColor = SiloForeground), - ) { - Text(dismissLabel) - } - }, + style = if (destructive) SiloDialogActionStyle.Destructive else SiloDialogActionStyle.Primary, + enabled = confirmEnabled, + ), + SiloDialogAction(label = dismissLabel, onClick = onDismiss), + ), ) } @@ -109,5 +256,6 @@ fun SignOutConfirmDialog( confirmLabel = "Sign out", onConfirm = onConfirm, onDismiss = onDismiss, + icon = Icons.AutoMirrored.Rounded.Logout, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt index 7e80cebdff..ab87b35a2b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SiloMenu.kt @@ -1,54 +1,62 @@ package org.siloserver.silo.android.ui.components import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties import org.siloserver.silo.android.ui.theme.MenuDimens import org.siloserver.silo.android.ui.theme.SettingsTextStyles -import org.siloserver.silo.android.ui.theme.SiloBorder +import org.siloserver.silo.android.ui.theme.SiloDestructive import org.siloserver.silo.android.ui.theme.SiloForeground -import org.siloserver.silo.android.ui.theme.SiloSurfaceContainer -import org.siloserver.silo.android.ui.theme.siloRowTopDivider -// The popup half of the grouped-surface pass. -// -// A stock `DropdownMenu` full of `DropdownMenuItem`s renders on M3's own -// container colour at M3's own radius with M3's own `bodyLarge` label, which -// is why the profile menu read as visibly cheaper than the settings screen it -// opens. These two primitives put a menu on the same surface, radius, hairline -// and label type as a settings card — and nothing else: no descriptions, no -// leading icons, no trailing controls. A menu is terse by definition, and the -// settings rows dropped their leading icons for exactly the reason a menu -// should not gain them. +// Menus share one surface with the dialogs: the raised card colour, a faint +// hairline, a 20dp radius and a real shadow, because a popup floats over +// artwork. Rows are 48dp with an optional leading icon — the Apple clients +// label menu items with symbols, and an icon is how a viewer finds "Sign out" +// in a list without reading it. Destructive rows are red, icon and all. + +internal object SiloMenuColors { + val Surface = Color(0xFF1C1C1F) + val Stroke = Color.White.copy(alpha = 0.07f) + val Icon = SiloForeground.copy(alpha = 0.78f) + val Detail = SiloForeground.copy(alpha = 0.58f) + val Separator = Color.White.copy(alpha = 0.07f) + val Selected = Color.White.copy(alpha = 0.07f) +} /** - * A [DropdownMenu] wearing the grouped-surface treatment. - * - * Every colour the popup paints is passed explicitly. M3 would otherwise - * default `containerColor` to `surfaceContainer` and `tonalElevation` to 3dp — - * currently harmless (the scheme's `surfaceContainer` *is* the card colour and - * its `surfaceTint` is transparent, so the tonal overlay resolves to nothing), - * but harmless by coincidence of three unrelated theme values. Naming them - * keeps a future tweak to any one of those from quietly re-tinting every menu. - * - * The drop shadow is left at the M3 default: a popup floats over artwork and - * needs the lift, and unlike the tonal overlay a shadow does not push the - * surface colour off-palette. + * A [DropdownMenu] wearing the Silo menu surface. Takes the same parameters as + * the Material menu it wraps, so any stock menu can switch to it by name. */ @Composable fun SiloDropdownMenu( @@ -56,58 +64,169 @@ fun SiloDropdownMenu( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, offset: DpOffset = DpOffset(0.dp, 0.dp), + scrollState: ScrollState = rememberScrollState(), + properties: PopupProperties = PopupProperties(focusable = true), content: @Composable ColumnScope.() -> Unit, ) { DropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, - modifier = modifier, + modifier = modifier.widthIn(min = MenuDimens.minWidth), offset = offset, + scrollState = scrollState, + properties = properties, shape = RoundedCornerShape(MenuDimens.cornerRadius), - containerColor = SiloSurfaceContainer, + containerColor = SiloMenuColors.Surface, tonalElevation = 0.dp, - border = BorderStroke(MenuDimens.borderThickness, SiloBorder), + shadowElevation = 12.dp, + border = BorderStroke(MenuDimens.borderThickness, SiloMenuColors.Stroke), content = content, ) } /** - * One row of a [SiloDropdownMenu]: a label, and nothing else. + * Drop-in replacement for [DropdownMenuItem] with the Silo row: 48dp, the + * menu label type, muted leading icon, and red when [destructive]. + */ +@Composable +fun SiloDropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + destructive: Boolean = false, +) { + val labelColor = if (destructive) SiloDestructive else SiloForeground + DropdownMenuItem( + // No color in the style: the label takes the row's content color, so a + // disabled row dims with disabledTextColor. + text = { ProvideTextStyle(SettingsTextStyles.menuLabel) { text() } }, + onClick = onClick, + modifier = modifier.heightIn(min = MenuDimens.rowMinHeight), + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + enabled = enabled, + colors = MenuDefaults.itemColors( + textColor = labelColor, + leadingIconColor = if (destructive) SiloDestructive else SiloMenuColors.Icon, + trailingIconColor = SiloMenuColors.Detail, + disabledTextColor = labelColor.copy(alpha = 0.38f), + disabledLeadingIconColor = SiloMenuColors.Icon.copy(alpha = 0.38f), + disabledTrailingIconColor = SiloMenuColors.Detail.copy(alpha = 0.38f), + ), + contentPadding = PaddingValues(horizontal = MenuDimens.rowHorizontalPadding), + ) +} + +/** + * One row of a [SiloDropdownMenu]: an optional leading [icon], the label, an + * optional [detail] line, and a check when [checked]. * - * @param showDivider Draws the settings hairline above this row. Menus use it - * to separate groups, not to rule every row — a settings card separates its - * groups by being a different card, which a single popup cannot do. + * @param showDivider Draws a hairline above this row, to separate groups. */ @Composable fun SiloMenuItem( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, - labelColor: Color = SiloForeground, + icon: ImageVector? = null, + detail: String? = null, + checked: Boolean = false, + destructive: Boolean = false, + labelColor: Color = if (destructive) SiloDestructive else SiloForeground, showDivider: Boolean = false, +) { + if (showDivider) SiloMenuDivider() + SiloDropdownMenuItem( + text = { + Column { + Text( + text = label, + color = labelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!detail.isNullOrBlank()) { + Text( + text = detail, + color = SiloMenuColors.Detail, + fontSize = 12.5.sp, + lineHeight = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .then(if (checked) Modifier.background(SiloMenuColors.Selected) else Modifier), + leadingIcon = icon?.let { vector -> + { Icon(imageVector = vector, contentDescription = null, modifier = Modifier.size(22.dp)) } + }, + trailingIcon = if (checked) { + { Icon(Icons.Rounded.Check, contentDescription = "Selected", tint = SiloForeground, modifier = Modifier.size(20.dp)) } + } else { + null + }, + destructive = destructive, + ) +} + +/** Hairline between groups of menu rows. */ +@Composable +fun SiloMenuDivider() { + Box( + modifier = Modifier + .padding(vertical = 4.dp) + .fillMaxWidth() + .height(1.dp) + .background(SiloMenuColors.Separator), + ) +} + +/** + * Who and where, above a menu's actions: an avatar or mark, a name, and a + * detail line such as the server it belongs to. + */ +@Composable +fun SiloMenuHeader( + title: String, + modifier: Modifier = Modifier, + detail: String? = null, + leading: (@Composable () -> Unit)? = null, ) { Row( - // `fillMaxWidth` resolves against the menu's `IntrinsicSize.Max` - // column, so every row ends up as wide as the widest label rather - // than as wide as the window. modifier = modifier .fillMaxWidth() - .widthIn(min = MenuDimens.minWidth) - .heightIn(min = MenuDimens.rowMinHeight) - .siloRowTopDivider(showDivider) - .clickable(onClick = onClick) - .padding( - horizontal = MenuDimens.rowHorizontalPadding, - vertical = MenuDimens.rowVerticalPadding, - ), + .padding(start = MenuDimens.rowHorizontalPadding, end = MenuDimens.rowHorizontalPadding, top = 8.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = label, - style = SettingsTextStyles.menuLabel, - color = labelColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (leading != null) { + leading() + Box(Modifier.size(12.dp)) + } + Column(modifier = Modifier.weight(1f, fill = false)) { + Text( + text = title, + style = SettingsTextStyles.menuLabel.copy(fontSize = 15.sp), + color = SiloForeground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!detail.isNullOrBlank()) { + Text( + text = detail, + color = SiloMenuColors.Detail, + fontSize = 12.5.sp, + lineHeight = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SortFilterControls.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SortFilterControls.kt index 1fa526c6d0..1ee7592fa9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SortFilterControls.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/SortFilterControls.kt @@ -19,8 +19,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton @@ -88,14 +86,14 @@ fun SortFilterControlsRow( trailingChevron = true, onClick = { sortMenuOpen = true }, ) - DropdownMenu( + SiloDropdownMenu( expanded = sortMenuOpen, onDismissRequest = { sortMenuOpen = false }, ) { sortOptions.forEach { option -> val selected = option.id == selectedSortId if (option.dividerAbove) HorizontalDivider() - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text( text = if (selected) option.selectedLabel else option.label, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt index aa4c65c63c..4f968bb536 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/TopBarActions.kt @@ -135,6 +135,7 @@ fun TopBarProfileMenu( onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, onSignOutClick = onSignOutClick, + activeProfile = activeProfile, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt index a9d0dd9c05..370b0f5c96 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/marquee/MarqueeServerChip.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.components.marquee +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -15,8 +17,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.outlined.Dns -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -84,10 +84,9 @@ fun MarqueeServerChip( ) Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = MarqueeColors.InkTertiary, modifier = Modifier.size(16.dp)) } - DropdownMenu( + SiloDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - containerColor = Color(0xFF1C1C1E), ) { Text( hostLabel, @@ -95,7 +94,7 @@ fun MarqueeServerChip( fontSize = 13.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(actionLabel, color = MarqueeColors.Ink) }, leadingIcon = { Icon(Icons.Outlined.Dns, contentDescription = null, tint = MarqueeColors.Ink) }, onClick = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayout.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayout.kt index 5aa387729b..f9ea960e25 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayout.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayout.kt @@ -9,9 +9,6 @@ private const val PreferredTransportSpacingDp = 28f private const val MinimumTransportSpacingDp = 8f private const val ChapterTransportControlWidthDp = 278f private const val CompactTransportControlWidthDp = 182f -private const val MinimumToolbarTitleWidthDp = 96f -private const val ToolbarButtonWidthDp = 48f -private const val ToolbarSpacingDp = 12f internal fun resolveAudiobookTransportLayout( availableWidthDp: Float, @@ -31,16 +28,3 @@ internal fun resolveAudiobookTransportLayout( requiresHorizontalScroll = availableWidthDp < controlWidth + MinimumTransportSpacingDp * gapCount, ) } - -internal fun useCompactPlayerToolbar( - availableWidthDp: Float, - trailingActionCount: Int, -): Boolean { - val buttonCount = trailingActionCount + 1 - val gapCount = trailingActionCount + 1 - val expandedWidth = buttonCount * ToolbarButtonWidthDp + - gapCount * ToolbarSpacingDp + - MinimumToolbarTitleWidthDp - - return expandedWidth > availableWidthDp -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingScreen.kt index cb2597f262..d36e7070ce 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingScreen.kt @@ -1,5 +1,9 @@ package org.siloserver.silo.android.ui.screens.auth +import androidx.compose.material.icons.rounded.Dns +import org.siloserver.silo.common.ui.marquee.ServerBranding +import org.siloserver.silo.android.ui.components.SiloMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import android.text.format.DateUtils import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,8 +29,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -280,17 +282,27 @@ private fun ServerChooser( ) Box { OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { - Text( - text = selected?.displayName.orEmpty(), - modifier = Modifier.weight(1f), - color = AuthColors.OnBackground, - ) + Column(modifier = Modifier.weight(1f)) { + Text(text = selected?.displayName.orEmpty(), color = AuthColors.OnBackground) + // Saved servers often share a name ("Silo"); the address + // is what tells them apart. + selected?.let { server -> + Text( + text = ServerBranding.hostLabel(server.url), + style = MaterialTheme.typography.bodySmall, + color = AuthColors.OnSurfaceVariant, + ) + } + } Icon(Icons.Default.ArrowDropDown, contentDescription = null, tint = AuthColors.OnBackground) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + SiloDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { state.servers.forEach { server -> - DropdownMenuItem( - text = { Text(server.displayName) }, + SiloMenuItem( + label = server.displayName, + detail = ServerBranding.hostLabel(server.url), + icon = Icons.Rounded.Dns, + checked = server.id == selected?.id, onClick = { expanded = false onSelectServer(server.id) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt index cebffcd008..2e8503dca0 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/InviteClaimScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.auth +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -10,11 +11,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -67,17 +66,14 @@ fun InviteClaimScreen( MarqueeErrorHaptic(state.error) state.pendingCleartextOrigin?.let { origin -> - AlertDialog( - onDismissRequest = viewModel::onCancelCleartext, - title = { Text("Connect without encryption?") }, - text = { - Text( - "Your new password will be sent unencrypted to ${ServerBranding.hostLabel(origin)}. " + - "Only do this on a network you trust.", - ) - }, - confirmButton = { TextButton(onClick = viewModel::onConfirmCleartext) { Text("Connect") } }, - dismissButton = { TextButton(onClick = viewModel::onCancelCleartext) { Text("Cancel") } }, + SiloConfirmDialog( + title = "Connect without encryption?", + body = "Your new password will be sent unencrypted to ${ServerBranding.hostLabel(origin)}. " + + "Only do this on a network you trust.", + confirmLabel = "Connect", + destructive = false, + onConfirm = viewModel::onConfirmCleartext, + onDismiss = viewModel::onCancelCleartext, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt index ca70adf3d0..584899b2bd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/LoginScreen.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.auth +import org.siloserver.silo.android.ui.components.SiloDialogAction +import org.siloserver.silo.android.ui.components.SiloDialog import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -22,7 +24,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Person -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -430,19 +431,11 @@ private fun AccountProviderChooser( onChoose: (SignInProvider) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + SiloDialog( + title = "Use a different account", onDismissRequest = onDismiss, - title = { Text("Use a different account") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - providers.forEach { provider -> - TextButton(onClick = { onChoose(provider) }, modifier = Modifier.fillMaxWidth()) { - Text(signInWithLabel(provider)) - } - } - } - }, - confirmButton = {}, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + actions = providers.map { provider -> + SiloDialogAction(label = signInWithLabel(provider), onClick = { onChoose(provider) }) + } + SiloDialogAction(label = "Cancel", onClick = onDismiss), ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt index f3a23c622d..afb9e658ec 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/ServerSetupScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.auth +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState @@ -25,10 +26,8 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -134,23 +133,15 @@ fun ServerSetupScreen( var showAdvanced by rememberSaveable { mutableStateOf(false) } state.pendingCleartextUrl?.let { origin -> - AlertDialog( - onDismissRequest = viewModel::cancelCleartextConnection, - title = { Text("Connect without encryption?") }, - text = { - Text( - "Your password and what you watch will be sent unencrypted to " + - "${ServerBranding.hostLabel(origin)}. Only do this on a network you trust.", - ) - }, - confirmButton = { - TextButton(onClick = viewModel::confirmCleartextConnection, enabled = !state.isLoading) { - Text("Connect") - } - }, - dismissButton = { - TextButton(onClick = viewModel::cancelCleartextConnection) { Text("Cancel") } - }, + SiloConfirmDialog( + title = "Connect without encryption?", + body = "Your password and what you watch will be sent unencrypted to " + + "${ServerBranding.hostLabel(origin)}. Only do this on a network you trust.", + confirmLabel = "Connect", + destructive = false, + confirmEnabled = !state.isLoading, + onConfirm = viewModel::confirmCleartextConnection, + onDismiss = viewModel::cancelCleartextConnection, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt index 4fb6e0bc4b..83046c5a2d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.siloserver.silo.android.ui.components.PosterGridSkeleton +import org.siloserver.silo.android.ui.components.SiloDropdownMenu +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem import org.siloserver.silo.android.ui.components.rememberShimmerProgress import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated import org.siloserver.silo.android.ui.util.formatCardDate @@ -98,13 +100,13 @@ fun BrowseScreen( contentDescription = "Sort", ) } - androidx.compose.material3.DropdownMenu( + SiloDropdownMenu( expanded = sortMenuOpen, onDismissRequest = { sortMenuOpen = false }, ) { browseSortOptions(state.mediaType).forEach { (field, label) -> val active = state.filterState.sort == field - androidx.compose.material3.DropdownMenuItem( + SiloDropdownMenuItem( text = { Text( text = if (active) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt index 6620d5b129..106eb5860f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.android.ui.screens.cast +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -43,10 +46,7 @@ import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.TvOff -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -56,7 +56,6 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -218,34 +217,21 @@ fun SiloCastRemoteScreen( } if (showBatteryPrompt) { - AlertDialog( - onDismissRequest = { - RemoteControlBatteryOptimization.markPromptShown(context) - showBatteryPrompt = false - }, - title = { Text(stringResource(R.string.remote_battery_title)) }, - text = { Text(stringResource(R.string.remote_battery_message)) }, - confirmButton = { - TextButton( - onClick = { - RemoteControlBatteryOptimization.markPromptShown(context) - showBatteryPrompt = false - RemoteControlBatteryOptimization.openSettings(context) - }, - ) { - Text(stringResource(R.string.remote_battery_settings)) - } - }, - dismissButton = { - TextButton( - onClick = { - RemoteControlBatteryOptimization.markPromptShown(context) - showBatteryPrompt = false - }, - ) { - Text(stringResource(R.string.remote_battery_not_now)) - } + val dismissBatteryPrompt = { + RemoteControlBatteryOptimization.markPromptShown(context) + showBatteryPrompt = false + } + SiloConfirmDialog( + title = stringResource(R.string.remote_battery_title), + body = stringResource(R.string.remote_battery_message), + confirmLabel = stringResource(R.string.remote_battery_settings), + dismissLabel = stringResource(R.string.remote_battery_not_now), + destructive = false, + onConfirm = { + dismissBatteryPrompt() + RemoteControlBatteryOptimization.openSettings(context) }, + onDismiss = dismissBatteryPrompt, ) } } @@ -308,8 +294,8 @@ private fun RemoteTopBar( tint = RemoteOnSurface, ) } - DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { - DropdownMenuItem( + SiloDropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + SiloDropdownMenuItem( text = { Text("Choose a Different TV") }, leadingIcon = { Icon(Icons.Outlined.Tv, contentDescription = null) }, onClick = { @@ -317,7 +303,7 @@ private fun RemoteTopBar( onChooseTv() }, ) - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Stop Playback") }, leadingIcon = { Icon(Icons.Filled.Stop, contentDescription = null) }, onClick = { @@ -326,7 +312,7 @@ private fun RemoteTopBar( }, ) if (playback?.supportsVideoGravity == true) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Aspect Ratio") }, leadingIcon = { Icon(Icons.Outlined.AspectRatio, contentDescription = null) @@ -341,7 +327,7 @@ private fun RemoteTopBar( ) } if (showBatterySettings) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(stringResource(R.string.remote_battery_settings)) }, leadingIcon = { Icon(Icons.Outlined.SettingsRemote, contentDescription = null) @@ -353,7 +339,7 @@ private fun RemoteTopBar( ) } HorizontalDivider() - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Disconnect", color = MaterialTheme.colorScheme.error) }, leadingIcon = { Icon( @@ -368,7 +354,7 @@ private fun RemoteTopBar( }, ) } - DropdownMenu( + SiloDropdownMenu( expanded = aspectMenuExpanded && playback?.supportsVideoGravity == true, onDismissRequest = { aspectMenuExpanded = false }, ) { @@ -382,7 +368,7 @@ private fun RemoteTopBar( .forEach { (id, label) -> val selected = playback?.videoGravity == id || (id == "fill" && playback?.videoGravity in listOf("zoom", "crop")) - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(label) }, leadingIcon = { if (selected) { @@ -1005,7 +991,7 @@ private fun RemoteChipMenu( color = if (enabled) RemoteOnSurface.copy(alpha = 0.9f) else RemoteSecondary.copy(alpha = 0.5f), ) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + SiloDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { entries.forEach { entry -> if (entry.isSectionHeader) { Text( @@ -1015,7 +1001,7 @@ private fun RemoteChipMenu( modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), ) } else { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(entry.label) }, enabled = entry.enabled, leadingIcon = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt index b6a6da327d..26c18b7c5d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt @@ -1,5 +1,10 @@ package org.siloserver.silo.android.ui.screens.detail +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu +import org.siloserver.silo.android.ui.components.SiloDialogActionStyle +import org.siloserver.silo.android.ui.components.SiloDialogAction +import org.siloserver.silo.android.ui.components.SiloDialog import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.ExperimentalFoundationApi @@ -38,14 +43,10 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Surface -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -1140,7 +1141,7 @@ fun CircleOverflowButton( } Text("More", fontSize = 10.sp, fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.60f)) } - DropdownMenu( + SiloDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, ) { @@ -1309,26 +1310,27 @@ fun HeroActionStack( if (showResumeDialog && onPlayFromBeginning != null) { val stoppedAt = resumeStoppedAtLabel - AlertDialog( + SiloDialog( + title = "Continue Watching?", + message = stoppedAt?.let { "You stopped at $it." }, onDismissRequest = { showResumeDialog = false }, - title = { Text("Continue Watching?") }, - text = if (stoppedAt != null) { - { Text("You stopped at $stoppedAt.") } - } else { - null - }, - confirmButton = { - TextButton(onClick = { - showResumeDialog = false - onPlay() - }) { Text("Resume") } - }, - dismissButton = { - TextButton(onClick = { - showResumeDialog = false - onPlayFromBeginning() - }) { Text("Play from Beginning") } - }, + actions = listOf( + SiloDialogAction( + label = "Resume", + style = SiloDialogActionStyle.Primary, + onClick = { + showResumeDialog = false + onPlay() + }, + ), + SiloDialogAction( + label = "Play from Beginning", + onClick = { + showResumeDialog = false + onPlayFromBeginning() + }, + ), + ), ) } } @@ -1478,11 +1480,11 @@ fun SeasonChips( ) } } - DropdownMenu( + SiloDropdownMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, ) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(if (isWatched) "Mark Season Unwatched" else "Mark Season Watched") }, onClick = { menuExpanded = false diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt index bbac6431dd..7373aeefbd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.detail +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -28,8 +30,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -322,11 +322,11 @@ private fun EpisodeRailCard( } } } - DropdownMenu( + SiloDropdownMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, ) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(if (isWatched) "Mark as Unwatched" else "Mark as Watched") }, onClick = { menuExpanded = false diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index b23ee13f1f..aa4a54a40a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.android.ui.screens.detail +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -34,15 +37,11 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.SettingsRemote -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -1046,25 +1045,16 @@ fun ItemDetailScreen( } pendingCancelDownloadAction?.let { confirmAction -> - AlertDialog( - onDismissRequest = { pendingCancelDownloadAction = null }, - title = { Text("Cancel download?") }, - text = { Text("The partially downloaded data will be discarded.") }, - confirmButton = { - TextButton( - onClick = { - pendingCancelDownloadAction = null - confirmAction() - }, - ) { - Text("Discard Download") - } - }, - dismissButton = { - TextButton(onClick = { pendingCancelDownloadAction = null }) { - Text("Keep Download") - } + SiloConfirmDialog( + title = "Cancel download?", + body = "The partially downloaded data will be discarded.", + confirmLabel = "Discard Download", + dismissLabel = "Keep Download", + onConfirm = { + pendingCancelDownloadAction = null + confirmAction() }, + onDismiss = { pendingCancelDownloadAction = null }, ) } @@ -1104,18 +1094,18 @@ fun ItemDetailScreen( tint = Color.White, ) } - DropdownMenu( + SiloDropdownMenu( expanded = remoteMenuExpanded, onDismissRequest = { remoteMenuExpanded = false }, ) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Remote Control") }, onClick = { remoteMenuExpanded = false onOpenCastRemote() }, ) - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Choose TV") }, onClick = { remoteMenuExpanded = false @@ -1123,7 +1113,7 @@ fun ItemDetailScreen( }, ) HorizontalDivider() - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Turn Off Control Mode", color = MaterialTheme.colorScheme.error) }, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt index dce4d472fb..c68bd4820c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.detail +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,7 +19,6 @@ import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.Groups import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -220,7 +220,7 @@ fun MovieDetailContent( overflow = if (hasOverflow) { { dismiss -> if (onSeriesClick != null) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Go to Series") }, onClick = { dismiss() @@ -229,7 +229,7 @@ fun MovieDetailContent( ) } if (partyAction != null) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(partyAction.label) }, leadingIcon = { Icon(Icons.Outlined.Groups, contentDescription = null) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt index b9714d36c2..d4a68afc13 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.detail +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,8 +19,6 @@ import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Circle import androidx.compose.material.icons.outlined.ClosedCaption -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -302,7 +302,7 @@ fun SeriesDetailContent( ) { { dismiss -> if (onShuffleSeries != null) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Shuffle Series") }, leadingIcon = { Icon(Icons.Filled.Shuffle, contentDescription = null) }, onClick = { @@ -312,7 +312,7 @@ fun SeriesDetailContent( ) } if (shuffleableSelectedSeason != null && onShuffleSeason != null) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Shuffle ${phoneSeasonLabel(shuffleableSelectedSeason)}") }, leadingIcon = { Icon(Icons.Filled.Shuffle, contentDescription = null) }, onClick = { @@ -323,7 +323,7 @@ fun SeriesDetailContent( } if (markableSelectedSeason != null && onSeasonWatchedChange != null) { val seasonWatched = markableSelectedSeason.userData?.played == true - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text( "Mark ${phoneSeasonLabel(markableSelectedSeason)} " + @@ -347,7 +347,7 @@ fun SeriesDetailContent( ) } if (partyAction != null) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(partyAction.label) }, leadingIcon = { Icon(Icons.Outlined.Groups, contentDescription = null) @@ -385,12 +385,12 @@ fun SeriesDetailContent( } }, ) - DropdownMenu( + SiloDropdownMenu( expanded = showDownloadMenu, onDismissRequest = { showDownloadMenu = false }, ) { if (onEpisodeDownloadClick != null) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(when { episodeDownloadState.isDownloaded -> "Episode downloaded" episodeDownloadState.progress != null -> "Cancel episode download" @@ -404,7 +404,7 @@ fun SeriesDetailContent( ) } if (onSeriesDownloadClick != null) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(if (seriesDownloadState.isDownloaded) "Series downloaded" else "Download series") }, enabled = !seriesDownloadState.isDownloaded, onClick = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt index b3c86e6f5a..b70202acbd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt @@ -1,5 +1,9 @@ package org.siloserver.silo.android.ui.screens.downloads +import org.siloserver.silo.android.ui.components.SiloDialogActionStyle +import org.siloserver.silo.android.ui.components.SiloDialogAction +import org.siloserver.silo.android.ui.components.SiloDialog +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import org.siloserver.silo.android.ui.util.formatBytes import android.content.Context import android.net.ConnectivityManager @@ -25,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.DownloadForOffline -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -272,60 +275,41 @@ fun DownloadsScreen( } pendingBulkDelete?.let { (ids, _) -> - AlertDialog( - onDismissRequest = { pendingBulkDelete = null }, - title = { Text("Delete downloaded files?") }, - text = { - Text( - "This removes the downloaded cop${if (ids.size == 1) "y" else "ies"} from this device. " + - "Nothing is deleted from the server library.", - ) - }, - confirmButton = { - TextButton( - onClick = { - viewModel.removeDownloadIds(ids) - pendingBulkDelete = null - exitSelectMode() - }, - ) { - Text( - text = if (ids.size == 1) "Delete Download" else "Delete ${ids.size} Downloads", - color = MaterialTheme.colorScheme.error, - ) - } - }, - dismissButton = { - TextButton(onClick = { pendingBulkDelete = null }) { Text("Cancel") } + SiloConfirmDialog( + title = "Delete downloaded files?", + body = "This removes the downloaded cop${if (ids.size == 1) "y" else "ies"} from this device. " + + "Nothing is deleted from the server library.", + confirmLabel = if (ids.size == 1) "Delete Download" else "Delete ${ids.size} Downloads", + onConfirm = { + viewModel.removeDownloadIds(ids) + pendingBulkDelete = null + exitSelectMode() }, + onDismiss = { pendingBulkDelete = null }, ) } state.reclaimPlan?.let { plan -> - AlertDialog( - onDismissRequest = viewModel::clearReclaimPlan, - title = { Text("Reclaim Watched") }, - text = { - Text( - if (plan.count == 0) { - "No watched downloads are ready to reclaim." - } else { - "Delete ${plan.count} watched download${if (plan.count == 1) "" else "s"} and free ${formatBytes(plan.totalBytes)}?" - }, - ) + SiloDialog( + title = "Reclaim Watched", + message = if (plan.count == 0) { + "No watched downloads are ready to reclaim." + } else { + "Delete ${plan.count} watched download${if (plan.count == 1) "" else "s"} and free ${formatBytes(plan.totalBytes)}?" }, - confirmButton = { - TextButton( - enabled = plan.count > 0 && !state.isReclaiming, - onClick = viewModel::reclaimWatched, - ) { - Text("Reclaim") - } - }, - dismissButton = { - TextButton(onClick = viewModel::clearReclaimPlan) { - Text(if (plan.count == 0) "Close" else "Cancel") + onDismissRequest = viewModel::clearReclaimPlan, + actions = buildList { + if (plan.count > 0) { + add( + SiloDialogAction( + label = "Reclaim", + onClick = viewModel::reclaimWatched, + style = SiloDialogActionStyle.Destructive, + enabled = !state.isReclaiming, + ), + ) } + add(SiloDialogAction(if (plan.count == 0) "Close" else "Cancel", viewModel::clearReclaimPlan)) }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt index 50daf9b4ea..7fb7ad6e33 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.home +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,8 +27,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.SettingsRemote -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -545,18 +545,18 @@ private fun HomeFloatingChrome( contentDescription = "Remote Control", ) } - DropdownMenu( + SiloDropdownMenu( expanded = remoteMenuExpanded, onDismissRequest = { remoteMenuExpanded = false }, ) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Remote Control") }, onClick = { remoteMenuExpanded = false onRemoteControlClick() }, ) - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Choose TV") }, onClick = { remoteMenuExpanded = false @@ -564,7 +564,7 @@ private fun HomeFloatingChrome( }, ) HorizontalDivider() - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text( "Turn Off Control Mode", diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt index cad7582659..1edc2ab881 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/AiTranslateSheet.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.player +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,9 +20,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -99,35 +100,24 @@ fun AiTranslateSheet( } // On job completion return to the tracks sheet (via onBack) so the freshly // generated track is visible, rather than dropping onto bare player controls. + val controller = rememberPlayerMenuController(onDismiss) + val back = onBack?.let { parent -> { controller.dismiss(then = parent) } } LaunchedEffect(tools.jobJustCompleted) { - if (tools.jobJustCompleted) (onBack ?: onDismiss)() + if (tools.jobJustCompleted) (back ?: { controller.dismiss() })() } - PlayerModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - tabletopPaneHeight = tabletopPaneHeight, - ) { + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerSheetHeader( + title = "Translate with AI", + onBack = back, + onDismiss = { controller.dismiss() }, + ) Column( modifier = Modifier .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ) + .weight(1f, fill = false) .verticalScroll(rememberScrollState()), ) { - PlayerSheetHeader( - title = "Translate with AI", - onBack = onBack, - onDismiss = onDismiss, - ) val activeJob = tools.activeJob when { @@ -254,12 +244,16 @@ fun AiTranslateSheet( !(mode == AiMode.Audio && quotaExhausted) && (if (mode == AiMode.Audio) audioTracks.isNotEmpty() else sourceTracks.isNotEmpty()), modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = PlayerChrome.Paper, + contentColor = PlayerChrome.Ink, + ), ) { if (tools.translateSubmitting) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, - color = Color.White, + color = PlayerChrome.Ink, ) } else { Text(if (mode == AiMode.Audio) "Generate" else "Translate") @@ -268,7 +262,7 @@ fun AiTranslateSheet( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(12.dp)) } } } @@ -310,7 +304,7 @@ private fun PickerRow( Row( modifier = Modifier .fillMaxWidth() - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) + .background(PlayerChrome.Raised, RoundedCornerShape(12.dp)) .clickable { expanded = true } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -328,13 +322,13 @@ private fun PickerRow( tint = Color.White.copy(alpha = 0.7f), ) } - DropdownMenu( + SiloDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 320.dp), ) { options.forEachIndexed { index, option -> - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(option) }, onClick = { onSelect(index) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/ChaptersSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/ChaptersSheet.kt index 2a3aae7b73..0b47898be9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/ChaptersSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/ChaptersSheet.kt @@ -1,49 +1,42 @@ package org.siloserver.silo.android.ui.screens.player -import androidx.compose.foundation.border +import androidx.compose.foundation.background 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.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign 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 kotlinx.coroutines.launch import org.siloserver.silo.android.ui.util.formatClockTime import org.siloserver.silo.model.catalog.VersionChapter -/** Adaptive chapter picker shared by regular phones and foldable tabletop mode. */ -@OptIn(ExperimentalMaterial3Api::class) +/** + * Chapter picker. Number, title, start and length line up in columns; the + * chapter that is playing is marked and the list opens scrolled to it. + */ @Composable fun ChaptersSheet( isVisible: Boolean, @@ -51,175 +44,148 @@ fun ChaptersSheet( onSelect: (chapterIndex: Int) -> Unit, onDismiss: () -> Unit, position: Double = 0.0, + /** The title's length: the last chapter's end when the server gives none. */ + duration: Double = 0.0, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return val currentChapterIndex = chapters.indexOfLast { it.startSeconds <= position } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { - scope.launch { sheetState.hide() } - onDismiss() - } - - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } + val controller = rememberPlayerMenuController(onDismiss) + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = (currentChapterIndex - 2).coerceAtLeast(0), + ) - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader(title = "Chapters", onClose = { controller.dismiss() }) + val current = chapters.getOrNull(currentChapterIndex) + val countLabel = if (chapters.size == 1) "1 chapter" else "${chapters.size} chapters" + Text( + text = if (current != null) { + val left = (chapterEnd(chapters, currentChapterIndex, duration) - position).coerceAtLeast(0.0) + "$countLabel · ${formatClockTime(left)} left in this chapter" + } else { + countLabel + }, + style = PlayerType.RowDetail.copy(fontFeatureSettings = "tnum"), + modifier = Modifier.padding(start = 18.dp, end = 18.dp, bottom = 6.dp), + ) + if (chapters.isEmpty()) { + PlayerFootnote("No chapters are available for this title.") + return@PlayerMenu + } + LazyColumn( + state = listState, modifier = Modifier + .weight(1f, fill = false) .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard), + .playerFadingEdges(listState) + .padding(horizontal = 8.dp), ) { - PlayerSheetHeader( - title = "Chapters", - subtitle = when (chapters.size) { - 1 -> "1 chapter" - else -> "${chapters.size} chapters" - }, - onDismiss = dismissSheet, - ) - - if (chapters.isEmpty()) { - PlayerSheetCard( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = playerSheetHorizontalPadding(tabletopPaneHeight), - vertical = 8.dp, - ), - ) { - Text( - text = "No chapters are available for this title.", - color = Color.White.copy(alpha = 0.62f), - fontSize = 14.sp, - modifier = Modifier.padding(20.dp), - ) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(if (tabletopPaneHeight == null) 1 else 2), - modifier = Modifier - .fillMaxWidth() - .then( - if (tabletopPaneHeight == null) Modifier else Modifier.weight(1f), - ), - contentPadding = PaddingValues( - start = playerSheetHorizontalPadding(tabletopPaneHeight), - end = playerSheetHorizontalPadding(tabletopPaneHeight), - top = 8.dp, - bottom = 24.dp, - ), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - itemsIndexed( - chapters, - key = { _, chapter -> chapter.index }, - contentType = { _, _ -> "chapter-card" }, - ) { index, chapter -> - ChapterCard( - chapter = chapter, - isCurrent = index == currentChapterIndex, - onClick = { - onSelect(index) - dismissSheet() - }, - ) - } - } + itemsIndexed(chapters, key = { _, chapter -> chapter.index }) { index, chapter -> + val length = chapterEnd(chapters, index, duration) - chapter.startSeconds + ChapterRow( + number = index + 1, + title = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, + start = chapter.startSeconds, + length = length, + progress = if (index == currentChapterIndex) { + if (length > 0) ((position - chapter.startSeconds) / length).toFloat() else 0f + } else { + null + }, + onClick = { + onSelect(index) + controller.dismiss() + }, + ) } } } } +private fun chapterEnd(chapters: List, index: Int, duration: Double): Double { + val chapter = chapters[index] + return when { + chapter.endSeconds > chapter.startSeconds -> chapter.endSeconds + index + 1 < chapters.size -> chapters[index + 1].startSeconds + duration > chapter.startSeconds -> duration + else -> chapter.startSeconds + } +} + @Composable -private fun ChapterCard( - chapter: VersionChapter, - isCurrent: Boolean, +private fun ChapterRow( + number: Int, + title: String, + start: Double, + length: Double, + progress: Float?, onClick: () -> Unit, ) { - val shape = RoundedCornerShape(16.dp) - Surface( - color = if (isCurrent) PlayerSheetSelectedColor else PlayerSheetCardColor, - shape = shape, + val isCurrent = progress != null + Column( modifier = Modifier .fillMaxWidth() - .heightIn(min = 72.dp) - .then( + .clip(RoundedCornerShape(12.dp)) + .background(if (isCurrent) PlayerChrome.Raised else androidx.compose.ui.graphics.Color.Transparent) + .clickable(role = Role.Button, onClickLabel = "Play from $title", onClick = onClick) + .heightIn(min = 44.dp) + .padding(horizontal = 10.dp), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 10.dp)) { + Box(modifier = Modifier.width(34.dp)) { if (isCurrent) { - Modifier.border( - width = 1.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), - shape = shape, + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Now playing", + tint = PlayerChrome.Paper, + modifier = Modifier.size(18.dp), ) } else { - Modifier - }, - ) - .clickable(onClick = onClick), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - shape = CircleShape, - color = if (isCurrent) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) - } else { - Color.White.copy(alpha = 0.07f) - }, - modifier = Modifier.size(38.dp), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - if (isCurrent) { - Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = "Now playing", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - } else { - Text( - text = (chapter.index + 1).toString(), - color = Color.White.copy(alpha = 0.72f), - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - } + Text( + text = number.toString().padStart(2, '0'), + style = PlayerType.Value.copy(fontWeight = FontWeight.SemiBold), + ) } } - Column( + Text( + text = title, + style = PlayerType.RowTitle.copy( + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), + ) + Text( + text = formatClockTime(start), + style = PlayerType.Value, + textAlign = TextAlign.End, + modifier = Modifier.width(64.dp), + ) + Text( + text = formatClockTime(length.coerceAtLeast(0.0)), + style = PlayerType.Value, + textAlign = TextAlign.End, + modifier = Modifier.width(56.dp), + ) + } + if (progress != null) { + Box( + modifier = Modifier + .padding(start = 34.dp, end = 120.dp, bottom = 6.dp) + .fillMaxWidth() + .height(2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(androidx.compose.ui.graphics.Color.White.copy(alpha = 0.14f)), ) { - Text( - text = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, - color = Color.White, - fontSize = 15.sp, - fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Medium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = formatClockTime(chapter.startSeconds), - color = Color.White.copy(alpha = 0.52f), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .height(2.dp) + .background(PlayerChrome.Paper), ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt index 97b9b44e86..1a7eb51331 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackQualitySheet.kt @@ -1,35 +1,11 @@ package org.siloserver.silo.android.ui.screens.player -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -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.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -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.siloserver.silo.model.playback.PlaybackQualityOption import org.siloserver.silo.model.playback.activePlaybackQualityId @@ -42,12 +18,11 @@ internal fun PlayerViewModel.PlayerUiState.activeQualityId(menu: List, @@ -59,91 +34,33 @@ fun PlaybackQualitySheet( onDismiss: () -> Unit, tabletopPaneHeight: Dp? = null, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val controller = rememberPlayerMenuController(onDismiss) - PlayerModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .padding(bottom = 24.dp), - ) { - PlayerSheetHeader(title = "Quality", onDismiss = onDismiss) - PlayerSheetDivider() + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader(title = "Quality", onClose = { controller.dismiss() }) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { if (inRoom) { - Text( - text = "Watch Party keeps everyone on the same version. " + + PlayerFootnote( + "Watch Party keeps everyone on the same version. " + "You can adjust your streaming quality below.", - color = Color.White.copy(alpha = 0.5f), - fontSize = 12.sp, - lineHeight = 16.sp, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 4.dp), ) } - LazyColumn { - items(options, key = { it.id }, contentType = { "quality-option" }) { option -> - PlaybackQualityRow( - option = option, - isSelected = option.id == activeId, + Spacer(Modifier.height(8.dp)) + PlayerGroup { + options.forEachIndexed { index, option -> + PlayerCheckRow( + label = option.name, + detail = option.bitrateLabel, + selected = option.id == activeId, onClick = { onSelect(option.id) - onDismiss() + controller.dismiss() }, + separator = index > 0, ) } } - } - } -} - -/** Name on the left; bitrate and the selected check on the right. */ -@Composable -private fun PlaybackQualityRow( - option: PlaybackQualityOption, - isSelected: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .semantics { selected = isSelected } - .heightIn(min = 46.dp) - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = option.name, - color = Color.White, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - option.bitrateLabel?.let { bitrate -> - Text( - text = bitrate, - color = Color.White.copy(alpha = 0.55f), - fontSize = 13.sp, - maxLines = 1, - ) - } - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } else { - Spacer(modifier = Modifier.size(20.dp)) + Spacer(Modifier.height(8.dp)) } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackStatsSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackStatsSheet.kt index eb1d3c795f..a5788783c7 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackStatsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackStatsSheet.kt @@ -1,134 +1,86 @@ package org.siloserver.silo.android.ui.screens.player -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope +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.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign 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.siloserver.silo.common.player.PlayerStatsSnapshot -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaybackStatsSheet( isVisible: Boolean, stats: PlayerStatsSnapshot, onDismiss: () -> Unit, - // Gear-submenu back affordance: dismisses this sheet and reopens the - // parent settings sheet (wired in PlayerOverlay). + // Back affordance: hands over to the settings menu (wired in PlayerOverlay). onBack: (() -> Unit)? = null, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } + val controller = rememberPlayerMenuController(onDismiss) - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF111827).copy(alpha = 0.97f), - Color.Black.copy(alpha = 0.94f), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 24.dp), - ) { - PlayerSheetHeader( - title = "Playback Stats", - onBack = onBack?.let { back -> - { scope.dismissPlayerSheet(sheetState, back) } - }, - onDismiss = dismissSheet, - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = "Current stream", - color = Color.White.copy(alpha = 0.62f), - fontSize = 13.sp, - ) - Spacer(modifier = Modifier.height(18.dp)) - - val rows = stats.mobileStatsRows() + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = "Playback stats", + onClose = { controller.dismiss() }, + onBack = onBack?.let { back -> { controller.dismiss(then = back) } }, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + PlayerSectionHeader("Current stream") + val rows = stats.mobileStatsRows() + PlayerGroup { if (rows.isEmpty()) { - Text( - text = "Waiting for player data", - color = Color.White.copy(alpha = 0.66f), - fontSize = 15.sp, - ) + StatsRow(label = "Waiting for player data", value = "", separator = false) } else { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - rows.forEach { (label, value) -> - StatsRow(label = label, value = value) - } + rows.forEachIndexed { index, (label, value) -> + StatsRow(label = label, value = value, separator = index > 0) } } } + Spacer(Modifier.height(12.dp)) } } } @Composable -private fun StatsRow(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth()) { +private fun StatsRow(label: String, value: String, separator: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .playerGroupSeparator(separator) + .heightIn(min = 40.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = label, - color = Color.White.copy(alpha = 0.62f), - fontSize = 14.sp, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = value, - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + style = PlayerType.RowDetail.copy(fontSize = PlayerType.Value.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + Column(modifier = Modifier.weight(1.4f)) { + Text( + text = value, + style = PlayerType.Value.copy(color = PlayerChrome.Paper), + textAlign = TextAlign.End, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerChrome.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerChrome.kt new file mode 100644 index 0000000000..393663008a --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerChrome.kt @@ -0,0 +1,875 @@ +package org.siloserver.silo.android.ui.screens.player + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +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.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import org.siloserver.silo.android.ui.screens.settings.settingsSwitchColors +import org.siloserver.silo.android.ui.theme.SiloDestructive +import org.siloserver.silo.android.ui.theme.SiloInterFamily + +/** + * The video player's visual language, shared by the controls and every player + * menu. Monochrome like the Apple clients: smoked panels over the picture, + * Paper for whatever is selected or focused, Inter with tabular figures for + * anything that counts time. There is no live blur on Android, so "glass" is a + * dark wash at a fixed opacity — the same fallback iOS uses over video on + * devices that cannot afford to re-blur every frame. + */ +internal object PlayerChrome { + val Paper = Color(0xFFEDEDED) + val Ink = Color(0xFF0B0B0C) + val Graphite = Paper.copy(alpha = 0.60f) + val Faint = Paper.copy(alpha = 0.38f) + + /** Docked panels and sheets over the picture. */ + val Smoke = Color(0xFF131417).copy(alpha = 0.955f) + + /** Resting fill of a disc or pill floating directly on the video. */ + val Disc = Color(0xFF16171A).copy(alpha = 0.55f) + val DiscStroke = Color.White.copy(alpha = 0.13f) + val PanelStroke = Color.White.copy(alpha = 0.09f) + + /** Wash behind a selected row, and the resting fill of in-panel controls. */ + val Raised = Color.White.copy(alpha = 0.075f) + val Group = Color.White.copy(alpha = 0.05f) + val GroupSeparator = Color.White.copy(alpha = 0.06f) + val Chip = Color.White.copy(alpha = 0.10f) + + /** The one destructive tint, shared with the rest of the app. */ + val Stop = SiloDestructive + + val DiscSize = 40.dp + val TouchSize = 48.dp + val PanelRadius = 24.dp + val PanelWidth = 372.dp +} + +/** Type for the player chrome. Inter throughout; timecodes use tabular figures. */ +internal object PlayerType { + private val base = TextStyle(fontFamily = SiloInterFamily, color = PlayerChrome.Paper) + + val Eyebrow = base.copy( + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.09.em, + color = PlayerChrome.Graphite, + ) + val HudTitle = base.copy(fontSize = 15.5.sp, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.01).em) + val Time = base.copy(fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum") + val PillLabel = base.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + val PillValue = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Medium, color = PlayerChrome.Graphite) + val PanelTitle = base.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.01).em) + val RowTitle = base.copy(fontSize = 14.5.sp, fontWeight = FontWeight.Medium, lineHeight = 19.sp) + val RowDetail = base.copy(fontSize = 11.5.sp, color = PlayerChrome.Graphite, lineHeight = 15.sp) + val Value = base.copy(fontSize = 13.sp, color = PlayerChrome.Graphite, fontFeatureSettings = "tnum") + val Section = base.copy( + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.1.em, + color = PlayerChrome.Graphite, + ) + val Segment = base.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + val SmallButton = base.copy(fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold) + val Chip = base.copy( + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.07.em, + color = PlayerChrome.Paper.copy(alpha = 0.78f), + ) + val FieldLabel = base.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = PlayerChrome.Graphite) + val Footnote = base.copy(fontSize = 12.sp, color = PlayerChrome.Graphite, lineHeight = 16.sp) + val BubbleTime = base.copy( + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.01).em, + fontFeatureSettings = "tnum", + ) + val BubbleDetail = base.copy( + fontSize = 11.5.sp, + fontWeight = FontWeight.SemiBold, + color = PlayerChrome.Paper.copy(alpha = 0.62f), + fontFeatureSettings = "tnum", + ) +} + +// --------------------------------------------------------------------------- +// Controls that sit directly on the picture +// --------------------------------------------------------------------------- + +/** + * A smoked disc on the video. The visual disc is [size]; the touch target is + * never smaller than 48dp, so a row of discs keeps Android's minimum target + * without the discs themselves growing. + */ +@Composable +internal fun PlayerDisc( + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = PlayerChrome.DiscSize, + enabled: Boolean = true, + fill: Color = PlayerChrome.Disc, + contentColor: Color = PlayerChrome.Paper, + stroke: Color = PlayerChrome.DiscStroke, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(size) + .clip(CircleShape) + .background(fill) + .border(1.dp, stroke, CircleShape) + // The disc is the control a screen reader names; its glyph says nothing. + .semantics { this.contentDescription = contentDescription } + .clickable( + enabled = enabled, + role = Role.Button, + onClickLabel = contentDescription, + onClick = onClick, + ) + .alpha(if (enabled) 1f else 0.32f), + contentAlignment = Alignment.Center, + ) { + Box(modifier = Modifier.clearAndSetSemantics {}, contentAlignment = Alignment.Center) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + content() + } + } + } +} + +@Composable +internal fun PlayerIconDisc( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = PlayerChrome.DiscSize, + iconSize: Dp = 22.dp, + enabled: Boolean = true, +) { + PlayerDisc( + contentDescription = contentDescription, + onClick = onClick, + modifier = modifier, + size = size, + enabled = enabled, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(iconSize), + ) + } +} + +/** + * A labeled control on the bottom row: icon, what it opens, and what it is set + * to now ("Chapters · Scene 10"). Compact mode keeps only the icon for narrow + * windows, the way iOS falls back through `ViewThatFits`. + */ +@Composable +internal fun PlayerActionPill( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + value: String? = null, + compact: Boolean = false, + enabled: Boolean = true, +) { + val shape = RoundedCornerShape(18.dp) + Row( + modifier = modifier + .minimumInteractiveComponentSize() + .height(36.dp) + .clip(shape) + .background(PlayerChrome.Disc) + .border(1.dp, PlayerChrome.DiscStroke, shape) + .clickable(enabled = enabled, role = Role.Button, onClickLabel = label, onClick = onClick) + .alpha(if (enabled) 1f else 0.32f) + .padding(start = if (compact) 8.5.dp else 11.dp, end = if (compact) 8.5.dp else 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Icon( + imageVector = icon, + contentDescription = if (compact) label else null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(19.dp), + ) + if (!compact) { + Text(text = label, style = PlayerType.PillLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!value.isNullOrBlank()) { + // The value gives way first when the pill is squeezed. + Text( + text = value, + style = PlayerType.PillValue, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, fill = false) + .widthIn(max = 160.dp), + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// Panel content: shared by the docked landscape panel and the portrait sheet +// --------------------------------------------------------------------------- + +/** Title row of a player menu, with an optional back chevron and a close disc. */ +@Composable +internal fun PlayerPanelHeader( + title: String, + onClose: () -> Unit, + onBack: (() -> Unit)? = null, + backDescription: String = "Back", +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = if (onBack != null) 4.dp else 18.dp, end = 6.dp, top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onBack != null) { + Box( + modifier = Modifier + .size(PlayerChrome.TouchSize) + .clip(CircleShape) + .clickable(onClickLabel = backDescription, role = Role.Button, onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, + contentDescription = backDescription, + tint = PlayerChrome.Paper, + modifier = Modifier.size(26.dp), + ) + } + } + Text( + text = title, + style = PlayerType.PanelTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + PlayerDisc( + contentDescription = "Close", + onClick = onClose, + size = 32.dp, + fill = Color.White.copy(alpha = 0.08f), + stroke = Color.Transparent, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(20.dp), + ) + } + } +} + +internal data class PlayerSegment(val label: String, val count: Int? = null) + +/** + * Pill segmented control. The selected segment inverts to Paper, the same + * rule the TV uses for focus. + */ +@Composable +internal fun PlayerSegmentedControl( + segments: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(19.dp)) + .background(PlayerChrome.Raised) + .padding(3.dp), + ) { + segments.forEachIndexed { index, segment -> + val selected = index == selectedIndex + Row( + modifier = Modifier + .weight(1f) + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(if (selected) PlayerChrome.Paper else Color.Transparent) + .selectable(selected = selected, role = Role.Tab, onClick = { onSelect(index) }), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val color = if (selected) PlayerChrome.Ink else PlayerChrome.Graphite + Text(text = segment.label, style = PlayerType.Segment.copy(color = color), maxLines = 1) + segment.count?.let { count -> + Spacer(Modifier.width(6.dp)) + Text( + text = count.toString(), + style = PlayerType.Segment.copy( + color = color.copy(alpha = color.alpha * 0.55f), + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + ), + ) + } + } + } + } +} + +/** + * Compact segmented control for value choices inside a panel (subtitle size, + * background, position). Selection is a raised wash rather than an inversion + * so a page of them does not turn into a wall of white. + */ +@Composable +internal fun PlayerChoiceSegments( + options: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(PlayerChrome.Raised) + .padding(3.dp), + ) { + val segmentShape = RoundedCornerShape(9.dp) + options.forEachIndexed { index, label -> + val selected = index == selectedIndex + Box( + modifier = Modifier + .weight(1f) + .height(32.dp) + .clip(segmentShape) + .then( + if (selected) { + Modifier + .background(Color.White.copy(alpha = 0.16f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), segmentShape) + } else { + Modifier + }, + ) + .selectable(selected = selected, role = Role.RadioButton, onClick = { onSelect(index) }), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = PlayerType.SmallButton.copy( + color = if (selected) PlayerChrome.Paper else PlayerChrome.Graphite, + ), + maxLines = 1, + ) + } + } + } +} + +/** "SDH", "FORCED": short facts about a track that belong next to its name. */ +@Composable +internal fun PlayerChip(text: String, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(PlayerChrome.Chip) + .padding(horizontal = 5.dp, vertical = 1.5.dp), + ) { + Text(text = text.uppercase(), style = PlayerType.Chip, maxLines = 1) + } +} + +/** + * One selectable track or option: title (with chips), an optional detail line, + * and a trailing check when selected. The selected row also gets a soft wash. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun PlayerTrackRow( + title: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + detail: String? = null, + chips: List = emptyList(), + enabled: Boolean = true, + leading: (@Composable () -> Unit)? = null, +) { + // A long list opens with the current choice on screen, not row one, and + // with the rows after it: revealing the row alone parked it under the + // list's bottom fade. + val revealRequester = remember { BringIntoViewRequester() } + var rowSize by remember { mutableStateOf(IntSize.Zero) } + val revealContextPx = with(LocalDensity.current) { 96.dp.toPx() } + if (selected && rowSize != IntSize.Zero) { + LaunchedEffect(Unit) { + revealRequester.bringIntoView( + Rect(0f, 0f, rowSize.width.toFloat(), rowSize.height + revealContextPx), + ) + } + } + Row( + modifier = modifier + .onSizeChanged { rowSize = it } + .bringIntoViewRequester(revealRequester) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) PlayerChrome.Raised else Color.Transparent) + .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f) + .heightIn(min = 46.dp) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + leading?.invoke() + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = PlayerType.RowTitle.copy( + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + chips.forEach { chip -> + Spacer(Modifier.width(6.dp)) + PlayerChip(chip) + } + } + if (!detail.isNullOrBlank()) { + Text( + text = detail, + style = PlayerType.RowDetail, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "Selected", + tint = PlayerChrome.Paper, + modifier = Modifier.size(21.dp), + ) + } + } +} + +/** Small caps header above a group of rows. */ +@Composable +internal fun PlayerSectionHeader(text: String, modifier: Modifier = Modifier) { + Text( + text = text.uppercase(), + style = PlayerType.Section, + modifier = modifier.padding(start = 12.dp, end = 12.dp, top = 14.dp, bottom = 6.dp), + ) +} + +/** An inset group of rows with hairlines between them. */ +@Composable +internal fun PlayerGroup( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Group), + content = content, + ) +} + +/** Draws the group hairline above every row except the first. */ +internal fun Modifier.playerGroupSeparator(show: Boolean): Modifier = + if (show) { + drawBehind { + drawLine( + color = PlayerChrome.GroupSeparator, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1.dp.toPx(), + ) + } + } else { + this + } + +@Composable +private fun PlayerGroupRow( + modifier: Modifier, + separator: Boolean, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .playerGroupSeparator(separator) + .heightIn(min = 44.dp) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) +} + +/** Label · value · chevron. Values line up in one right-aligned column. */ +@Composable +internal fun PlayerValueRow( + label: String, + value: String?, + onClick: () -> Unit, + separator: Boolean = false, + enabled: Boolean = true, + detail: String? = null, +) { + PlayerGroupRow( + modifier = Modifier + .clickable(enabled = enabled, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f), + separator = separator, + ) { + // The label keeps its natural width; the value takes the rest of the + // row and ends at the chevron, so every value lines up on one edge. + Column(modifier = if (value.isNullOrBlank()) Modifier.weight(1f) else Modifier) { + Text(text = label, style = PlayerType.RowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + if (!value.isNullOrBlank()) { + Text( + text = value, + style = PlayerType.Value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } + PlayerRowChevron() + } +} + +@Composable +private fun PlayerRowChevron() { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = PlayerChrome.Faint, + modifier = Modifier + .size(20.dp) + .offset(x = 4.dp), + ) +} + +/** A switch row matching the app's Settings switch (green track, white thumb). */ +@Composable +internal fun PlayerSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + separator: Boolean = false, + enabled: Boolean = true, + detail: String? = null, +) { + PlayerGroupRow( + modifier = Modifier + .toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onCheckedChange) + .alpha(if (enabled) 1f else 0.4f), + separator = separator, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = PlayerType.RowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + thumbContent = {}, + colors = settingsSwitchColors(), + ) + } +} + +/** A choice inside a group: label, optional detail, trailing check when chosen. */ +@Composable +internal fun PlayerCheckRow( + label: String, + selected: Boolean, + onClick: () -> Unit, + separator: Boolean = false, + detail: String? = null, + labelColor: Color = PlayerChrome.Paper, +) { + PlayerGroupRow( + modifier = Modifier.selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + separator = separator, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = PlayerType.RowTitle.copy( + color = labelColor, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + fontFeatureSettings = "tnum", + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "Selected", + tint = PlayerChrome.Paper, + modifier = Modifier.size(20.dp), + ) + } + } +} + +/** Flat button for a panel footer: "Find more", "Translate", "Timing". */ +@Composable +internal fun PlayerFooterButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = modifier + .heightIn(min = 40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(PlayerChrome.Raised) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = icon, contentDescription = null, tint = PlayerChrome.Paper, modifier = Modifier.size(18.dp)) + Text(text = label, style = PlayerType.SmallButton, maxLines = 1) + } +} + +/** "Size ........ Large": the label over a control, with its current value. */ +@Composable +internal fun PlayerFieldLabel(label: String, value: String?, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 4.dp, top = 14.dp, bottom = 7.dp), + ) { + Text(text = label, style = PlayerType.FieldLabel, modifier = Modifier.weight(1f)) + if (!value.isNullOrBlank()) { + Text(text = value, style = PlayerType.FieldLabel.copy(color = PlayerChrome.Paper)) + } + } +} + +/** Color choices as swatches; the chosen one gets a Paper ring. */ +@Composable +internal fun PlayerSwatchRow( + colors: List>, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + // Nine swatches need about 330dp; a narrow docked panel has less, so the + // row scrolls rather than clip the last colors. + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + colors.forEachIndexed { index, (color, name) -> + val selected = index == selectedIndex + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .selectable(selected = selected, role = Role.RadioButton, onClick = { onSelect(index) }) + .then( + if (selected) Modifier.border(2.dp, PlayerChrome.Paper, CircleShape) else Modifier, + ) + .padding(4.dp) + .clip(CircleShape) + .background(color) + .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape) + .semantics { contentDescription = name }, + ) + } + } +} + +/** Explanatory text under a group. */ +@Composable +internal fun PlayerFootnote(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = PlayerType.Footnote, + modifier = modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp), + ) +} + +/** "− +0.25 s +" stepper used for delays. */ +@Composable +internal fun PlayerStepperRow( + label: String, + value: String, + onDecrease: () -> Unit, + onIncrease: () -> Unit, + separator: Boolean = false, + detail: String? = null, + enabled: Boolean = true, +) { + PlayerGroupRow(modifier = Modifier.alpha(if (enabled) 1f else 0.4f), separator = separator) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label, style = PlayerType.RowTitle, maxLines = 1) + if (!detail.isNullOrBlank()) { + Text(text = detail, style = PlayerType.RowDetail, modifier = Modifier.padding(top = 1.dp)) + } + } + StepperButton(symbol = "−", description = "Decrease $label", enabled = enabled, onClick = onDecrease) + Text( + text = value, + style = PlayerType.Value.copy(color = PlayerChrome.Paper, fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + modifier = Modifier.width(72.dp), + ) + StepperButton(symbol = "+", description = "Increase $label", enabled = enabled, onClick = onIncrease) + } +} + +@Composable +private fun StepperButton(symbol: String, description: String, enabled: Boolean, onClick: () -> Unit) { + PlayerDisc( + contentDescription = description, + onClick = onClick, + size = 34.dp, + enabled = enabled, + fill = Color.White.copy(alpha = 0.09f), + stroke = Color.Transparent, + ) { + Text(text = symbol, style = PlayerType.PanelTitle.copy(fontWeight = FontWeight.Medium)) + } +} + +internal val PlayerPanelBorder = BorderStroke(1.dp, PlayerChrome.PanelStroke) + +/** An action inside a group: leading icon and a label, no value. */ +@Composable +internal fun PlayerActionRow( + icon: ImageVector, + label: String, + onClick: () -> Unit, + separator: Boolean = false, + enabled: Boolean = true, + tint: Color = PlayerChrome.Paper, +) { + PlayerGroupRow( + modifier = Modifier + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .alpha(if (enabled) 1f else 0.4f), + separator = separator, + ) { + Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) + Text( + text = label, + style = PlayerType.RowTitle.copy(color = tint), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + PlayerRowChevron() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt index 8ce53b25d0..a5f5895907 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerControls.kt @@ -6,7 +6,10 @@ import android.media.AudioManager import android.os.Handler import android.os.Looper import android.provider.Settings +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween 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 @@ -19,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size @@ -27,26 +31,25 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Brightness6 -import androidx.compose.material.icons.filled.HighQuality -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.ScreenLockRotation -import androidx.compose.material.icons.filled.ScreenRotation -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.automirrored.filled.SpeakerNotes -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.FormatListBulleted +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.Brightness6 +import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PictureInPictureAlt +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.ScreenLockRotation +import androidx.compose.material.icons.rounded.ScreenRotation +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.Subtitles +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -56,34 +59,37 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.siloserver.silo.android.ui.components.SeekIntervalIcon -import org.siloserver.silo.android.ui.layout.useCompactPlayerToolbar /** - * Transport controls overlay for the video player. Top-bar icon layout - * mirrors iOS phone's `MobilePlayerControls` (lock | chapters | tracks | - * settings) — see `iosApp/Screens/Player/iOS/MobilePlayerControls.swift:73`. + * Transport controls over the video, after iOS `MobilePlayerControls`: * - * Fullscreen uses the existing three-row HUD; tabletop groups the same shared - * controls into the lower pane, keeps brightness available at every size, and - * adds volume, speed, and next-episode shortcuts when the pane is large enough. + * - Top: back, eyebrow + title, then utility discs (Picture in Picture, Cast, + * rotation lock). + * - Center, on the true center of the screen: skip back, a Paper play disc, + * skip forward. + * - Bottom: elapsed and remaining time, the seek bar, and a row of labeled + * actions that also say what they are set to (Audio & Subtitles · English, + * Chapters · Scene 10, Quality · 1080p), with the full settings menu behind ⋯. * - * Core rows: - * - Top: Back (chevron) · title · orientation lock toggle · chapters (when - * present) · tracks (audio + subs) · quality (whenever there is a - * playback plan) · settings (gear). Version (when the title has several files) - * lives in the gear sheet and the compact overflow. - * - Center: Skip back · play/pause · skip forward - * - Bottom: Seek bar with timestamps + * While the seek bar is being scrubbed everything except the bar fades to 12% + * so the bubble and the picture carry the moment. + * + * Tabletop posture groups the same pieces into the lower pane and adds the + * brightness, volume, speed, and next-episode shortcuts when they fit. */ @Composable fun PlayerControls( @@ -96,11 +102,10 @@ fun PlayerControls( bufferedPosition: Double, hasChapters: Boolean, hasTracks: Boolean, - // Quality lives on the HUD (chapters + tracks + quality product decision); - // hidden only when there is no playback plan (offline downloads). + // The plan's Quality menu; hidden only when there is no playback plan + // (offline downloads). Version, for a title with several files, lives in + // the settings menu behind ⋯. hasQualityMenu: Boolean, - // The file-version picker; hidden for a single version and in a Watch Party. - hasMultipleVersions: Boolean, chapters: List = emptyList(), intro: org.siloserver.silo.model.catalog.TimeRange? = null, credits: org.siloserver.silo.model.catalog.TimeRange? = null, @@ -125,6 +130,14 @@ fun PlayerControls( // revision-9 server, the legacy fixed pair otherwise). skipBackSeconds: Int = PlayerViewModel.LEGACY_VIDEO_SEEK_INTERVALS.backSeconds, skipForwardSeconds: Int = PlayerViewModel.LEGACY_VIDEO_SEEK_INTERVALS.forwardSeconds, + // "MOVIE · 2026" or "SEVERANCE · S2:E4" above the title. + eyebrow: String = subtitle, + // Current values shown on the action pills. + tracksValue: String? = null, + chapterValue: String? = null, + qualityValue: String? = null, + pictureInPictureAvailable: Boolean = false, + onEnterPictureInPicture: () -> Unit = {}, onBack: () -> Unit, onPlayPause: () -> Unit, onSeek: (Double) -> Unit, @@ -134,24 +147,35 @@ fun PlayerControls( onOpenChapters: () -> Unit, onOpenTracks: () -> Unit, onOpenQuality: () -> Unit, - onOpenVersion: () -> Unit, onOpenSettings: () -> Unit, onSetPlaybackSpeed: (Double) -> Unit = {}, onPlayNextEpisode: () -> Unit = {}, onSetBrightness: (Float) -> Unit = {}, + // True while the seek bar is being dragged, so the auto-hide can hold off. + onScrubbingChange: (Boolean) -> Unit = {}, // Google Cast (Chromecast) button — sits in the top bar alongside the other // controls. Provided by PlayerScreen; empty by default so this stateless - // composable stays test-friendly and decoupled from the Cast SDK. - castSlot: @Composable () -> Unit = {}, + // composable stays test-friendly and decoupled from the Cast SDK. The + // modifier dresses it as a player disc. + castSlot: @Composable (Modifier) -> Unit = {}, modifier: Modifier = Modifier, ) { - // iOS dims the entire screen with a flat `Color.black.opacity(0.4)` - // backdrop (MobilePlayerControls) — no top/bottom gradients. The VStack - // sits inside the dim with iOS's default 16pt edge padding. + var isScrubbing by remember { mutableStateOf(false) } + val chromeAlpha by animateFloatAsState( + targetValue = if (isScrubbing) 0.12f else 1f, + animationSpec = tween(180), + label = "playerChromeAlpha", + ) + val scrimBoost by animateFloatAsState( + targetValue = if (isScrubbing) 0.18f else 0f, + animationSpec = tween(180), + label = "playerScrimBoost", + ) + Box( modifier = modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)), + .playerScrim(boost = scrimBoost), ) { val contentModifier = if (tabletopMode) { Modifier @@ -160,7 +184,7 @@ fun PlayerControls( // bar inset here would create a fake gap below the hinge. Only // reserve the real bottom navigation/gesture inset. .windowInsetsPadding(WindowInsets.navigationBars) - .padding(horizontal = 24.dp, vertical = 12.dp) + .padding(horizontal = 20.dp, vertical = 10.dp) } else { // In landscape the safe-drawing insets are lopsided (camera cutout // on one edge, nothing on the other), so padding by them directly @@ -188,30 +212,24 @@ fun PlayerControls( Modifier .fillMaxSize() .padding(horizontal = horizontalInset) - .padding(16.dp) + .padding(horizontal = 18.dp, vertical = 6.dp) } - val toolbar: @Composable () -> Unit = { - PlayerToolbar( + val topBar: @Composable () -> Unit = { + PlayerTopBar( + eyebrow = eyebrow, title = title, - subtitle = subtitle, isOrientationLocked = isOrientationLocked, orientationLockSupported = orientationLockSupported, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasQualityMenu = hasQualityMenu, - hasMultipleVersions = hasMultipleVersions, + pictureInPictureAvailable = pictureInPictureAvailable, onBack = onBack, + onEnterPictureInPicture = onEnterPictureInPicture, onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenVersion = onOpenVersion, - onOpenSettings = onOpenSettings, castSlot = castSlot, + modifier = Modifier.alpha(chromeAlpha), ) } - val transportControls: @Composable () -> Unit = { + val transport: @Composable () -> Unit = { PlayerTransportControls( isPlaying = isPlaying, isPaused = isPaused, @@ -222,21 +240,41 @@ fun PlayerControls( onPlayPause = onPlayPause, onSkipForward = onSkipForward, onSkipBackward = onSkipBackward, + modifier = Modifier.alpha(chromeAlpha), ) } - val progressBar: @Composable () -> Unit = { - PlayerProgressBar( - position = position, - duration = duration, - bufferedPosition = bufferedPosition, - onSeek = onSeek, - enabled = seekEnabled, - chapters = chapters, - intro = intro, - credits = credits, - recap = recap, - preview = preview, - ) + val bottom: @Composable () -> Unit = { + Column(modifier = Modifier.padding(horizontal = 4.dp)) { + PlayerProgressBar( + position = position, + duration = duration, + bufferedPosition = bufferedPosition, + onSeek = onSeek, + enabled = seekEnabled, + chapters = chapters, + intro = intro, + credits = credits, + recap = recap, + preview = preview, + onScrubbingChange = { + isScrubbing = it + onScrubbingChange(it) + }, + ) + PlayerActionPillRow( + hasTracks = hasTracks, + hasChapters = hasChapters, + hasQualityMenu = hasQualityMenu, + tracksValue = tracksValue, + chapterValue = chapterValue, + qualityValue = qualityValue, + onOpenTracks = onOpenTracks, + onOpenChapters = onOpenChapters, + onOpenQuality = onOpenQuality, + onOpenSettings = onOpenSettings, + modifier = Modifier.alpha(chromeAlpha), + ) + } } if (tabletopMode) { @@ -249,9 +287,9 @@ fun PlayerControls( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween, ) { - toolbar() - transportControls() - progressBar() + topBar() + transport() + bottom() TabletopUtilityRow( playbackSpeed = playbackSpeed, playbackSpeedEnabled = playbackSpeedEnabled, @@ -265,105 +303,118 @@ fun PlayerControls( } } } else { - // The toolbar and progress bar respect the safe-drawing insets, but - // those insets are asymmetric in landscape (cutout on one side, - // navigation bar on the other), so a transport row inside the same - // padded column lands visibly off-center. Anchor the transport - // cluster to the true center of the overlay instead. Column(modifier = contentModifier) { - toolbar() + topBar() Spacer(modifier = Modifier.weight(1f)) - progressBar() + bottom() } + // The insets are asymmetric in landscape, so a transport row inside + // the padded column lands off-centre. Anchor it to the true centre. Box( modifier = Modifier.align(Alignment.Center), contentAlignment = Alignment.Center, ) { - transportControls() + transport() } } } } +/** + * Gradients instead of a flat dim: dark enough at the top and bottom edges for + * the chrome to read over any frame, while a paused picture stays visible in + * the middle. [boost] adds an even dim while scrubbing. + */ +private fun Modifier.playerScrim(boost: Float): Modifier = drawBehind { + drawRect(Color.Black.copy(alpha = 0.12f + boost)) + val top = 116.dp.toPx() + drawRect( + brush = Brush.verticalGradient(0f to Color.Black.copy(alpha = 0.6f), 1f to Color.Transparent, endY = top), + size = Size(size.width, top), + ) + val bottom = 200.dp.toPx() + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.78f), + startY = size.height - bottom, + endY = size.height, + ), + topLeft = Offset(0f, size.height - bottom), + size = Size(size.width, bottom), + ) +} + @Composable -private fun PlayerToolbar( +private fun PlayerTopBar( + eyebrow: String, title: String, - subtitle: String, isOrientationLocked: Boolean, orientationLockSupported: Boolean, - hasChapters: Boolean, - hasTracks: Boolean, - hasQualityMenu: Boolean, - hasMultipleVersions: Boolean, + pictureInPictureAvailable: Boolean, onBack: () -> Unit, + onEnterPictureInPicture: () -> Unit, onToggleOrientationLock: () -> Unit, - onOpenChapters: () -> Unit, - onOpenTracks: () -> Unit, - onOpenQuality: () -> Unit, - onOpenVersion: () -> Unit, - onOpenSettings: () -> Unit, - castSlot: @Composable () -> Unit, + castSlot: @Composable (Modifier) -> Unit, + modifier: Modifier = Modifier, ) { - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val trailingActionCount = 3 + - (if (orientationLockSupported) 1 else 0) + - (if (hasChapters) 1 else 0) + - (if (hasQualityMenu) 1 else 0) - val compact = useCompactPlayerToolbar( - availableWidthDp = maxWidth.value, - trailingActionCount = trailingActionCount, + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + PlayerIconDisc( + icon = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + onClick = onBack, ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), ) { - ControlButton( - icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - PlayerToolbarTitle( - title = title, - subtitle = subtitle, - modifier = Modifier.weight(1f), - ) - if (compact) { - castSlot() - PlayerToolbarOverflow( - isOrientationLocked = isOrientationLocked, - orientationLockSupported = orientationLockSupported, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasQualityMenu = hasQualityMenu, - hasMultipleVersions = hasMultipleVersions, - onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenVersion = onOpenVersion, - onOpenSettings = onOpenSettings, - ) - } else { - PlayerToolbarActions( - isOrientationLocked = isOrientationLocked, - orientationLockSupported = orientationLockSupported, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasQualityMenu = hasQualityMenu, - onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenSettings = onOpenSettings, - castSlot = castSlot, + if (eyebrow.isNotBlank()) { + Text( + text = eyebrow.uppercase(), + style = PlayerType.Eyebrow, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } + Text( + text = title, + style = PlayerType.HudTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 1.dp), + ) + } + if (pictureInPictureAvailable) { + PlayerIconDisc( + icon = Icons.Rounded.PictureInPictureAlt, + contentDescription = "Picture in picture", + onClick = onEnterPictureInPicture, + ) + } + castSlot(Modifier.playerDiscSurface()) + if (orientationLockSupported) { + PlayerIconDisc( + icon = if (isOrientationLocked) Icons.Rounded.ScreenLockRotation else Icons.Rounded.ScreenRotation, + contentDescription = if (isOrientationLocked) "Landscape locked" else "Rotate freely", + onClick = onToggleOrientationLock, + ) } } } +/** Dresses a foreign button (Cast) as a player disc. */ +private fun Modifier.playerDiscSurface(): Modifier = this + .minimumInteractiveComponentSize() + .size(PlayerChrome.DiscSize) + .clip(CircleShape) + .background(PlayerChrome.Disc) + .border(1.dp, PlayerChrome.DiscStroke, CircleShape) + @Composable private fun PlayerTransportControls( isPlaying: Boolean, @@ -375,83 +426,134 @@ private fun PlayerTransportControls( onPlayPause: () -> Unit, onSkipForward: () -> Unit, onSkipBackward: () -> Unit, + modifier: Modifier = Modifier, ) { + val showPlay = isPaused || !isPlaying Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterHorizontally), + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(46.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { - IconButton( + PlayerDisc( + contentDescription = "Skip back $skipBackSeconds seconds", onClick = onSkipBackward, + size = 54.dp, enabled = seekEnabled, - modifier = Modifier.size(52.dp), ) { SeekIntervalIcon( forward = false, seconds = skipBackSeconds, - contentDescription = "Skip back $skipBackSeconds seconds", - tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(32.dp), + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(31.dp), ) } - IconButton( + PlayerDisc( + contentDescription = if (showPlay) "Play" else "Pause", onClick = onPlayPause, + size = 70.dp, enabled = playPauseEnabled, - modifier = Modifier - .size(64.dp) - .background(Color.White.copy(alpha = 0.14f), CircleShape), + fill = PlayerChrome.Paper, + contentColor = PlayerChrome.Ink, + stroke = Color.Transparent, ) { Icon( - imageVector = if (isPaused || !isPlaying) { - Icons.Default.PlayArrow - } else { - Icons.Default.Pause - }, - contentDescription = if (isPaused || !isPlaying) "Play" else "Pause", - tint = if (playPauseEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(38.dp), + imageVector = if (showPlay) Icons.Rounded.PlayArrow else Icons.Rounded.Pause, + contentDescription = null, + tint = PlayerChrome.Ink, + // The play triangle's visual centre sits left of its box. + modifier = Modifier + .size(44.dp) + .offset(x = if (showPlay) 2.dp else 0.dp), ) } - IconButton( + PlayerDisc( + contentDescription = "Skip forward $skipForwardSeconds seconds", onClick = onSkipForward, + size = 54.dp, enabled = seekEnabled, - modifier = Modifier.size(52.dp), ) { SeekIntervalIcon( forward = true, seconds = skipForwardSeconds, - contentDescription = "Skip forward $skipForwardSeconds seconds", - tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(32.dp), + contentDescription = null, + tint = PlayerChrome.Paper, + modifier = Modifier.size(31.dp), ) } } } +/** How much of each action pill fits: label and value, label, or icon only. */ +private enum class ActionPillDensity { Full, Labels, Icons } + @Composable -private fun PlayerToolbarTitle( - title: String, - subtitle: String, +private fun PlayerActionPillRow( + hasTracks: Boolean, + hasChapters: Boolean, + hasQualityMenu: Boolean, + tracksValue: String?, + chapterValue: String?, + qualityValue: String?, + onOpenTracks: () -> Unit, + onOpenChapters: () -> Unit, + onOpenQuality: () -> Unit, + onOpenSettings: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = title, - fontSize = 15.sp, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (subtitle.isNotBlank()) { - Text( - text = subtitle, - fontSize = 12.sp, - color = Color.White.copy(alpha = 0.64f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val density = when { + maxWidth >= 680.dp -> ActionPillDensity.Full + maxWidth >= 480.dp -> ActionPillDensity.Labels + else -> ActionPillDensity.Icons + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // The left pills share what Quality and ⋯ leave, so a long track or + // chapter name ellipsizes instead of pushing them off the row. + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PlayerActionPill( + icon = Icons.Rounded.Subtitles, + label = "Audio & Subtitles", + value = tracksValue.takeIf { density == ActionPillDensity.Full }, + compact = density == ActionPillDensity.Icons, + enabled = hasTracks, + onClick = onOpenTracks, + modifier = Modifier.weight(1f, fill = false), + ) + if (hasChapters) { + PlayerActionPill( + icon = Icons.AutoMirrored.Rounded.FormatListBulleted, + label = "Chapters", + value = chapterValue.takeIf { density == ActionPillDensity.Full }, + compact = density == ActionPillDensity.Icons, + onClick = onOpenChapters, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + if (hasQualityMenu) { + PlayerActionPill( + icon = Icons.Rounded.Tune, + label = "Quality", + value = qualityValue.takeIf { density != ActionPillDensity.Icons }, + compact = density == ActionPillDensity.Icons, + onClick = onOpenQuality, + ) + } + PlayerIconDisc( + icon = Icons.Rounded.MoreHoriz, + contentDescription = "Playback settings", + onClick = onOpenSettings, + size = 36.dp, + iconSize = 21.dp, ) } } @@ -472,7 +574,7 @@ private fun TabletopUtilityRow( val brightnessControl: @Composable (Modifier) -> Unit = { modifier -> TabletopSliderControl( - icon = Icons.Default.Brightness6, + icon = Icons.Rounded.Brightness6, contentDescription = "Player brightness", value = brightnessFraction, onValueChange = onSetBrightness, @@ -514,7 +616,7 @@ private fun TabletopUtilityRow( verticalAlignment = Alignment.CenterVertically, ) { TabletopSliderControl( - icon = Icons.AutoMirrored.Filled.VolumeUp, + icon = Icons.AutoMirrored.Rounded.VolumeUp, contentDescription = "Media volume", value = volumeFraction, onValueChange = { fraction -> @@ -530,14 +632,14 @@ private fun TabletopUtilityRow( brightnessControl(Modifier.weight(1f)) if (playbackSpeedEnabled) { TabletopActionButton( - icon = Icons.Default.Speed, + icon = Icons.Rounded.Speed, label = playbackSpeedLabel(playbackSpeed), onClick = { onSetPlaybackSpeed(nextTabletopPlaybackSpeed(playbackSpeed)) }, ) } nextEpisode?.let { episode -> TabletopActionButton( - icon = Icons.Default.SkipNext, + icon = Icons.Rounded.SkipNext, label = "Next S${episode.seasonNumber}·E${episode.episodeNumber}", onClick = onPlayNextEpisode, ) @@ -547,7 +649,7 @@ private fun TabletopUtilityRow( @Composable private fun TabletopSliderControl( - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, contentDescription: String, value: Float, onValueChange: (Float) -> Unit, @@ -556,21 +658,26 @@ private fun TabletopSliderControl( Row( modifier = modifier .height(48.dp) - .clip(RoundedCornerShape(14.dp)) - .background(Color.White.copy(alpha = 0.08f)) - .padding(horizontal = 10.dp), + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Raised) + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = icon, contentDescription = contentDescription, - tint = Color.White.copy(alpha = 0.82f), + tint = PlayerChrome.Paper.copy(alpha = 0.82f), modifier = Modifier.size(20.dp), ) Slider( value = value, onValueChange = onValueChange, + colors = SliderDefaults.colors( + thumbColor = PlayerChrome.Paper, + activeTrackColor = PlayerChrome.Paper, + inactiveTrackColor = Color.White.copy(alpha = 0.22f), + ), modifier = Modifier.weight(1f), ) } @@ -578,7 +685,7 @@ private fun TabletopSliderControl( @Composable private fun TabletopActionButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, label: String, onClick: () -> Unit, ) { @@ -586,8 +693,8 @@ private fun TabletopActionButton( modifier = Modifier .height(48.dp) .widthIn(min = 112.dp) - .clip(RoundedCornerShape(14.dp)) - .background(Color.White.copy(alpha = 0.10f)) + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Raised) .clickable(onClick = onClick) .padding(horizontal = 14.dp), verticalAlignment = Alignment.CenterVertically, @@ -596,16 +703,10 @@ private fun TabletopActionButton( Icon( imageVector = icon, contentDescription = null, - tint = Color.White, + tint = PlayerChrome.Paper, modifier = Modifier.size(19.dp), ) - Text( - text = label, - color = Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - ) + Text(text = label, style = PlayerType.PillLabel, maxLines = 1) } } @@ -615,166 +716,3 @@ internal fun nextTabletopPlaybackSpeed(current: Double): Double = internal fun playbackSpeedLabel(speed: Double): String { return "${formatPlaybackSpeed(speed)}× Speed" } - -@Composable -private fun PlayerToolbarActions( - isOrientationLocked: Boolean, - orientationLockSupported: Boolean, - hasChapters: Boolean, - hasTracks: Boolean, - hasQualityMenu: Boolean, - onToggleOrientationLock: () -> Unit, - onOpenChapters: () -> Unit, - onOpenTracks: () -> Unit, - onOpenQuality: () -> Unit, - onOpenSettings: () -> Unit, - castSlot: @Composable () -> Unit, -) { - if (orientationLockSupported) { - ControlButton( - icon = if (isOrientationLocked) { - Icons.Default.ScreenLockRotation - } else { - Icons.Default.ScreenRotation - }, - contentDescription = if (isOrientationLocked) "Landscape Locked" else "Rotate Freely", - onClick = onToggleOrientationLock, - ) - } - if (hasChapters) { - ControlButton( - icon = Icons.AutoMirrored.Filled.List, - contentDescription = "Chapters", - onClick = onOpenChapters, - ) - } - ControlButton( - icon = Icons.AutoMirrored.Filled.SpeakerNotes, - contentDescription = "Audio and subtitles", - onClick = onOpenTracks, - enabled = hasTracks, - ) - if (hasQualityMenu) { - ControlButton( - icon = Icons.Default.HighQuality, - contentDescription = "Quality", - onClick = onOpenQuality, - ) - } - castSlot() - ControlButton( - icon = Icons.Default.Settings, - contentDescription = "Playback settings", - onClick = onOpenSettings, - ) -} - -@Composable -private fun PlayerToolbarOverflow( - isOrientationLocked: Boolean, - orientationLockSupported: Boolean, - hasChapters: Boolean, - hasTracks: Boolean, - hasQualityMenu: Boolean, - hasMultipleVersions: Boolean, - onToggleOrientationLock: () -> Unit, - onOpenChapters: () -> Unit, - onOpenTracks: () -> Unit, - onOpenQuality: () -> Unit, - onOpenVersion: () -> Unit, - onOpenSettings: () -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - - Box { - ControlButton( - icon = Icons.Default.MoreVert, - contentDescription = "More playback controls", - onClick = { expanded = true }, - ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - if (orientationLockSupported) { - DropdownMenuItem( - text = { - Text(if (isOrientationLocked) "Unlock orientation" else "Lock orientation") - }, - onClick = { - expanded = false - onToggleOrientationLock() - }, - ) - } - if (hasChapters) { - DropdownMenuItem( - text = { Text("Chapters") }, - onClick = { - expanded = false - onOpenChapters() - }, - ) - } - DropdownMenuItem( - text = { Text("Audio and subtitles") }, - enabled = hasTracks, - onClick = { - expanded = false - onOpenTracks() - }, - ) - if (hasQualityMenu) { - DropdownMenuItem( - text = { Text("Quality") }, - onClick = { - expanded = false - onOpenQuality() - }, - ) - } - if (hasMultipleVersions) { - DropdownMenuItem( - text = { Text("Version") }, - onClick = { - expanded = false - onOpenVersion() - }, - ) - } - DropdownMenuItem( - text = { Text("Playback settings") }, - onClick = { - expanded = false - onOpenSettings() - }, - ) - } - } -} - -/** - * Top-bar control button matching iOS `controlButton`: a `size 20` icon inside - * a 48x48 tap target (Android minimum touch target), white, dimmed to 0.3 when - * disabled. - */ -@Composable -private fun ControlButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, - contentDescription: String, - onClick: () -> Unit, - enabled: Boolean = true, -) { - IconButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = if (enabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(22.dp), - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerMenu.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerMenu.kt new file mode 100644 index 0000000000..9801bdef80 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerMenu.kt @@ -0,0 +1,332 @@ +package org.siloserver.silo.android.ui.screens.player + +import android.os.SystemClock +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * How player menus present themselves. + * + * [Docked] is the landscape treatment: the menu docks as a smoked panel on the + * right edge and the picture stays live beside it, so a subtitle or style + * change is visible as it is made. [Sheet] is the portrait (and tabletop) + * bottom sheet. The content of every menu is identical in both. + */ +internal enum class PlayerMenuPresentation { Sheet, Docked } + +internal val LocalPlayerMenuPresentation = staticCompositionLocalOf { PlayerMenuPresentation.Sheet } + +/** + * Reports the left edge (root px) of an open docked panel, or null once it + * closes, so the subtitle canvas can re-center in the part of the picture that + * is still visible. + */ +internal val LocalPlayerMenuDockReporter = staticCompositionLocalOf<(Float?) -> Unit> { {} } + +/** + * Lets one docked menu hand over to the next without the panel sliding out and + * back in: "Settings → Subtitle style" swaps the content in place. + */ +@Stable +internal class PlayerMenuHandoff { + internal var handedOffAtMs: Long = 0L + + internal fun consumeRecent(): Boolean { + val recent = SystemClock.uptimeMillis() - handedOffAtMs < HANDOFF_WINDOW_MS + handedOffAtMs = 0L + return recent + } + + private companion object { + const val HANDOFF_WINDOW_MS = 400L + } +} + +internal val LocalPlayerMenuHandoff = staticCompositionLocalOf { PlayerMenuHandoff() } + +/** Landscape windows wide enough to keep a useful picture beside the panel. */ +internal fun playerMenuPresentationFor( + widthDp: Float, + heightDp: Float, + tabletopMode: Boolean, +): PlayerMenuPresentation = + if (!tabletopMode && widthDp > heightDp && widthDp >= 560f) { + PlayerMenuPresentation.Docked + } else { + PlayerMenuPresentation.Sheet + } + +@OptIn(ExperimentalMaterial3Api::class) +@Stable +internal class PlayerMenuController internal constructor( + val presentation: PlayerMenuPresentation, + val sheetState: SheetState, + val panelState: MutableTransitionState, + private val scope: CoroutineScope, + private val handoff: PlayerMenuHandoff, + private val onDismiss: State<() -> Unit>, +) { + /** + * Closes the menu, then runs [then]. A docked menu that hands over to + * another menu (a non-null [then]) swaps in place instead of animating out. + */ + fun dismiss(then: (() -> Unit)? = null) { + when (presentation) { + PlayerMenuPresentation.Sheet -> scope.launch { + sheetState.hide() + onDismiss.value() + then?.invoke() + } + PlayerMenuPresentation.Docked -> if (then != null) { + handoff.handedOffAtMs = SystemClock.uptimeMillis() + onDismiss.value() + then() + } else { + panelState.targetState = false + scope.launch { + snapshotFlow { panelState.isIdle && !panelState.currentState }.first { it } + onDismiss.value() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun rememberPlayerMenuController(onDismiss: () -> Unit): PlayerMenuController { + val presentation = LocalPlayerMenuPresentation.current + val handoff = LocalPlayerMenuHandoff.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val latestDismiss = rememberUpdatedState(onDismiss) + val panelState = remember { + MutableTransitionState(handoff.consumeRecent()).apply { targetState = true } + } + return remember(presentation, sheetState, panelState) { + PlayerMenuController(presentation, sheetState, panelState, scope, handoff, latestDismiss) + } +} + +/** + * The container every player menu renders into. Content is a column; menus + * that scroll put their list in [PlayerMenuScrollColumn] so it takes the room + * left under the header without pushing a footer off screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PlayerMenu( + controller: PlayerMenuController, + tabletopPaneHeight: Dp?, + content: @Composable ColumnScope.() -> Unit, +) { + when (controller.presentation) { + PlayerMenuPresentation.Sheet -> { + LaunchedEffect(Unit) { controller.sheetState.show() } + PlayerModalBottomSheet( + onDismissRequest = { controller.dismiss() }, + sheetState = controller.sheetState, + tabletopPaneHeight = tabletopPaneHeight, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard) + .padding(bottom = 12.dp), + content = content, + ) + } + } + PlayerMenuPresentation.Docked -> PlayerDockedPanel(controller, content) + } +} + +@Composable +private fun PlayerDockedPanel( + controller: PlayerMenuController, + content: @Composable ColumnScope.() -> Unit, +) { + val reportDockEdge = LocalPlayerMenuDockReporter.current + BackHandler { controller.dismiss() } + DisposableEffect(Unit) { + onDispose { reportDockEdge(null) } + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize().zIndex(20f)) { + val panelWidth = minOf(PlayerChrome.PanelWidth, maxWidth * 0.46f).coerceAtLeast(320.dp) + // The camera cutout can sit on the right in one landscape and not the + // other; clear it so the panel never runs under the camera. + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val cutoutEnd = with(density) { + WindowInsets.displayCutout.getRight(this, layoutDirection).toDp() + } + + AnimatedVisibility( + visibleState = controller.panelState, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(160)), + ) { + // Darken only the panel's side; the picture on the left stays as + // bright as it plays. A tap anywhere outside the panel closes it. + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0.40f to Color.Transparent, + 0.62f to Color.Black.copy(alpha = 0.45f), + ), + ) + .pointerInput(Unit) { detectTapGestures { controller.dismiss() } }, + ) + } + + AnimatedVisibility( + visibleState = controller.panelState, + enter = slideInHorizontally(tween(240, easing = FastOutSlowInEasing)) { it / 3 } + + fadeIn(tween(180)), + exit = slideOutHorizontally(tween(180, easing = FastOutSlowInEasing)) { it / 3 } + + fadeOut(tween(140)), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + val shape = RoundedCornerShape(PlayerChrome.PanelRadius) + Column( + modifier = Modifier + .padding(top = 10.dp, bottom = 10.dp, end = 10.dp + cutoutEnd) + .width(panelWidth) + .fillMaxHeight() + .onGloballyPositioned { reportDockEdge(it.boundsInRoot().left) } + .clip(shape) + .background(PlayerChrome.Smoke) + .border(PlayerPanelBorder, shape) + // Taps on the panel's own empty space must not fall through + // to the dismiss scrim underneath. + .pointerInput(Unit) { detectTapGestures { } } + .padding(bottom = 6.dp), + content = content, + ) + } + } +} + +/** + * The scrolling body of a menu: fills what the header and footer leave, wraps + * when shorter, and fades its bottom edge instead of cutting a row in half. + */ +@Composable +internal fun ColumnScope.PlayerMenuScrollColumn( + modifier: Modifier = Modifier, + horizontalPadding: Dp = 8.dp, + content: @Composable ColumnScope.() -> Unit, +) { + val scrollState = rememberScrollState() + Column( + modifier = modifier + .weight(1f, fill = false) + .fillMaxWidth() + .playerFadingEdges(scrollState) + .verticalScroll(scrollState) + .padding(horizontal = horizontalPadding), + content = content, + ) +} + +/** + * Fades the top and bottom edges of a scrolling list while there is more to + * scroll in that direction, so the last visible row reads as "continues" + * rather than as clipped. + */ +internal fun Modifier.playerFadingEdges( + state: ScrollableState, + top: Dp = 16.dp, + bottom: Dp = 40.dp, +): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + if (state.canScrollBackward) { + val height = top.toPx() + drawRect( + brush = Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black, endY = height), + size = Size(size.width, height), + blendMode = BlendMode.DstIn, + ) + } + if (state.canScrollForward) { + val height = bottom.toPx() + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Black, + 1f to Color.Transparent, + startY = size.height - height, + endY = size.height, + ), + topLeft = Offset(0f, size.height - height), + size = Size(size.width, height), + blendMode = BlendMode.DstIn, + ) + } + } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index ae21abedb3..624c30b03e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -8,7 +8,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -21,14 +24,15 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Group -import androidx.compose.material.icons.outlined.Bedtime +import androidx.compose.material.icons.rounded.Bedtime +import androidx.compose.material.icons.rounded.Groups import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text 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 @@ -37,6 +41,7 @@ import androidx.compose.runtime.remember 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp @@ -92,8 +97,14 @@ fun PlayerOverlay( onSelectSubtitle: (Int) -> Unit, onSelectAudio: (Int) -> Unit, onSelectVersion: (Int) -> Unit, - // Google Cast (Chromecast) button rendered in the transport top bar. - castSlot: @Composable () -> Unit = {}, + // Google Cast (Chromecast) button rendered in the transport top bar; the + // modifier dresses it as a player disc. + castSlot: @Composable (Modifier) -> Unit = {}, + pictureInPictureAvailable: Boolean = false, + onEnterPictureInPicture: () -> Unit = {}, + // Left edge (root px) of an open docked menu, null when none is open, so + // the subtitle canvas can re-center in the visible part of the picture. + onDockedMenuEdgeChanged: (Float?) -> Unit = {}, modifier: Modifier = Modifier, ) { // Sheet visibility — one bool per sheet. iOS uses a sealed `activeSheet` @@ -101,6 +112,7 @@ fun PlayerOverlay( // `rememberModalBottomSheetState`, so per-sheet bools are the natural // fit (and the sheets can't be nested anyway). var tracksSheetVisible by remember { mutableStateOf(false) } + var tracksInitialTab by remember { mutableStateOf(TracksTab.Subtitles) } var qualitySheetVisible by remember { mutableStateOf(false) } var versionSheetVisible by remember { mutableStateOf(false) } var settingsSheetVisible by remember { mutableStateOf(false) } @@ -218,7 +230,26 @@ fun PlayerOverlay( // stream URLs); hidden for offline/local playback. val subtitleToolsAvailable = state.sessionId != null && state.mediaFileId != null - Box(modifier = modifier.fillMaxSize()) { + val menuHandoff = remember { PlayerMenuHandoff() } + // A menu that is not drawn (no plan, or Version in a party) must not hide the controls. + val anyMenuOpen = tracksSheetVisible || (qualitySheetVisible && hasQualityMenu) || + (versionSheetVisible && hasVersionMenu) || settingsSheetVisible || + subtitleStyleVisible || sleepTimerVisible || chaptersSheetVisible || statsSheetVisible || + subtitleSearchVisible || aiTranslateVisible + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val menuPresentation = playerMenuPresentationFor( + widthDp = maxWidth.value, + heightDp = maxHeight.value, + tabletopMode = tabletopMode || tabletopPaneHeight != null, + ) + // A docked menu replaces the controls; the picture beside it stays clear. + val menuDocked = anyMenuOpen && menuPresentation == PlayerMenuPresentation.Docked + CompositionLocalProvider( + LocalPlayerMenuPresentation provides menuPresentation, + LocalPlayerMenuHandoff provides menuHandoff, + LocalPlayerMenuDockReporter provides onDockedMenuEdgeChanged, + ) { // Gesture layer stays out of the tree while controls are visible so // full-screen pointer handlers cannot consume taps meant for buttons. if (!alwaysShowControls && !state.showControls && !state.showUpNext) { @@ -250,7 +281,7 @@ fun PlayerOverlay( modifier = Modifier .size(56.dp) .align(Alignment.Center), - color = Color.White, + color = PlayerChrome.Paper, strokeWidth = 3.dp, ) } @@ -265,16 +296,8 @@ fun PlayerOverlay( .padding(top = 64.dp) .zIndex(3f), ) { - Surface( - color = Color.Black.copy(alpha = 0.72f), - shape = RoundedCornerShape(999.dp), - ) { - Text( - text = "2x", - color = Color.White, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp), - ) + PlayerStatusChip { + Text(text = "2×", style = PlayerType.PillLabel.copy(fontSize = 15.sp)) } } @@ -317,17 +340,15 @@ fun PlayerOverlay( .zIndex(10f), contentAlignment = Alignment.TopCenter, ) { - Surface( - color = Color.Black.copy(alpha = 0.78f), - shape = MaterialTheme.shapes.medium, - ) { - Text( - text = message.text, - color = Color.White, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - ) - } + Text( + text = message.text, + style = PlayerType.RowTitle, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(PlayerChrome.Smoke) + .border(1.dp, PlayerChrome.PanelStroke, RoundedCornerShape(16.dp)) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) } } @@ -342,34 +363,24 @@ fun PlayerOverlay( .padding(top = 16.dp), contentAlignment = Alignment.TopCenter, ) { - Row( - modifier = Modifier - .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(20.dp)) - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + PlayerStatusChip { Icon( - imageVector = Icons.Filled.Group, + imageVector = Icons.Rounded.Groups, contentDescription = null, - tint = Color.White, - modifier = Modifier.size(14.dp), + tint = PlayerChrome.Paper, + modifier = Modifier.size(15.dp), ) Spacer(modifier = Modifier.width(6.dp)) val label = roomStatus ?: "${roomSnapshot.memberCount} watching" Text( text = label, - color = Color.White, - fontSize = 13.sp, + style = PlayerType.PillLabel.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Medium), maxLines = 2, modifier = Modifier.widthIn(max = 360.dp), ) if (isRoomHost && roomSnapshot.code.isNotBlank()) { Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Code ${roomSnapshot.code}", - color = Color.White, - fontSize = 12.sp, - ) + Text(text = "Code ${roomSnapshot.code}", style = PlayerType.Time.copy(color = PlayerChrome.Graphite)) } } } @@ -377,7 +388,7 @@ fun PlayerOverlay( // Transport controls (shown/hidden with animation) AnimatedVisibility( - visible = (alwaysShowControls || state.showControls) && !state.showUpNext, + visible = (alwaysShowControls || state.showControls) && !state.showUpNext && !menuDocked, enter = fadeIn(), exit = fadeOut(), modifier = Modifier @@ -387,6 +398,12 @@ fun PlayerOverlay( PlayerControls( title = state.title, subtitle = state.subtitle, + eyebrow = playerEyebrow(state), + tracksValue = playerTracksValue(state), + chapterValue = chapterTitleAt(state.chapters, state.position), + qualityValue = playerQualityLabel(qualityOptions, activeQualityId), + pictureInPictureAvailable = pictureInPictureAvailable, + onEnterPictureInPicture = onEnterPictureInPicture, isPlaying = state.isPlaying, isPaused = state.isPaused, position = state.position, @@ -400,7 +417,6 @@ fun PlayerOverlay( hasChapters = state.chapters.isNotEmpty(), hasTracks = state.subtitleTracks.isNotEmpty() || state.audioTracks.isNotEmpty(), hasQualityMenu = hasQualityMenu, - hasMultipleVersions = hasVersionMenu, isOrientationLocked = isOrientationLocked, orientationLockSupported = orientationLockSupported, tabletopMode = tabletopMode, @@ -425,13 +441,16 @@ fun PlayerOverlay( } }, onOpenChapters = { chaptersSheetVisible = true }, - onOpenTracks = { tracksSheetVisible = true }, + onOpenTracks = { + tracksInitialTab = TracksTab.Subtitles + tracksSheetVisible = true + }, onOpenQuality = { qualitySheetVisible = true }, - onOpenVersion = { versionSheetVisible = true }, onOpenSettings = { settingsSheetVisible = true }, onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, onPlayNextEpisode = viewModel::playUpNextNow, onSetBrightness = onSetBrightness, + onScrubbingChange = viewModel::onScrubbingChanged, castSlot = castSlot, ) } @@ -548,222 +567,226 @@ fun PlayerOverlay( SleepTimerChip(remainingSeconds = active.remainingSeconds) } } - } - - // Combined audio + subtitle picker — opened from the top-bar - // captions.bubble icon (iOS parity). Replaces the previous side-popup - // "SettingsPanel" + individual SubtitleSelector / AudioTrackSelector - // sheets with a single sectioned ModalBottomSheet. - TracksSheet( - isVisible = tracksSheetVisible, - audioTracks = state.audioTracks, - selectedAudioIndex = state.selectedAudioIndex, - subtitles = state.subtitleTracks, - selectedSubtitleIndex = state.selectedSubtitleIndex, - onSelectAudio = onSelectAudio, - onSelectSubtitle = onSelectSubtitle, - onDismiss = { tracksSheetVisible = false }, - showSearchAction = subtitleToolsAvailable, - showTranslateAction = subtitleToolsAvailable && - subtitleTools.aiStatus?.let { it.enabled || it.transcribeEnabled } == true, - onSearchSubtitles = { - tracksSheetVisible = false - subtitleSearchVisible = true - }, - onTranslateWithAi = { - tracksSheetVisible = false - aiTranslateVisible = true - }, - subtitleStatus = subtitleSync::statusLabelFor, - timingActions = subtitleSync.timingActionsFor( - state.subtitleTracks.getOrNull(state.selectedSubtitleIndex)?.syncKey, - ), - onSyncSubtitle = viewModel::requestSubtitleSync, - onResetTiming = viewModel::resetSubtitleTiming, - tabletopPaneHeight = tabletopPaneHeight, - ) - if (subtitleSearchVisible) { - SubtitleSearchSheet( - tools = subtitleTools, - defaultLanguage = LanguageNames.searchCode(state.preferredTextLanguage), - onSearch = viewModel::searchSubtitles, - onDownload = viewModel::downloadSubtitle, - onDismiss = { - subtitleSearchVisible = false - viewModel.onSearchSheetClosed() + // Player menus. In landscape they dock beside the picture (see + // PlayerMenu); the controls step aside while one is open. + // Audio & Subtitles — opened from its action pill or a settings row. + TracksSheet( + isVisible = tracksSheetVisible, + audioTracks = state.audioTracks, + selectedAudioIndex = state.selectedAudioIndex, + subtitles = state.subtitleTracks, + selectedSubtitleIndex = state.selectedSubtitleIndex, + onSelectAudio = onSelectAudio, + onSelectSubtitle = onSelectSubtitle, + onDismiss = { tracksSheetVisible = false }, + initialTab = tracksInitialTab, + showSearchAction = subtitleToolsAvailable, + showTranslateAction = subtitleToolsAvailable && + subtitleTools.aiStatus?.let { it.enabled || it.transcribeEnabled } == true, + onSearchSubtitles = { + tracksSheetVisible = false + subtitleSearchVisible = true }, - onBack = { - subtitleSearchVisible = false - tracksSheetVisible = true - viewModel.onSearchSheetClosed() + onTranslateWithAi = { + tracksSheetVisible = false + aiTranslateVisible = true }, + subtitleStatus = subtitleSync::statusLabelFor, + timingActions = subtitleSync.timingActionsFor( + state.subtitleTracks.getOrNull(state.selectedSubtitleIndex)?.syncKey, + ), + onSyncSubtitle = viewModel::requestSubtitleSync, + onResetTiming = viewModel::resetSubtitleTiming, tabletopPaneHeight = tabletopPaneHeight, ) - } - if (aiTranslateVisible) { - AiTranslateSheet( - tools = subtitleTools, - subtitleTracks = state.subtitleTracks, - audioTracks = state.audioTracks, - defaultTargetLanguage = LanguageNames.searchCode(state.preferredTextLanguage), - onRefreshQuota = viewModel::refreshAiQuota, - onSubmit = viewModel::startAiJob, - onCancelJob = viewModel::cancelAiJob, - onDismiss = { - aiTranslateVisible = false - viewModel.onTranslateSheetClosed() + if (subtitleSearchVisible) { + SubtitleSearchSheet( + tools = subtitleTools, + defaultLanguage = LanguageNames.searchCode(state.preferredTextLanguage), + onSearch = viewModel::searchSubtitles, + onDownload = viewModel::downloadSubtitle, + onDismiss = { + subtitleSearchVisible = false + viewModel.onSearchSheetClosed() + }, + onBack = { + subtitleSearchVisible = false + tracksSheetVisible = true + viewModel.onSearchSheetClosed() + }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + if (aiTranslateVisible) { + AiTranslateSheet( + tools = subtitleTools, + subtitleTracks = state.subtitleTracks, + audioTracks = state.audioTracks, + defaultTargetLanguage = LanguageNames.searchCode(state.preferredTextLanguage), + onRefreshQuota = viewModel::refreshAiQuota, + onSubmit = viewModel::startAiJob, + onCancelJob = viewModel::cancelAiJob, + onDismiss = { + aiTranslateVisible = false + viewModel.onTranslateSheetClosed() + }, + onBack = { + aiTranslateVisible = false + tracksSheetVisible = true + viewModel.onTranslateSheetClosed() + }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + if (qualitySheetVisible && hasQualityMenu) { + PlaybackQualitySheet( + options = qualityOptions, + activeId = activeQualityId, + inRoom = inRoom, + onSelect = viewModel::onSelectQuality, + onDismiss = { qualitySheetVisible = false }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + if (versionSheetVisible && hasVersionMenu) { + VersionSelector( + versions = state.versions, + selectedIndex = state.selectedVersionIndex, + onSelect = onSelectVersion, + onDismiss = { versionSheetVisible = false }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + + // Glass-style playback settings sheet (speed / aspect / HDR / auto-skip / auto-play) + PlayerSettingsSheet( + isVisible = settingsSheetVisible, + onDismiss = { settingsSheetVisible = false }, + playbackSpeed = playbackSpeed, + onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, + videoGravity = videoGravity, + onSetVideoGravity = viewModel::onSetVideoGravity, + letterboxExpansion = viewModel.letterboxExpansion.collectAsState().value, + onSetLetterboxExpansion = viewModel::onSetLetterboxExpansion, + introSkipMode = viewModel.introSkipMode.collectAsState().value, + onSetIntroSkipMode = viewModel::onSetIntroSkipMode, + autoPlayNextEnabled = viewModel.autoPlayNextEnabled.collectAsState().value, + onSetAutoPlayNext = viewModel::onSetAutoPlayNext, + hdrEnabled = viewModel.hdrEnabled.collectAsState().value, + onSetHdrEnabled = viewModel::onSetHdrEnabled, + dolbyVisionEnabled = viewModel.dolbyVisionEnabled.collectAsState().value, + onSetDolbyVisionEnabled = viewModel::onSetDolbyVisionEnabled, + // A party hides speed (session-only 1x) and the version picker. + showPlaybackSpeed = !inRoom, + showQuality = hasQualityMenu, + qualityLabel = playerQualityLabel(qualityOptions, activeQualityId), + onOpenQuality = { + settingsSheetVisible = false + qualitySheetVisible = true }, - onBack = { - aiTranslateVisible = false + showVersion = hasVersionMenu, + versionLabel = playerVersionLabel(state.versions, state.selectedVersionIndex), + onOpenVersion = { + settingsSheetVisible = false + versionSheetVisible = true + }, + audioLabel = playerAudioLabel(state.audioTracks, state.selectedAudioIndex), + subtitleLabel = playerSubtitleLabel(state.subtitleTracks, state.selectedSubtitleIndex), + subtitleStyleLabel = subtitleStyleSummary(viewModel.subtitleAppearance.collectAsState().value), + onOpenTracks = { tab -> + settingsSheetVisible = false + tracksInitialTab = tab tracksSheetVisible = true - viewModel.onTranslateSheetClosed() }, + onOpenSubtitleStyle = { + settingsSheetVisible = false + subtitleStyleVisible = true + }, + onOpenSleepTimer = { + settingsSheetVisible = false + sleepTimerVisible = true + }, + stats = state.stats, + onOpenPlaybackStats = { + settingsSheetVisible = false + statsSheetVisible = true + }, + audioDelayMs = viewModel.audioDelayMs.collectAsState().value, + audioDelayEnabled = state.playbackPlan?.claims?.audio?.passthrough != true, + onSetAudioDelay = viewModel::onSetAudioDelay, + subtitleDelayMs = viewModel.subtitleDelayMs.collectAsState().value, + onSetSubtitleDelay = viewModel::onSetSubtitleDelay, + sleepTimerState = sleepTimerState, tabletopPaneHeight = tabletopPaneHeight, ) - } - if (qualitySheetVisible && hasQualityMenu) { - PlaybackQualitySheet( - options = qualityOptions, - activeId = activeQualityId, - inRoom = inRoom, - onSelect = viewModel::onSelectQuality, - onDismiss = { qualitySheetVisible = false }, + PlaybackStatsSheet( + isVisible = statsSheetVisible, + stats = state.stats, + onDismiss = { statsSheetVisible = false }, + onBack = { + statsSheetVisible = false + settingsSheetVisible = true + }, tabletopPaneHeight = tabletopPaneHeight, ) - } - if (versionSheetVisible && hasVersionMenu) { - VersionSelector( - versions = state.versions, - selectedIndex = state.selectedVersionIndex, - onSelect = onSelectVersion, - onDismiss = { versionSheetVisible = false }, + // Chapters picker — opened from the HUD chapters button (HUD product + // decision: chapters + tracks + quality; no longer reachable from the + // gear sheet). Selecting a row seeks the player to the chapter's + // startSeconds. The HUD button hides when the active version has no + // embedded chapters. + ChaptersSheet( + isVisible = chaptersSheetVisible, + chapters = state.chapters, + position = state.position, + duration = state.duration, + // A room seek in a party (a guest is told only the host can seek). + onSelect = { idx -> + viewModel.onSeekToChapter(idx)?.let(onSeek) + }, + onDismiss = { chaptersSheetVisible = false }, tabletopPaneHeight = tabletopPaneHeight, ) - } - // Glass-style playback settings sheet (speed / aspect / HDR / auto-skip / auto-play) - PlayerSettingsSheet( - isVisible = settingsSheetVisible, - onDismiss = { settingsSheetVisible = false }, - playbackSpeed = playbackSpeed, - onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, - videoGravity = videoGravity, - onSetVideoGravity = viewModel::onSetVideoGravity, - letterboxExpansion = viewModel.letterboxExpansion.collectAsState().value, - onSetLetterboxExpansion = viewModel::onSetLetterboxExpansion, - introSkipMode = viewModel.introSkipMode.collectAsState().value, - onSetIntroSkipMode = viewModel::onSetIntroSkipMode, - autoPlayNextEnabled = viewModel.autoPlayNextEnabled.collectAsState().value, - onSetAutoPlayNext = viewModel::onSetAutoPlayNext, - hdrEnabled = viewModel.hdrEnabled.collectAsState().value, - onSetHdrEnabled = viewModel::onSetHdrEnabled, - dolbyVisionEnabled = viewModel.dolbyVisionEnabled.collectAsState().value, - onSetDolbyVisionEnabled = viewModel::onSetDolbyVisionEnabled, - // A party hides speed (session-only 1x) and the version picker. - showPlaybackSpeed = !inRoom, - showQuality = hasQualityMenu, - qualityLabel = playerQualityLabel(qualityOptions, activeQualityId), - onOpenQuality = { - settingsSheetVisible = false - qualitySheetVisible = true - }, - showVersion = hasVersionMenu, - versionLabel = playerVersionLabel(state.versions, state.selectedVersionIndex), - onOpenVersion = { - settingsSheetVisible = false - versionSheetVisible = true - }, - audioLabel = playerAudioLabel(state.audioTracks, state.selectedAudioIndex), - subtitleLabel = playerSubtitleLabel(state.subtitleTracks, state.selectedSubtitleIndex), - onOpenTracks = { - settingsSheetVisible = false - tracksSheetVisible = true - }, - onOpenSubtitleStyle = { - settingsSheetVisible = false - subtitleStyleVisible = true - }, - onOpenSleepTimer = { - settingsSheetVisible = false - sleepTimerVisible = true - }, - stats = state.stats, - onOpenPlaybackStats = { - settingsSheetVisible = false - statsSheetVisible = true - }, - audioDelayMs = viewModel.audioDelayMs.collectAsState().value, - audioDelayEnabled = state.playbackPlan?.claims?.audio?.passthrough != true, - onSetAudioDelay = viewModel::onSetAudioDelay, - subtitleDelayMs = viewModel.subtitleDelayMs.collectAsState().value, - onSetSubtitleDelay = viewModel::onSetSubtitleDelay, - sleepTimerState = sleepTimerState, - tabletopPaneHeight = tabletopPaneHeight, - ) - - PlaybackStatsSheet( - isVisible = statsSheetVisible, - stats = state.stats, - onDismiss = { statsSheetVisible = false }, - onBack = { - statsSheetVisible = false - settingsSheetVisible = true - }, - tabletopPaneHeight = tabletopPaneHeight, - ) - - // Chapters picker — opened from the HUD chapters button (HUD product - // decision: chapters + tracks + quality; no longer reachable from the - // gear sheet). Selecting a row seeks the player to the chapter's - // startSeconds. The HUD button hides when the active version has no - // embedded chapters. - ChaptersSheet( - isVisible = chaptersSheetVisible, - chapters = state.chapters, - position = state.position, - // A room seek in a party (a guest is told only the host can seek). - onSelect = { idx -> - viewModel.onSeekToChapter(idx)?.let(onSeek) - }, - onDismiss = { chaptersSheetVisible = false }, - tabletopPaneHeight = tabletopPaneHeight, - ) - - // Subtitle styling sheet — opened from the "Subtitle Style" row in - // PlayerSettingsSheet. Material 3 sheets can't nest, so the parent sheet - // dismisses itself before we open this one. - SubtitleStyleSheet( - isVisible = subtitleStyleVisible, - appearance = viewModel.subtitleAppearance.collectAsState().value, - showTextOpacity = viewModel.subtitleTextOpacitySupported.collectAsState().value, - onUpdate = viewModel::onEditSubtitleAppearance, - onDismiss = { subtitleStyleVisible = false }, - onBack = { - subtitleStyleVisible = false - settingsSheetVisible = true - }, - tabletopPaneHeight = tabletopPaneHeight, - ) + // Subtitle styling sheet — opened from the "Subtitle Style" row in + // PlayerSettingsSheet. Material 3 sheets can't nest, so the parent sheet + // dismisses itself before we open this one. + SubtitleStyleSheet( + isVisible = subtitleStyleVisible, + appearance = viewModel.subtitleAppearance.collectAsState().value, + showTextOpacity = viewModel.subtitleTextOpacitySupported.collectAsState().value, + onUpdate = viewModel::onEditSubtitleAppearance, + onDismiss = { subtitleStyleVisible = false }, + onBack = { + subtitleStyleVisible = false + settingsSheetVisible = true + }, + tabletopPaneHeight = tabletopPaneHeight, + ) - // Sleep timer picker — opened from the "Sleep Timer" row in - // PlayerSettingsSheet. Same nested-sheet caveat as Subtitle Style above. - SleepTimerSheet( - isVisible = sleepTimerVisible, - activeState = sleepTimerState, - defaultMinutes = sleepTimerDefault, - onStart = viewModel::onStartSleepTimer, - onCancel = viewModel::onCancelSleepTimer, - onDismiss = { sleepTimerVisible = false }, - onBack = { - sleepTimerVisible = false - settingsSheetVisible = true - }, - tabletopPaneHeight = tabletopPaneHeight, - ) + // Sleep timer picker — opened from the "Sleep Timer" row in + // PlayerSettingsSheet. Same nested-sheet caveat as Subtitle Style above. + SleepTimerSheet( + isVisible = sleepTimerVisible, + activeState = sleepTimerState, + defaultMinutes = sleepTimerDefault, + onStart = viewModel::onStartSleepTimer, + onCancel = viewModel::onCancelSleepTimer, + onDismiss = { sleepTimerVisible = false }, + onBack = { + sleepTimerVisible = false + settingsSheetVisible = true + }, + tabletopPaneHeight = tabletopPaneHeight, + ) + } + } } /** @@ -772,30 +795,79 @@ fun PlayerOverlay( */ @Composable private fun SleepTimerChip(remainingSeconds: Int) { - Row( - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.55f), - shape = RoundedCornerShape(20.dp), - ) - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + PlayerStatusChip { Icon( - imageVector = Icons.Outlined.Bedtime, + imageVector = Icons.Rounded.Bedtime, contentDescription = "Sleep timer active", - tint = Color.White, + tint = PlayerChrome.Paper, modifier = Modifier.size(14.dp), ) Spacer(modifier = Modifier.width(6.dp)) - Text( - text = formatRemaining(remainingSeconds), - color = Color.White, - fontSize = 13.sp, - ) + Text(text = formatRemaining(remainingSeconds), style = PlayerType.Time) } } +/** A small smoked capsule for status that sits on the picture. */ +@Composable +private fun PlayerStatusChip(content: @Composable RowScope.() -> Unit) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(Color.Black.copy(alpha = 0.55f)) + .border(1.dp, PlayerChrome.DiscStroke, RoundedCornerShape(20.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +/** + * The small line above the title: "MOVIE · 2026", or "SEVERANCE · S2:E4" for + * an episode, whose title is then the episode's own. + */ +internal fun playerEyebrow(state: PlayerViewModel.PlayerUiState): String { + val series = state.seriesTitle?.takeIf { it.isNotBlank() } + if (series != null) { + val code = if (state.seasonNumber != null && state.episodeNumber != null) { + "S${state.seasonNumber}:E${state.episodeNumber}" + } else { + null + } + return listOfNotNull(series, code).joinToString(" · ") + } + val kind = when { + state.contentType == "movie" -> "Movie" + else -> null + } + return listOfNotNull(kind, state.subtitle.takeIf { it.isNotBlank() }).joinToString(" · ") +} + +/** The value on the Audio & Subtitles pill: the subtitle language when one is on. */ +internal fun playerTracksValue(state: PlayerViewModel.PlayerUiState): String? = + if (state.selectedSubtitleIndex >= 0) { + subtitleTrackShortLabel(state.subtitleTracks.getOrNull(state.selectedSubtitleIndex), state.selectedSubtitleIndex) + } else { + null + } + +/** "Large · White · Box" for the settings menu's Subtitle style row. */ +internal fun subtitleStyleSummary(appearance: org.siloserver.silo.model.settings.SubtitleAppearance): String { + val size = when (appearance.fontSize) { + org.siloserver.silo.model.settings.SubtitleFontSizePreset.Small -> "Small" + org.siloserver.silo.model.settings.SubtitleFontSizePreset.Medium -> "Medium" + org.siloserver.silo.model.settings.SubtitleFontSizePreset.Large -> "Large" + org.siloserver.silo.model.settings.SubtitleFontSizePreset.XLarge -> "Extra large" + else -> "Largest" + } + val background = when (appearance.backgroundStyle) { + org.siloserver.silo.model.settings.SubtitleBackgroundStylePreset.Box -> "Box" + org.siloserver.silo.model.settings.SubtitleBackgroundStylePreset.Shadow -> "Shadow" + org.siloserver.silo.model.settings.SubtitleBackgroundStylePreset.Outline -> "Outline" + else -> "No background" + } + return "$size · $background" +} + // Directional gravity steps, clamped at both ends (iOS nextVideoGravity / // previousVideoGravity in MobilePlayerGestureLayer — no wrap-around). internal fun nextMobileVideoGravity(current: String): String = when (current) { @@ -844,22 +916,8 @@ internal fun playerAudioLabel( val track = tracks.getOrNull(selectedIndex) ?: return "Default" // Language first, not title: a container's audio title is often the full // codec string ("ATSC A/52B (AC-3, E-AC-3)") and swamps the row. - val name = track.language?.takeIf { it.isNotBlank() }?.uppercase() - ?: track.title?.takeIf { it.isNotBlank() } - ?: "Audio ${selectedIndex + 1}" - val detail = listOfNotNull( - track.codec?.takeIf { it.isNotBlank() }?.uppercase(), - track.channels?.let(::audioChannelLabel), - ).joinToString(" ") - return if (detail.isBlank()) name else "$name · $detail" -} - -private fun audioChannelLabel(channels: Int): String = when (channels) { - 1 -> "Mono" - 2 -> "Stereo" - 6 -> "5.1" - 8 -> "7.1" - else -> "${channels}ch" + val presentation = audioTrackPresentation(track, selectedIndex) + return listOfNotNull(presentation.title, audioChannelsDisplayName(track.channels)).joinToString(" · ") } internal fun playerSubtitleLabel( @@ -867,6 +925,5 @@ internal fun playerSubtitleLabel( selectedIndex: Int, ): String { if (selectedIndex < 0) return "Off" - val track = tracks.getOrNull(selectedIndex) ?: return "Off" - return subtitleTrackLabel(track, selectedIndex) + return subtitleTrackShortLabel(tracks.getOrNull(selectedIndex), selectedIndex) ?: "Off" } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerProgressBar.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerProgressBar.kt index bbc60398c1..f346e6432b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerProgressBar.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerProgressBar.kt @@ -1,61 +1,77 @@ package org.siloserver.silo.android.ui.screens.player +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeight -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf 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.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt import org.siloserver.silo.android.ui.util.formatClockTime import org.siloserver.silo.model.catalog.TimeRange import org.siloserver.silo.model.catalog.VersionChapter /** - * Seek bar with current/total time and a buffered-ahead track, mirroring - * iOS `MobilePlayerControls.progressSlider`: + * The seek bar. Like iOS, the whole bar is the handle: it grows from 5 to + * 11dp under the finger, a bubble above shows the target time, the chapter, + * and how far the scrub has moved, and a tick marks where it started. Chapters + * show as gaps in the bar rather than tick marks, and a light haptic fires as + * the scrub crosses into a new chapter. * - * - three track regions — played, buffered (safe to seek into), base; - * - detected marker ranges tinted as bands (intro cyan, recap green, - * credits orange, preview purple); - * - a 2dp chapter tick per chapter, drawn under the played fill; - * - while scrubbing, a preview bubble above the thumb with the target time - * and the chapter title at that point (text only — iOS has no thumbnail - * trickplay either). + * Track regions: played (Paper), buffered (safe to seek into), base. Detected + * markers are tinted bands under the played fill (intro cyan, recap green, + * credits orange, preview purple). */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlayerProgressBar( position: Double, @@ -69,199 +85,283 @@ fun PlayerProgressBar( credits: TimeRange? = null, recap: TimeRange? = null, preview: TimeRange? = null, + showTimes: Boolean = true, + onScrubbingChange: (Boolean) -> Unit = {}, ) { var isSeeking by remember { mutableStateOf(false) } - var seekPosition by remember { mutableFloatStateOf(0f) } + var seekPosition by remember { mutableDoubleStateOf(0.0) } + var scrubStart by remember { mutableDoubleStateOf(0.0) } var barWidthPx by remember { mutableFloatStateOf(0f) } + var bubbleWidthPx by remember { mutableIntStateOf(0) } + var lastChapter by remember { mutableIntStateOf(-1) } + var showTotal by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + val density = LocalDensity.current + val latestOnSeek by rememberUpdatedState(onSeek) + val latestOnScrubbing by rememberUpdatedState(onScrubbingChange) + // The gesture coroutine outlives recompositions, so it reads these + // through state rather than the values it started with. + val latestPosition by rememberUpdatedState(position) + val latestChapters by rememberUpdatedState(chapters) val hasKnownDuration = duration.isFinite() && duration > 0.0 - val maxDuration = if (hasKnownDuration) duration.toFloat() else 1f - val rawDisplayPosition = if (isSeeking) seekPosition else position.toFloat() - val displayPosition = if (hasKnownDuration) { - rawDisplayPosition.coerceIn(0f, maxDuration) - } else { - rawDisplayPosition.coerceAtLeast(0f) - } - val sliderPosition = if (hasKnownDuration) displayPosition else 0f - val playedFraction = if (hasKnownDuration) displayPosition / maxDuration else 0f + val seekable = enabled && hasKnownDuration + val displayPosition = (if (isSeeking) seekPosition else position) + .let { if (hasKnownDuration) it.coerceIn(0.0, duration) else it.coerceAtLeast(0.0) } + val playedFraction = if (hasKnownDuration) (displayPosition / duration).toFloat() else 0f val bufferedFraction = if (hasKnownDuration) { - (bufferedPosition.toFloat().coerceIn(0f, maxDuration) / maxDuration) - .coerceIn(playedFraction, 1f) + (bufferedPosition.coerceIn(0.0, duration) / duration).toFloat().coerceAtLeast(playedFraction) } else { 0f } - // iOS bottom bar is VStack(spacing: 8): progress slider, then the time row. - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Scrub preview bubble — pinned above the bar at the drag position, - // clamped so it never runs off-screen (iOS clamps to [80, width-80]pt). - Box(modifier = Modifier.fillMaxWidth()) { - if (isSeeking) { - val density = LocalDensity.current - val barWidthDp = with(density) { barWidthPx.toDp() } - val clampPad = 80.dp - val bubbleX = (barWidthDp * playedFraction) - .coerceIn(clampPad, (barWidthDp - clampPad).coerceAtLeast(clampPad)) - val chapterTitle = chapterTitleAt(chapters, seekPosition.toDouble()) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .offset(x = bubbleX - clampPad) - .padding(bottom = 4.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.Black.copy(alpha = 0.82f)) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - text = formatClockTime(seekPosition.toDouble()), - fontSize = 19.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = Color.White, - ) - chapterTitle?.let { title -> - Text( - text = title, - modifier = Modifier.widthIn(max = 160.dp), - fontSize = 12.sp, - color = Color.White, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + val barHeight by animateDpAsState( + targetValue = if (isSeeking) 11.dp else 5.dp, + animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow), + label = "seekBarHeight", + ) + val timesAlpha by animateFloatAsState(if (isSeeking) 0f else 1f, label = "seekTimesAlpha") + + fun positionAt(x: Float): Double = + if (barWidthPx <= 0f) 0.0 else (x / barWidthPx).coerceIn(0f, 1f).toDouble() * duration + + fun chapterIndexAt(seconds: Double): Int = latestChapters.indexOfLast { it.startSeconds <= seconds } + + Column(modifier = modifier.fillMaxWidth()) { + if (showTimes) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(timesAlpha), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatClockTime(displayPosition), + style = PlayerType.Time, + modifier = Modifier.weight(1f), + ) + // Tap to switch between time left and total length (iOS). + Text( + text = if (showTotal && hasKnownDuration) { + formatClockTime(duration) + } else { + remainingTimeLabel(displayPosition, duration) + }, + style = PlayerType.Time.copy(color = PlayerChrome.Graphite), + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClickLabel = "Switch between time left and total length", + ) { showTotal = !showTotal }, + ) } } - Slider( - value = sliderPosition, - enabled = enabled && hasKnownDuration, - onValueChange = { value -> - isSeeking = true - seekPosition = value - }, - onValueChangeFinished = { - isSeeking = false - onSeek(seekPosition.toDouble()) - }, - valueRange = 0f..maxDuration, - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.primary, - activeTrackColor = MaterialTheme.colorScheme.primary, - inactiveTrackColor = Color.White.copy(alpha = 0.3f), - ), - // iOS-style dot instead of Material's chunky pill: a small circle - // that grows slightly while scrubbing (the target time shows in the - // floating preview bubble above). Ignores the SliderState param. - thumb = { - Box( - modifier = Modifier - .size(if (isSeeking) 20.dp else 14.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape), - ) - }, - track = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.16f)) - .onSizeChanged { barWidthPx = it.width.toFloat() }, - ) { - // Buffered-ahead: downloaded and safe to seek into. - Box( - modifier = Modifier - .fillMaxWidth(bufferedFraction) - .fillMaxHeight() - .background(Color.White.copy(alpha = 0.52f)), - ) - // Marker bands — tinted segments for each detected marker - // kind (intro/recap/credits/preview), drawn under the played - // fill so the playhead still reads clearly over them. + Box( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .onSizeChanged { barWidthPx = it.width.toFloat() } + .semantics { + contentDescription = "Playback position" + stateDescription = "${formatClockTime(displayPosition)} of ${formatClockTime(duration)}" if (hasKnownDuration) { - val density = LocalDensity.current - val barWidthDp = with(density) { barWidthPx.toDp() } - val markers = listOfNotNull( - intro?.let { it to Color.Cyan }, - recap?.let { it to Color(0xFF8BC34A) }, - credits?.let { it to Color(0xFFFFB74D) }, - preview?.let { it to Color(0xFFBA68C8) }, + progressBarRangeInfo = ProgressBarRangeInfo( + current = displayPosition.toFloat(), + range = 0f..duration.toFloat(), ) - markers.forEach { (range, color) -> - val startFraction = (range.start / maxDuration).toFloat().coerceIn(0f, 1f) - val endFraction = (range.end / maxDuration).toFloat().coerceIn(startFraction, 1f) - if (endFraction > startFraction) { - Box( - modifier = Modifier - .offset(x = barWidthDp * startFraction) - .width(barWidthDp * (endFraction - startFraction)) - .fillMaxHeight() - .background(color.copy(alpha = 0.4f)), - ) - } + } + if (seekable) { + setProgress { target -> + latestOnSeek(target.toDouble().coerceIn(0.0, duration)) + true } } - // Chapter ticks, under the played fill (iOS: the fill - // covers ticks in played territory). - if (hasKnownDuration && chapters.isNotEmpty()) { - val density = LocalDensity.current - val barWidthDp = with(density) { barWidthPx.toDp() } - chapters.forEach { chapter -> - val fraction = (chapter.startSeconds / maxDuration).toFloat() - if (fraction > 0f && fraction < 1f) { - Box( - modifier = Modifier - .offset(x = barWidthDp * fraction - 1.dp) - .width(2.dp) - .fillMaxHeight() - .background(Color.White.copy(alpha = 0.72f)), - ) + } + .pointerInput(seekable, duration) { + if (!seekable) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + scrubStart = latestPosition + seekPosition = positionAt(down.position.x) + lastChapter = chapterIndexAt(seekPosition) + isSeeking = true + latestOnScrubbing(true) + var pointerId = down.id + // A restart of this block (seekability or duration + // changed mid-scrub) cancels the gesture: end the + // scrub without seeking so the chrome comes back. + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + ?: event.changes.firstOrNull()?.also { pointerId = it.id } + ?: break + if (!change.pressed) { + change.consume() + break + } + if (change.positionChange() != Offset.Zero) { + change.consume() + seekPosition = positionAt(change.position.x) + val chapter = chapterIndexAt(seekPosition) + if (chapter != lastChapter) { + lastChapter = chapter + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } } + } finally { + isSeeking = false + latestOnScrubbing(false) } + latestOnSeek(seekPosition) } - // Played. - Box( + }, + contentAlignment = Alignment.CenterStart, + ) { + val markers = listOfNotNull( + intro?.let { it to Color(0xFF22D3EE) }, + recap?.let { it to Color(0xFF8BC34A) }, + credits?.let { it to Color(0xFFFFB74D) }, + preview?.let { it to Color(0xFFBA68C8) }, + ) + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(barHeight + 12.dp), + ) { + drawSeekBar( + barHeightPx = barHeight.toPx(), + playedFraction = playedFraction, + bufferedFraction = bufferedFraction, + chapterFractions = if (hasKnownDuration) { + chapters.map { (it.startSeconds / duration).toFloat() } + } else { + emptyList() + }, + markers = if (hasKnownDuration) { + markers.map { (range, color) -> + Triple((range.start / duration).toFloat(), (range.end / duration).toFloat(), color) + } + } else { + emptyList() + }, + startFraction = if (isSeeking && hasKnownDuration) (scrubStart / duration).toFloat() else null, + showHead = seekable && !isSeeking, + dimmed = !enabled, + ) + } + + if (isSeeking && hasKnownDuration) { + val x = barWidthPx * playedFraction + val half = bubbleWidthPx / 2f + val clampedX = if (barWidthPx > bubbleWidthPx) x.coerceIn(half, barWidthPx - half) else barWidthPx / 2f + val delta = seekPosition - scrubStart + val chapterTitle = chapterTitleAt(chapters, seekPosition) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .wrapContentSize(unbounded = true) + .onSizeChanged { bubbleWidthPx = it.width } + .offset { + IntOffset( + x = (clampedX - half).roundToInt(), + y = -with(density) { 52.dp.toPx() }.roundToInt(), + ) + } + .clip(RoundedCornerShape(14.dp)) + .background(PlayerChrome.Smoke) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(14.dp)) + .padding(horizontal = 14.dp, vertical = 9.dp), + ) { + Text(text = formatClockTime(seekPosition), style = PlayerType.BubbleTime) + Text( + text = listOfNotNull(chapterTitle, formatSeekDelta(delta)).joinToString(" · "), + style = PlayerType.BubbleDetail, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier - .fillMaxWidth(playedFraction) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.primary), + .padding(top = 2.dp) + .widthIn(max = 220.dp), ) } - }, - // Keep a generous invisible touch target around the visual track. - // This makes fine seeking practical on a phone without turning the - // timeline itself into a chunky Material slider. - modifier = Modifier - .fillMaxWidth() - .requiredHeight(48.dp), - ) + } + } + } +} - // Current + remaining is more useful during playback than current + - // total, especially when the controls are separated from the video in - // tabletop posture. - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = formatClockTime(displayPosition.toDouble()), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = Color.White.copy(alpha = 0.86f), - ) - Text( - text = remainingTimeLabel(displayPosition.toDouble(), duration), - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - color = Color.White.copy(alpha = 0.86f), - ) +private fun DrawScope.drawSeekBar( + barHeightPx: Float, + playedFraction: Float, + bufferedFraction: Float, + chapterFractions: List, + markers: List>, + startFraction: Float?, + showHead: Boolean, + dimmed: Boolean, +) { + val width = size.width + val top = (size.height - barHeightPx) / 2f + val radius = CornerRadius(barHeightPx / 2f) + val gap = 2.dp.toPx() + // Chapter boundaries become gaps only while every segment stays long + // enough to read as a segment; a title with dozens of short chapters + // falls back to one continuous bar. + val boundaries = chapterFractions.filter { it > 0.001f && it < 0.999f }.sorted() + val edges = (listOf(0f) + boundaries + listOf(1f)).map { it * width } + val useGaps = boundaries.isNotEmpty() && + edges.zipWithNext().all { (a, b) -> b - a >= barHeightPx * 2f + gap } + val segments = if (useGaps) edges.zipWithNext() else listOf(0f to width) + val alpha = if (dimmed) 0.35f else 1f + + segments.forEachIndexed { index, (rawStart, rawEnd) -> + val start = if (index == 0) rawStart else rawStart + gap / 2f + val end = if (index == segments.lastIndex) rawEnd else rawEnd - gap / 2f + val segmentSize = Size(end - start, barHeightPx) + val segmentTopLeft = Offset(start, top) + fun fill(color: Color, untilX: Float, fromX: Float = start) { + if (untilX <= fromX) return + clipRect(left = fromX, top = 0f, right = untilX.coerceAtMost(end), bottom = size.height) { + drawRoundRect(color = color, topLeft = segmentTopLeft, size = segmentSize, cornerRadius = radius) + } + } + drawRoundRect( + color = Color.White.copy(alpha = 0.22f * alpha), + topLeft = segmentTopLeft, + size = segmentSize, + cornerRadius = radius, + ) + fill(Color.White.copy(alpha = 0.22f * alpha), bufferedFraction * width) + markers.forEach { (from, to, color) -> + fill(color.copy(alpha = 0.45f * alpha), untilX = to * width, fromX = maxOf(start, from * width)) } + fill(PlayerChrome.Paper.copy(alpha = alpha), playedFraction * width) } + + startFraction?.let { fraction -> + val x = fraction * width + val tickWidth = 2.dp.toPx() + drawRoundRect( + color = PlayerChrome.Paper.copy(alpha = 0.55f), + topLeft = Offset(x - tickWidth / 2f, top - 5.dp.toPx()), + size = Size(tickWidth, barHeightPx + 10.dp.toPx()), + cornerRadius = CornerRadius(tickWidth / 2f), + ) + } + + if (showHead) { + val center = Offset(playedFraction * width, size.height / 2f) + drawCircle(color = Color.Black.copy(alpha = 0.35f), radius = 8.dp.toPx(), center = center) + drawCircle(color = PlayerChrome.Paper, radius = 6.5.dp.toPx(), center = center) + } +} + +private fun formatSeekDelta(delta: Double): String? { + if (kotlin.math.abs(delta) < 1.0) return null + val sign = if (delta > 0) "+" else "−" + return sign + formatClockTime(kotlin.math.abs(delta)) } internal fun remainingTimeLabel(position: Double, duration: Double): String = diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt index 7c570171c9..c22b943272 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt @@ -1696,6 +1696,24 @@ fun PlayerScreen( val controller = mediaController val videoGravity by viewModel.videoGravity.collectAsState() var playerViewRef by remember { mutableStateOf(null) } + // Left edge of a docked player menu (root px), null when none is + // open. The subtitle canvas pads its right side by the overlap so + // cues re-center in the picture that is still visible. + var dockedMenuEdgePx by remember { mutableStateOf(null) } + val latestDockedMenuEdgePx by rememberUpdatedState(dockedMenuEdgePx) + LaunchedEffect(playerViewRef, dockedMenuEdgePx) { + playerViewRef?.subtitleView?.let { padSubtitlesClearOfDock(it, dockedMenuEdgePx) } + } + // The subtitle manager resizes and moves the canvas on its own + // (video size, aspect, letterbox), so re-pad after each layout too. + DisposableEffect(playerViewRef) { + val subtitleView = playerViewRef?.subtitleView ?: return@DisposableEffect onDispose { } + val listener = android.view.View.OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + view.post { padSubtitlesClearOfDock(view, latestDockedMenuEdgePx) } + } + subtitleView.addOnLayoutChangeListener(listener) + onDispose { subtitleView.removeOnLayoutChangeListener(listener) } + } val letterboxExpansion by viewModel.letterboxExpansion.collectAsState() // The camera only reaches the picture once expansion pushes it out // to the edges — at FIT's 2560px the pillarbox already swallows it. @@ -1926,10 +1944,11 @@ fun PlayerScreen( } }, showBufferingIndicator = activeTabletopPaneLayout == null, - castSlot = { + castSlot = { discModifier -> // No Cast in a party (D7). if (!inRoom) { SiloCastButton( + modifier = discModifier, castManager = castManager, onStartCast = { castScope.launch { @@ -1967,6 +1986,18 @@ fun PlayerScreen( onSelectSubtitle = { viewModel.onSelectSubtitle(it) }, onSelectAudio = { viewModel.onSelectAudio(it) }, onSelectVersion = { viewModel.onSelectVersion(it) }, + pictureInPictureAvailable = activity?.let { + pictureInPictureCoordinator.canOfferPictureInPicture(it, SiloPictureInPictureSurface.Mobile) + } == true, + onEnterPictureInPicture = { + activity?.let { + pictureInPictureCoordinator.enterPictureInPictureOnRequest( + it, + SiloPictureInPictureSurface.Mobile, + ) + } + }, + onDockedMenuEdgeChanged = { edge -> dockedMenuEdgePx = edge }, modifier = playerOverlayModifier, ) } @@ -2126,3 +2157,20 @@ private fun PlaybackExecutionPlan?.validatedPassthroughCodecs(): List { ?.let { listOf(it) } .orEmpty() } + +/** + * Pads the subtitle canvas's right side by its overlap with a docked player + * menu whose left edge is [dockedMenuEdgePx] (root px), so cues re-center in + * the picture that is still visible. Null clears the padding. + */ +private fun padSubtitlesClearOfDock(subtitleView: android.view.View, dockedMenuEdgePx: Float?) { + val location = IntArray(2) + subtitleView.getLocationInWindow(location) + val viewRight = location[0] + subtitleView.width + val overlap = dockedMenuEdgePx + ?.let { edge -> (viewRight - edge).toInt().coerceIn(0, subtitleView.width / 2) } + ?: 0 + if (subtitleView.paddingRight != overlap) { + subtitleView.setPadding(subtitleView.paddingLeft, subtitleView.paddingTop, overlap, subtitleView.paddingBottom) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt index 8a5dbd930b..52eb519adf 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSettingsSheet.kt @@ -1,70 +1,31 @@ package org.siloserver.silo.android.ui.screens.player -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable 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.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource import org.siloserver.silo.android.R import org.siloserver.silo.common.player.PlayerStatsSnapshot -import org.siloserver.silo.domain.player.IntroSkipMode -import org.siloserver.silo.common.settings.LetterboxExpansion import org.siloserver.silo.common.player.SleepTimerState +import org.siloserver.silo.common.settings.LetterboxExpansion +import org.siloserver.silo.domain.player.IntroSkipMode /** - * Where the sheet currently is. The gear opens [Root] — a flat list of the - * things worth reading at a glance (quality, version, subtitles, audio) plus - * one door to everything else, the shape Plex uses. Sub-screens replace the sheet - * content in place rather than stacking new sheets, so there is only ever one - * surface over the video. - * - * Back targets are fixed rather than a stack: [PlaybackOptions] returns to - * [Root], every leaf returns to [PlaybackOptions]. + * Where the settings menu currently is. [Root] is one grouped list — Picture, + * Audio & Subtitles, This session, Advanced — with every current value in a + * right-aligned column, so the menu answers "what am I watching with?" without + * opening anything. Value pickers replace the list in place; menus with their + * own surface (quality, version, tracks, style, sleep timer, stats) hand over to it. */ private enum class SettingsRoute { Root, - PlaybackOptions, Speed, PictureSize, FillScreen, @@ -72,17 +33,9 @@ private enum class SettingsRoute { Sync, ; - val parent: SettingsRoute? - get() = when (this) { - Root -> null - PlaybackOptions -> Root - else -> PlaybackOptions - } - val title: String get() = when (this) { Root -> "Settings" - PlaybackOptions -> "Playback Options" Speed -> "Speed" PictureSize -> "Picture size" FillScreen -> "Fill the screen" @@ -92,7 +45,6 @@ private enum class SettingsRoute { } /** Playback settings menu shared by regular phones and tabletop mode. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlayerSettingsSheet( isVisible: Boolean, @@ -118,16 +70,14 @@ fun PlayerSettingsSheet( // Shown for a title with several files, except in a Watch Party, which // plays exactly the room's file. showVersion: Boolean = false, - // Root-list values. The gear reports the same things the toolbar - // buttons open, so the menu answers "what am I watching with?" without - // opening anything. qualityLabel: String = "", onOpenQuality: () -> Unit = {}, versionLabel: String = "", onOpenVersion: () -> Unit = {}, audioLabel: String = "", subtitleLabel: String = "", - onOpenTracks: () -> Unit = {}, + subtitleStyleLabel: String = "", + onOpenTracks: (TracksTab) -> Unit = {}, onOpenSubtitleStyle: () -> Unit = {}, onOpenSleepTimer: () -> Unit = {}, stats: PlayerStatsSnapshot = PlayerStatsSnapshot(), @@ -142,429 +92,225 @@ fun PlayerSettingsSheet( ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() + val controller = rememberPlayerMenuController(onDismiss) var routeOrdinal by rememberSaveable { mutableIntStateOf(SettingsRoute.Root.ordinal) } val route = SettingsRoute.entries[routeOrdinal] - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } - val leaveFor = { next: () -> Unit -> - scope.dismissPlayerSheet(sheetState, onDismiss, next) - } + val toRoot = { routeOrdinal = SettingsRoute.Root.ordinal } + val handOff = { next: () -> Unit -> controller.dismiss(then = next) } - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard), - ) { - PlayerSheetHeader( - title = route.title, - onBack = route.parent?.let { parent -> { routeOrdinal = parent.ordinal } }, - onDismiss = dismissSheet, - ) - PlayerSheetDivider() - - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(top = 2.dp, bottom = 18.dp), - ) { - when (route) { - SettingsRoute.Root -> { + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = route.title, + onClose = { controller.dismiss() }, + onBack = if (route == SettingsRoute.Root) null else toRoot, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + when (route) { + SettingsRoute.Root -> { + PlayerSectionHeader("Picture") + PlayerGroup { if (showQuality) { - ValueRow( - label = "Quality", - value = qualityLabel, - onClick = { leaveFor(onOpenQuality) }, - ) + PlayerValueRow("Quality", qualityLabel, onClick = { handOff(onOpenQuality) }) } if (showVersion) { - ValueRow( + PlayerValueRow( label = "Version", value = versionLabel, - onClick = { leaveFor(onOpenVersion) }, + onClick = { handOff(onOpenVersion) }, + separator = showQuality, ) } - ValueRow( - label = "Subtitles", - value = subtitleLabel, - onClick = { leaveFor(onOpenTracks) }, - ) - ValueRow( - label = "Audio", - value = audioLabel, - onClick = { leaveFor(onOpenTracks) }, - ) - ValueRow( - label = "Playback Options", - value = null, - onClick = { routeOrdinal = SettingsRoute.PlaybackOptions.ordinal }, - ) - } - - SettingsRoute.PlaybackOptions -> { if (showPlaybackSpeed) { - ValueRow( + PlayerValueRow( label = "Speed", - value = "${formatPlaybackSpeed(playbackSpeed)}×", + value = speedLabel(playbackSpeed), onClick = { routeOrdinal = SettingsRoute.Speed.ordinal }, + separator = showQuality || showVersion, ) } - ValueRow( + PlayerValueRow( label = "Picture size", value = pictureSizeLabel(videoGravity), onClick = { routeOrdinal = SettingsRoute.PictureSize.ordinal }, + separator = showQuality || showVersion || showPlaybackSpeed, ) - ValueRow( + PlayerValueRow( label = "Fill the screen", value = letterboxExpansionLabel(letterboxExpansion), onClick = { routeOrdinal = SettingsRoute.FillScreen.ordinal }, + separator = true, ) - ValueRow( + } + + PlayerSectionHeader("Audio & subtitles") + PlayerGroup { + PlayerValueRow("Subtitles", subtitleLabel, onClick = { handOff { onOpenTracks(TracksTab.Subtitles) } }) + PlayerValueRow( + label = "Audio", + value = audioLabel, + onClick = { handOff { onOpenTracks(TracksTab.Audio) } }, + separator = true, + ) + PlayerValueRow( + label = "Subtitle style", + value = subtitleStyleLabel, + onClick = { handOff(onOpenSubtitleStyle) }, + separator = true, + ) + PlayerValueRow( + label = "Sync", + value = "${formatDelayMs(audioDelayMs)} · ${formatDelayMs(subtitleDelayMs)}", + onClick = { routeOrdinal = SettingsRoute.Sync.ordinal }, + separator = true, + ) + } + + PlayerSectionHeader("This session") + PlayerGroup { + PlayerValueRow( label = stringResource(R.string.settings_intro_skip_title), value = introSkipLabel(introSkipMode), onClick = { routeOrdinal = SettingsRoute.SkipIntros.ordinal }, ) - SwitchRow( - label = "Auto-play next episode", + PlayerSwitchRow( + label = "Auto-play next", checked = autoPlayNextEnabled, onCheckedChange = onSetAutoPlayNext, + separator = true, ) - ValueRow( - label = "Audio & subtitle sync", - value = "${formatDelayMs(audioDelayMs)} · ${formatDelayMs(subtitleDelayMs)}", - onClick = { routeOrdinal = SettingsRoute.Sync.ordinal }, - ) - ValueRow( - label = "Subtitle style", - value = null, - onClick = { leaveFor(onOpenSubtitleStyle) }, - ) - ValueRow( + PlayerValueRow( label = "Sleep timer", value = formatSleepTimerSubtitle(sleepTimerState), - onClick = { leaveFor(onOpenSleepTimer) }, + onClick = { handOff(onOpenSleepTimer) }, + separator = true, ) - SwitchRow( - label = "HDR", - checked = hdrEnabled, - onCheckedChange = onSetHdrEnabled, - ) - SwitchRow( + } + + PlayerSectionHeader("Advanced") + PlayerGroup { + PlayerSwitchRow(label = "HDR", checked = hdrEnabled, onCheckedChange = onSetHdrEnabled) + PlayerSwitchRow( label = "Dolby Vision", checked = dolbyVisionEnabled, onCheckedChange = onSetDolbyVisionEnabled, + separator = true, ) - ValueRow( + PlayerValueRow( label = "Playback stats", value = stats.summaryLabel(), - onClick = { leaveFor(onOpenPlaybackStats) }, + onClick = { handOff(onOpenPlaybackStats) }, + separator = true, ) } + } - SettingsRoute.Speed -> if (showPlaybackSpeed) { - listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0).forEach { value -> - CheckRow( - label = "${formatPlaybackSpeed(value)}×", - isSelected = isSameSpeed(value, playbackSpeed), - onClick = { onSetPlaybackSpeed(value) }, - ) - } - } + SettingsRoute.Speed -> if (showPlaybackSpeed) { + ChoiceGroup( + options = SPEEDS, + isSelected = { isSameSpeed(it, playbackSpeed) }, + label = { speedLabel(it) }, + detail = { if (it == 1.0) "Normal" else null }, + onSelect = onSetPlaybackSpeed, + ) + } - SettingsRoute.PictureSize -> { - listOf("fit", "fill", "stretch").forEach { value -> - CheckRow( - label = pictureSizeLabel(value), - isSelected = videoGravity == value, - onClick = { onSetVideoGravity(value) }, - ) - } - } + SettingsRoute.PictureSize -> ChoiceGroup( + options = listOf("fit", "fill", "stretch"), + isSelected = { videoGravity == it }, + label = ::pictureSizeLabel, + onSelect = onSetVideoGravity, + ) - SettingsRoute.FillScreen -> { - listOf( + SettingsRoute.FillScreen -> { + ChoiceGroup( + options = listOf( LetterboxExpansion.ClearOfCamera, LetterboxExpansion.FullWidth, LetterboxExpansion.Off, - ).forEach { value -> - CheckRow( - label = letterboxExpansionLabel(value), - isSelected = letterboxExpansion == value, - onClick = { onSetLetterboxExpansion(value) }, - ) - } - FootnoteText( - "Widescreen films are expanded past the black bars stored in the " + - "file, never into the picture itself. Full width uses the whole " + - "display and lets the camera sit on the image. Video without " + - "stored bars already fits and does not change.", - ) - } + ), + isSelected = { letterboxExpansion == it }, + label = ::letterboxExpansionLabel, + onSelect = onSetLetterboxExpansion, + ) + PlayerFootnote( + "Widescreen films are expanded past the black bars stored in the " + + "file, never into the picture itself. Full width uses the whole " + + "display and lets the camera sit on the image. Video without " + + "stored bars already fits and does not change.", + ) + } - SettingsRoute.SkipIntros -> { - listOf( - IntroSkipMode.NEVER, - IntroSkipMode.ASK, - IntroSkipMode.ALWAYS, - ).forEach { value -> - CheckRow( - label = introSkipLabel(value), - isSelected = introSkipMode == value, - onClick = { onSetIntroSkipMode(value) }, - ) - } - FootnoteText( - "What happens when a detected intro starts: leave it alone, " + - "offer a Skip Intro button, or skip it and offer an undo.", - ) - } + SettingsRoute.SkipIntros -> { + val labels = listOf( + IntroSkipMode.NEVER to stringResource(R.string.settings_intro_skip_never), + IntroSkipMode.ASK to stringResource(R.string.settings_intro_skip_ask), + IntroSkipMode.ALWAYS to stringResource(R.string.settings_intro_skip_always), + ) + ChoiceGroup( + options = labels, + isSelected = { introSkipMode == it.first }, + label = { it.second }, + onSelect = { onSetIntroSkipMode(it.first) }, + ) + PlayerFootnote( + "What happens when a detected intro starts: leave it alone, " + + "offer a Skip Intro button, or skip it and offer an undo.", + ) + } - SettingsRoute.Sync -> { - DelaySpinnerRow( + SettingsRoute.Sync -> { + Spacer(Modifier.height(8.dp)) + PlayerGroup { + PlayerStepperRow( label = "Audio delay", - subtitle = "PCM audio only", - valueMs = audioDelayMs, + detail = if (audioDelayEnabled) "PCM audio only" else "Off for passthrough audio", + value = formatDelayMs(audioDelayMs), enabled = audioDelayEnabled, - stepMs = 50, - minMs = -5000, - maxMs = 5000, - onChange = onSetAudioDelay, + onDecrease = { onSetAudioDelay((audioDelayMs - 50).coerceIn(-5000, 5000)) }, + onIncrease = { onSetAudioDelay((audioDelayMs + 50).coerceIn(-5000, 5000)) }, ) - DelaySpinnerRow( + PlayerStepperRow( label = "Subtitle delay", - subtitle = "Move captions earlier or later", - valueMs = subtitleDelayMs, - stepMs = 50, - minMs = -10000, - maxMs = 10000, - onChange = onSetSubtitleDelay, + detail = "Move captions earlier or later", + value = formatDelayMs(subtitleDelayMs), + onDecrease = { onSetSubtitleDelay((subtitleDelayMs - 50).coerceIn(-10000, 10000)) }, + onIncrease = { onSetSubtitleDelay((subtitleDelayMs + 50).coerceIn(-10000, 10000)) }, + separator = true, ) } } } + Spacer(Modifier.height(10.dp)) } } } -/** Label · value · chevron. The single row shape the whole menu is built from. */ -@Composable -private fun ValueRow( - label: String, - value: String?, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .heightIn(min = 48.dp) - .padding(horizontal = 20.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - // The value carries the slack so every chevron lands on the same right - // edge; an over-long track name truncates instead of shoving the label. - Text( - text = value.orEmpty(), - color = Color.White.copy(alpha = 0.55f), - fontSize = 15.sp, - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = Color.White.copy(alpha = 0.34f), - modifier = Modifier.size(20.dp), - ) - } -} - -@Composable -private fun SwitchRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = checked, - onValueChange = onCheckedChange, - role = Role.Switch, - ) - .heightIn(min = 48.dp) - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Switch( - checked = checked, - onCheckedChange = null, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.Black, - checkedTrackColor = MaterialTheme.colorScheme.primary, - uncheckedThumbColor = Color.White.copy(alpha = 0.72f), - uncheckedTrackColor = Color.White.copy(alpha = 0.15f), - uncheckedBorderColor = Color.Transparent, - ), - ) - } -} - -/** Leaf-screen option: label on the left, a check on the selected one. */ @Composable -private fun CheckRow( - label: String, - isSelected: Boolean, - onClick: () -> Unit, +private fun ChoiceGroup( + options: List, + isSelected: (T) -> Boolean, + label: (T) -> String, + onSelect: (T) -> Unit, + detail: (T) -> String? = { null }, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .heightIn(min = 46.dp) - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + Spacer(Modifier.height(8.dp)) + PlayerGroup { + options.forEachIndexed { index, option -> + PlayerCheckRow( + label = label(option), + detail = detail(option), + selected = isSelected(option), + onClick = { onSelect(option) }, + separator = index > 0, ) } } } -@Composable -private fun FootnoteText(text: String) { - Text( - text = text, - color = Color.White.copy(alpha = 0.5f), - fontSize = 12.sp, - lineHeight = 16.sp, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp), - ) -} - -@Composable -private fun DelaySpinnerRow( - label: String, - subtitle: String, - valueMs: Int, - stepMs: Int, - minMs: Int, - maxMs: Int, - enabled: Boolean = true, - onChange: (Int) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - ) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.48f), - fontSize = 12.sp, - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - SpinnerButton( - label = "−", - enabled = enabled, - onClick = { onChange((valueMs - stepMs).coerceIn(minMs, maxMs)) }, - ) - Text( - text = formatDelayMs(valueMs), - color = if (enabled) Color.White else Color.White.copy(alpha = 0.38f), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - modifier = Modifier.width(96.dp), - ) - SpinnerButton( - label = "+", - enabled = enabled, - onClick = { onChange((valueMs + stepMs).coerceIn(minMs, maxMs)) }, - ) - } - } -} +private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) -@Composable -private fun SpinnerButton( - label: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = if (enabled) 0.10f else 0.04f)) - .clickable(enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = if (enabled) Color.White else Color.White.copy(alpha = 0.30f), - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - ) - } -} +private fun speedLabel(speed: Double): String = "${formatPlaybackSpeed(speed)}×" private fun pictureSizeLabel(value: String): String = when (value) { "fill" -> "Fill" @@ -594,7 +340,7 @@ private fun PlayerStatsSnapshot.summaryLabel(): String { val route = backendRoute ?: backendDisplayName return listOfNotNull(resolution, route, bitrateBps?.let(::formatStatsBitrate)) .take(2) - .joinToString(" - ") + .joinToString(" · ") .ifBlank { "Waiting for player data" } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt index 972295c985..0f32d65a13 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerSheetSupport.kt @@ -116,14 +116,17 @@ internal fun Modifier.playerSheetContent(tabletopPaneHeight: Dp?): Modifier = internal fun playerSheetScrimColor(tabletopPaneHeight: Dp?): Color = if (tabletopPaneHeight == null) Color.Black.copy(alpha = 0.32f) else Color.Transparent -internal val PlayerSheetBackground = Color(0xFF090D12) -internal val PlayerSheetCardColor = Color(0xFF151B24) -internal val PlayerSheetSelectedColor = Color(0xFF10333D) -internal val PlayerSheetDividerColor = Color.White.copy(alpha = 0.08f) +// Opaque equivalents of the docked panel's smoke and its raised washes, for +// sheets: a portrait sheet covers the picture, so there is nothing to show +// through it. +internal val PlayerSheetBackground = Color(0xFF131417) +internal val PlayerSheetCardColor = Color(0xFF1C1D20) +internal val PlayerSheetSelectedColor = Color(0xFF26272B) +internal val PlayerSheetDividerColor = Color.White.copy(alpha = 0.07f) internal fun playerSheetShape(tabletopPaneHeight: Dp?): Shape = if (tabletopPaneHeight == null) { - RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + RoundedCornerShape(topStart = PlayerChrome.PanelRadius, topEnd = PlayerChrome.PanelRadius) } else { RectangleShape } @@ -213,9 +216,9 @@ internal val PlayerSheetFlingGuard = object : NestedScrollConnection { } /** - * Header row for the glass-style sheets. When [onBack] is provided (gear - * submenus), a leading chevron returns to the parent settings sheet — QA: - * submenus previously had no way back. + * Header for a player menu that carries a subtitle line (subtitle search, AI + * subtitles). Same chrome as [PlayerPanelHeader]: back chevron when there is a + * parent menu, close disc on the right. */ @Composable internal fun PlayerSheetHeader( @@ -224,54 +227,20 @@ internal fun PlayerSheetHeader( onDismiss: (() -> Unit)? = null, subtitle: String? = null, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 52.dp) - .padding( - start = if (onBack != null) 4.dp else 20.dp, - end = if (onDismiss != null) 4.dp else 20.dp, - top = 2.dp, - bottom = 2.dp, - ), - ) { - if (onBack != null) { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back to playback settings", - tint = Color.White, - ) - } - } - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.fillMaxWidth()) { + PlayerPanelHeader( + title = title, + onClose = onDismiss ?: {}, + onBack = onBack, + ) + if (subtitle != null) { Text( - text = title, - color = Color.White, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, + text = subtitle, + style = PlayerType.RowDetail, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = if (onBack != null) 52.dp else 18.dp, end = 18.dp), ) - if (subtitle != null) { - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.56f), - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - if (onDismiss != null) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Close menu", - tint = Color.White.copy(alpha = 0.82f), - ) - } } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerTrackLabels.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerTrackLabels.kt new file mode 100644 index 0000000000..9345672416 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerTrackLabels.kt @@ -0,0 +1,114 @@ +package org.siloserver.silo.android.ui.screens.player + +import java.util.Locale +import org.siloserver.silo.model.catalog.AudioTrack +import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.player.subtitleTrackLabelParts +import org.siloserver.silo.player.trackLanguageDisplayName + +/** A track laid out for a picker row: title, chips beside it, detail below. */ +internal data class TrackPresentation( + val title: String, + val chips: List = emptyList(), + val detail: String? = null, +) + +/** + * Audio tracks are titled by language, not by whatever the muxer wrote into + * the track title. Container titles are very often a codec description + * ("ATSC A/52B (AC-3, E-AC-3)"), which says nothing a viewer can choose by; + * the codec is shown in plain words in the detail line instead. A title that + * does describe the track ("Director's commentary") is kept as the first + * detail. + */ +internal fun audioTrackPresentation(track: AudioTrack, index: Int): TrackPresentation { + val language = trackLanguageDisplayName(track.language) + val descriptor = track.title + ?.trim() + ?.takeIf { it.isNotBlank() && !isCodecDescription(it) && !it.equals(language, ignoreCase = true) } + val title = language ?: descriptor ?: "Audio ${index + 1}" + val detail = listOfNotNull( + descriptor.takeIf { language != null }, + audioCodecDisplayName(track.codec), + audioChannelsDisplayName(track.channels), + "Default".takeIf { track.isDefault }, + ).joinToString(" · ").ifBlank { null } + return TrackPresentation(title = title, detail = detail) +} + +internal fun subtitleTrackPresentation( + sub: PlayerSubtitleInfo, + index: Int, + status: String? = null, +): TrackPresentation { + val parts = subtitleTrackLabelParts( + rawLabel = sub.label, + language = sub.language, + codecOrMime = sub.codec, + isForced = sub.forced == true, + ) + val title = parts.languageName ?: parts.descriptor ?: "Subtitle ${index + 1}" + val chips = buildList { + if (parts.aiGenerated) add("AI") + if (parts.forced) add("Forced") + if (parts.sdh) add("SDH") + } + val detail = listOfNotNull( + parts.descriptor.takeIf { parts.languageName != null }, + parts.format, + parts.provider ?: subtitleSourceLabel(sub), + status, + ).distinctBy { it.lowercase(Locale.US) }.joinToString(" · ").ifBlank { null } + return TrackPresentation(title = title, chips = chips, detail = detail) +} + +/** The short value for the controls: "English", "English · SDH". */ +internal fun subtitleTrackShortLabel(sub: PlayerSubtitleInfo?, index: Int): String? { + sub ?: return null + val presentation = subtitleTrackPresentation(sub, index) + return (listOf(presentation.title) + presentation.chips).joinToString(" · ") +} + +private fun subtitleSourceLabel(sub: PlayerSubtitleInfo): String? = + when ((sub.source ?: sub.catalogSource)?.lowercase(Locale.US)) { + "embedded" -> "Embedded" + "external" -> "External" + "downloaded" -> "Downloaded" + "server_artifact" -> "Server" + else -> null + } + +internal fun audioCodecDisplayName(codec: String?): String? { + val clean = codec?.trim()?.lowercase(Locale.US)?.takeIf { it.isNotBlank() } ?: return null + return when { + clean == "eac3" || clean == "e-ac-3" || clean.contains("ec-3") -> "Dolby Digital Plus" + clean == "ac3" || clean == "ac-3" -> "Dolby Digital" + clean.contains("truehd") || clean == "mlp" -> "Dolby TrueHD" + clean.startsWith("dts") -> "DTS" + clean == "aac" || clean.startsWith("mp4a") -> "AAC" + clean == "flac" -> "FLAC" + clean == "opus" -> "Opus" + clean == "vorbis" -> "Vorbis" + clean == "alac" -> "ALAC" + clean.startsWith("pcm") -> "PCM" + else -> clean.uppercase(Locale.US) + } +} + +internal fun audioChannelsDisplayName(channels: Int?): String? = when (channels) { + null, 0 -> null + 1 -> "Mono" + 2 -> "Stereo" + 6 -> "5.1" + 7 -> "6.1" + 8 -> "7.1" + else -> "$channels ch" +} + +private val codecDescription = Regex( + """(?i)\b(atsc|a/52|e-?ac-?3|ac-?3|aac|dts(-hd)?|truehd|flac|pcm|lpcm|opus|mp3|vorbis|alac|dolby|surround|stereo|mono|atmos|\d\.\d|\d+\s?ch)\b""", +) +private val descriptiveWords = Regex("""(?i)\b(commentary|description|descriptive|director|dub|dubbed|original|isolated|score|karaoke)\b""") + +private fun isCodecDescription(title: String): Boolean = + codecDescription.containsMatchIn(title) && !descriptiveWords.containsMatchIn(title) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 65b023e2c3..ac1bdd3f4b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -427,6 +427,8 @@ class PlayerViewModel( val title: String = "", val subtitle: String = "", val seriesTitle: String? = null, + /** Catalog item type ("movie", "episode", …), for the controls' "MOVIE · 2026" line. */ + val contentType: String? = null, /** * Artwork URL used for the Now Playing lock-screen / Bluetooth / * notification surface. Sourced from `WatchDetail.backdropUrl` with @@ -911,6 +913,8 @@ class PlayerViewModel( private var aiJobHandle: Job? = null private var controlsHideJob: Job? = null + // True while the seek bar is dragged; see onScrubbingChanged. + private var scrubbing = false private var introObserverJob: Job? = null private var lifecycleObserverJob: Job? = null private var resolveNextEpisodeJob: Job? = null @@ -1608,6 +1612,7 @@ class PlayerViewModel( error = null, title = watchDetail?.title ?: playbackState.title, seriesTitle = watchDetail?.seriesTitle, + contentType = watchDetail?.type ?: playbackState.contentType, subtitle = watchDetail?.let { detail -> buildSubtitle(detail) } ?: playbackState.subtitle.orEmpty(), artworkUrl = playbackState.artworkUrl, sessionId = playbackState.sessionId @@ -5316,6 +5321,16 @@ class PlayerViewModel( } } + /** + * Holds the controls on screen while the seek bar is dragged: an auto-hide + * mid-drag would remove the bar and drop the seek. The timer restarts when + * the drag ends. + */ + fun onScrubbingChanged(active: Boolean) { + scrubbing = active + scheduleControlsHide() + } + /** Called when the user exits the player. */ fun onExit() { if (!exitPrepared.compareAndSet(false, true)) return @@ -5388,6 +5403,7 @@ class PlayerViewModel( private fun scheduleControlsHide() { controlsHideJob?.cancel() + if (scrubbing) return controlsHideJob = viewModelScope.launch { delay(CONTROLS_AUTO_HIDE_MS) val state = _uiState.value @@ -5526,6 +5542,7 @@ class PlayerViewModel( error = null, title = title, subtitle = subtitle, + contentType = watchDetail?.type, artworkUrl = artworkUrl, // Playback fields — file:// is read directly by Media3, no // server session needed. diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SleepTimerSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SleepTimerSheet.kt index 368b21e24f..6759370b3a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SleepTimerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SleepTimerSheet.kt @@ -1,47 +1,22 @@ package org.siloserver.silo.android.ui.screens.player -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row 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.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.TimerOff import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -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.input.nestedscroll.nestedScroll -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 org.siloserver.silo.common.player.SleepTimerState /** - * Glass-style bottom sheet for arming the sleep timer. Mirrors iOS - * `SleepTimerSheet` semantics: - * - preset rows (15/30/45/60/90) call [onStart] and dismiss the sheet, - * - "Cancel Timer" appears when the timer is currently [SleepTimerState.Active], - * - the row matching [defaultMinutes] is highlighted (white-on-black) so the - * user's last choice is the obvious next pick. - * - * Layout matches `PlayerSettingsSheet` and `SubtitleStyleSheet` for visual parity. + * Arms the sleep timer. Mirrors iOS `SleepTimerSheet` semantics: + * - preset rows (15/30/45/60/90) call [onStart] and close the menu, + * - "Cancel timer" appears while the timer is [SleepTimerState.Active], + * - the preset matching [defaultMinutes] carries the check, so the user's + * last choice is the obvious next pick. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SleepTimerSheet( isVisible: Boolean, @@ -50,92 +25,54 @@ fun SleepTimerSheet( onStart: (Int) -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, - // Gear-submenu back affordance: dismisses this sheet and reopens the - // parent settings sheet (wired in PlayerOverlay). + // Back affordance: hands over to the settings menu (wired in PlayerOverlay). onBack: (() -> Unit)? = null, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } + val controller = rememberPlayerMenuController(onDismiss) - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - // Keep the sheet handle below the top screen edge — see - // PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) { - PlayerSheetHeader( - title = "Sleep Timer", - onBack = onBack?.let { back -> - { scope.dismissPlayerSheet(sheetState, back) } - }, - onDismiss = dismissSheet, - ) - - if (activeState is SleepTimerState.Active) { - Text( - text = "Pausing in ${formatRemainingDetailed(activeState.remainingSeconds)}", - color = Color.White.copy(alpha = 0.7f), - fontSize = 13.sp, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 12.dp), - ) - } else { - Spacer(modifier = Modifier.height(12.dp)) - } - - PRESETS.forEach { preset -> - PresetRow( + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = "Sleep timer", + onClose = { controller.dismiss() }, + onBack = onBack?.let { back -> { controller.dismiss(then = back) } }, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + if (activeState is SleepTimerState.Active) { + PlayerFootnote("Pausing in ${formatRemainingDetailed(activeState.remainingSeconds)}") + } + Spacer(Modifier.height(8.dp)) + PlayerGroup { + PRESETS.forEachIndexed { index, preset -> + PlayerCheckRow( label = preset.label, - isSelected = preset.minutes == defaultMinutes, + selected = preset.minutes == defaultMinutes, onClick = { onStart(preset.minutes) - dismissSheet() + controller.dismiss() }, + separator = index > 0, ) } - - if (activeState is SleepTimerState.Active) { - Spacer(modifier = Modifier.height(8.dp)) - PresetRow( - label = "Cancel Timer", - isSelected = false, - isDestructive = true, + } + if (activeState is SleepTimerState.Active) { + Spacer(Modifier.height(12.dp)) + PlayerGroup { + PlayerActionRow( + icon = Icons.Rounded.TimerOff, + label = "Cancel timer", + tint = PlayerChrome.Stop, onClick = { onCancel() - dismissSheet() + controller.dismiss() }, ) } - - Spacer(modifier = Modifier.height(24.dp)) } + Spacer(Modifier.height(12.dp)) } } } @@ -150,54 +87,6 @@ private val PRESETS = listOf( Preset(90, "1 hour 30 minutes"), ) -@Composable -private fun PresetRow( - label: String, - isSelected: Boolean, - isDestructive: Boolean = false, - onClick: () -> Unit, -) { - val shape = RoundedCornerShape(12.dp) - val rowModifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onClick) - .height(60.dp) - val styledModifier = when { - isSelected -> rowModifier.background(color = Color.White, shape = shape) - isDestructive -> rowModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color(0xFFEF4444), shape = shape) - else -> rowModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color.White.copy(alpha = 0.6f), shape = shape) - } - Row( - modifier = styledModifier.padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - color = when { - isSelected -> Color.Black - isDestructive -> Color(0xFFEF4444) - else -> Color.White - }, - fontSize = 16.sp, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, - modifier = Modifier.weight(1f), - ) - if (isSelected) { - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Default", - color = Color.Black.copy(alpha = 0.5f), - fontSize = 12.sp, - ) - } - } -} - /** * Compact remaining-time format for chips: "3m", "45s", "1m 12s". */ @@ -213,7 +102,7 @@ internal fun formatRemaining(seconds: Int): String { } /** - * Verbose form used for the "Pausing in …" subtitle when the sheet is open. + * Verbose form used for the "Pausing in …" line while the menu is open. */ private fun formatRemainingDetailed(seconds: Int): String { val safe = seconds.coerceAtLeast(0) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt index ffb7af72af..1e20a7c04e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleSearchSheet.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.player +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,9 +20,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -89,35 +90,18 @@ fun SubtitleSearchSheet( // On successful download return to the tracks sheet (via onBack) so the // freshly added track is visible, rather than dropping onto bare player // controls. One-shot flag in VM. + val controller = rememberPlayerMenuController(onDismiss) + val back = onBack?.let { parent -> { controller.dismiss(then = parent) } } + LaunchedEffect(tools.downloadCompleted) { - if (tools.downloadCompleted) (onBack ?: onDismiss)() + if (tools.downloadCompleted) (back ?: { controller.dismiss() })() } - PlayerModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), - ) { + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { PlayerSheetHeader( - title = "Search Subtitles", - onBack = onBack, - onDismiss = onDismiss, + title = "Find subtitles", + onBack = back, + onDismiss = { controller.dismiss() }, ) Row( @@ -131,7 +115,7 @@ fun SubtitleSearchSheet( Row( modifier = Modifier .fillMaxWidth() - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) + .background(PlayerChrome.Raised, RoundedCornerShape(12.dp)) .clickable { languageMenuExpanded = true } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -147,13 +131,13 @@ fun SubtitleSearchSheet( tint = Color.White.copy(alpha = 0.7f), ) } - DropdownMenu( + SiloDropdownMenu( expanded = languageMenuExpanded, onDismissRequest = { languageMenuExpanded = false }, modifier = Modifier.heightIn(max = 320.dp), ) { LanguageNames.dropdownOptions.forEach { (code, label) -> - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(label) }, onClick = { selectedLanguage = code @@ -166,12 +150,16 @@ fun SubtitleSearchSheet( Button( onClick = { onSearch(selectedLanguage) }, enabled = !tools.searchLoading, + colors = ButtonDefaults.buttonColors( + containerColor = PlayerChrome.Paper, + contentColor = PlayerChrome.Ink, + ), ) { if (tools.searchLoading) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, - color = Color.White, + color = PlayerChrome.Ink, ) } else { Text("Search") @@ -209,13 +197,7 @@ fun SubtitleSearchSheet( LazyColumn( modifier = Modifier .fillMaxWidth() - .then( - if (tabletopPaneHeight == null) { - Modifier.heightIn(max = 420.dp) - } else { - Modifier.weight(1f) - }, - ), + .weight(1f, fill = false), ) { items(tools.searchResults, key = { "${it.provider}:${it.id}" }) { result -> val key = "${result.provider}:${result.id}" @@ -228,8 +210,7 @@ fun SubtitleSearchSheet( } } - Spacer(modifier = Modifier.height(24.dp)) - } + Spacer(modifier = Modifier.height(12.dp)) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt index 53395265d8..2d90f0d5f7 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/SubtitleStyleSheet.kt @@ -1,79 +1,55 @@ package org.siloserver.silo.android.ui.screens.player 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.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row 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.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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 +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType 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.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.model.settings.SubtitleBackgroundStylePreset import org.siloserver.silo.model.settings.SubtitleFontSizePreset import org.siloserver.silo.model.settings.SubtitlePositionPreset /** - * Glass-style bottom sheet for editing the user's [SubtitleAppearance]: - * font size/family/color, background style/color/opacity, optional outline, - * and on-screen position. + * Editor for the user's [SubtitleAppearance]: size, font, colors, background, + * outline and position. Docked beside the picture, the cue on screen is the + * preview, so there is no sample box of its own. * * Each control writes back a transform via [onUpdate] rather than a * precomputed value built from [appearance]: that parameter is a * composable-captured snapshot that can go stale between when a control's * closure is built and when it actually runs (e.g. two opacity fields - * committing independently as the sheet is dismissed), so the caller applies + * committing independently as the menu is dismissed), so the caller applies * the transform against the freshest value it can read instead. The * consuming layer (`PlayerViewModel` / `PlayerSettingsStore`) is responsible * for persistence and propagating to [org.siloserver.silo.common.player.SubtitleManager]. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SubtitleStyleSheet( isVisible: Boolean, @@ -83,388 +59,125 @@ fun SubtitleStyleSheet( // False when the server is known to discard text opacity; the row is // hidden rather than offering a value that will not be kept. showTextOpacity: Boolean = true, - // Gear-submenu back affordance: dismisses this sheet and reopens the - // parent settings sheet (wired in PlayerOverlay). + // Back affordance: hands over to the settings menu (wired in PlayerOverlay). onBack: (() -> Unit)? = null, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } - - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() - } - - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - ) { - PlayerSheetHeader( - title = "Subtitle Style", - onBack = onBack?.let { back -> - { scope.dismissPlayerSheet(sheetState, back) } - }, - onDismiss = dismissSheet, - ) - - // ---- Text section ------------------------------------------------ - SectionHeader("Text") - - FontSizeRow( - selected = appearance.fontSize, - onSelect = { value -> - onUpdate { it.copy(fontSize = value).sanitized() } - }, - ) - - FontFamilyRow( - selected = appearance.fontFamily, - onSelect = { value -> - onUpdate { it.copy(fontFamily = value).sanitized() } - }, - ) - - ColorSwatchRow( - label = "Text Color", - swatches = TEXT_COLOR_SWATCHES, - selectedHex = appearance.fontColor, - onSelect = { hex -> - onUpdate { it.copy(fontColor = hex).sanitized() } - }, - ) - - if (showTextOpacity) { - PercentInputRow( - label = "Text Opacity", - value = appearance.textOpacity, - min = 1, - onChange = { value -> - onUpdate { it.copy(textOpacity = value).sanitized() } - }, - ) - } - - // ---- Background section ----------------------------------------- - SectionHeader("Background") - - BackgroundStyleRow( - selected = appearance.backgroundStyle, - onSelect = { value -> - onUpdate { it.copy(backgroundStyle = value).sanitized() } - }, - ) - - ColorSwatchRow( - label = "Background Color", - swatches = BACKGROUND_COLOR_SWATCHES, - selectedHex = appearance.backgroundColor, - onSelect = { hex -> - onUpdate { it.copy(backgroundColor = hex).sanitized() } - }, - ) - - PercentInputRow( - label = "Background Opacity", - value = appearance.backgroundOpacity, - min = 0, - onChange = { value -> - onUpdate { it.copy(backgroundOpacity = value).sanitized() } - }, - ) - - // ---- Outline section -------------------------------------------- - SectionHeader("Outline") + val controller = rememberPlayerMenuController(onDismiss) - ToggleRow( - label = "Text Outline", - subtitle = null, - checked = appearance.textOutline, - onCheckedChange = { value -> - onUpdate { it.copy(textOutline = value).sanitized() } - }, - ) + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader( + title = "Subtitle style", + onClose = { controller.dismiss() }, + onBack = onBack?.let { back -> { controller.dismiss(then = back) } }, + backDescription = "Back to settings", + ) + PlayerMenuScrollColumn(horizontalPadding = 18.dp) { + val sizeIndex = FONT_SIZES.indexOfFirst { it.first == appearance.fontSize } + PlayerFieldLabel("Size", FONT_SIZES.getOrNull(sizeIndex)?.third) + PlayerChoiceSegments( + options = FONT_SIZES.map { it.second }, + selectedIndex = sizeIndex, + onSelect = { index -> onUpdate { it.copy(fontSize = FONT_SIZES[index].first).sanitized() } }, + ) - if (appearance.textOutline) { - ColorSwatchRow( - label = "Outline Color", - swatches = BACKGROUND_COLOR_SWATCHES, - selectedHex = appearance.textOutlineColor, - onSelect = { hex -> - onUpdate { it.copy(textOutlineColor = hex).sanitized() } - }, - ) - } + val familyIndex = FONT_FAMILIES.indexOfFirst { it.first == appearance.fontFamily } + PlayerFieldLabel("Font", FONT_FAMILIES.getOrNull(familyIndex)?.second) + PlayerChoiceSegments( + options = FONT_FAMILIES.map { it.second }, + selectedIndex = familyIndex, + onSelect = { index -> onUpdate { it.copy(fontFamily = FONT_FAMILIES[index].first).sanitized() } }, + ) - // ---- Position section ------------------------------------------- - SectionHeader("Position") + val textColorIndex = TEXT_COLORS.indexOfFirst { colorsEqual(it.first, appearance.fontColor) } + PlayerFieldLabel("Text color", TEXT_COLORS.getOrNull(textColorIndex)?.second) + PlayerSwatchRow( + colors = TEXT_COLORS.map { hexToComposeColor(it.first) to it.second }, + selectedIndex = textColorIndex, + onSelect = { index -> onUpdate { it.copy(fontColor = TEXT_COLORS[index].first).sanitized() } }, + ) - PositionRow( - selected = appearance.position, - onSelect = { value -> - onUpdate { it.copy(position = value).sanitized() } - }, + if (showTextOpacity) { + PercentField( + label = "Text opacity", + value = appearance.textOpacity, + min = 1, + onChange = { value -> onUpdate { it.copy(textOpacity = value).sanitized() } }, ) - - Spacer(modifier = Modifier.height(24.dp)) } - } - } -} -@Composable -private fun SectionHeader(text: String) { - Text( - text = text.uppercase(), - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 4.dp), - ) -} + val backgroundIndex = BACKGROUND_STYLES.indexOfFirst { it.first == appearance.backgroundStyle } + PlayerFieldLabel("Background", BACKGROUND_STYLES.getOrNull(backgroundIndex)?.second) + PlayerChoiceSegments( + options = BACKGROUND_STYLES.map { it.second }, + selectedIndex = backgroundIndex, + onSelect = { index -> + onUpdate { it.copy(backgroundStyle = BACKGROUND_STYLES[index].first).sanitized() } + }, + ) -@Composable -private fun FontSizeRow( - selected: SubtitleFontSizePreset, - onSelect: (SubtitleFontSizePreset) -> Unit, -) { - val options = listOf( - SubtitleFontSizePreset.Small to "Small", - SubtitleFontSizePreset.Medium to "Medium", - SubtitleFontSizePreset.Large to "Large", - SubtitleFontSizePreset.XLarge to "X-Large", - SubtitleFontSizePreset.XXLarge to "XX-Large", - ) - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { - Text( - text = "Font Size", - color = Color.White, - fontSize = 16.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(options) { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, - ) + val backgroundColorIndex = BACKGROUND_COLORS.indexOfFirst { + colorsEqual(it.first, appearance.backgroundColor) } - } - } -} + PlayerFieldLabel("Background color", BACKGROUND_COLORS.getOrNull(backgroundColorIndex)?.second) + PlayerSwatchRow( + colors = BACKGROUND_COLORS.map { hexToComposeColor(it.first) to it.second }, + selectedIndex = backgroundColorIndex, + onSelect = { index -> + onUpdate { it.copy(backgroundColor = BACKGROUND_COLORS[index].first).sanitized() } + }, + ) -@Composable -private fun FontFamilyRow( - selected: String, - onSelect: (String) -> Unit, -) { - val options = listOf( - SubtitleAppearance.SANS_SERIF to "Sans-serif", - SubtitleAppearance.SERIF to "Serif", - SubtitleAppearance.MONOSPACE to "Monospace", - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Font Family", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, - ) - } - } - } -} + PercentField( + label = "Background opacity", + value = appearance.backgroundOpacity, + min = 0, + onChange = { value -> onUpdate { it.copy(backgroundOpacity = value).sanitized() } }, + ) -@Composable -private fun BackgroundStyleRow( - selected: SubtitleBackgroundStylePreset, - onSelect: (SubtitleBackgroundStylePreset) -> Unit, -) { - val options = listOf( - SubtitleBackgroundStylePreset.Box to "Box", - SubtitleBackgroundStylePreset.Shadow to "Shadow", - SubtitleBackgroundStylePreset.Outline to "Outline", - SubtitleBackgroundStylePreset.None to "None", - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Background Style", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, + Spacer(Modifier.height(14.dp)) + PlayerGroup { + PlayerSwitchRow( + label = "Text outline", + checked = appearance.textOutline, + onCheckedChange = { value -> onUpdate { it.copy(textOutline = value).sanitized() } }, ) } - } - } -} - -@Composable -private fun PositionRow( - selected: SubtitlePositionPreset, - onSelect: (SubtitlePositionPreset) -> Unit, -) { - val options = listOf( - SubtitlePositionPreset.Bottom to "Bottom", - SubtitlePositionPreset.LowerThird to "Lower Third", - SubtitlePositionPreset.Top to "Top", - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Position", - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { (value, label) -> - PillButton( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, + if (appearance.textOutline) { + val outlineIndex = BACKGROUND_COLORS.indexOfFirst { + colorsEqual(it.first, appearance.textOutlineColor) + } + PlayerFieldLabel("Outline color", BACKGROUND_COLORS.getOrNull(outlineIndex)?.second) + PlayerSwatchRow( + colors = BACKGROUND_COLORS.map { hexToComposeColor(it.first) to it.second }, + selectedIndex = outlineIndex, + onSelect = { index -> + onUpdate { it.copy(textOutlineColor = BACKGROUND_COLORS[index].first).sanitized() } + }, ) } - } - } -} - -@Composable -private fun PillButton( - label: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - val shape = RoundedCornerShape(14.dp) - val baseModifier = Modifier.clickable(onClick = onClick) - Box( - modifier = if (isSelected) { - baseModifier.background(color = Color.White, shape = shape) - } else { - baseModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color.White, shape = shape) - }, - ) { - Text( - text = label, - color = if (isSelected) Color.Black else Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) - } -} -@Composable -private fun ColorSwatchRow( - label: String, - swatches: List, - selectedHex: String, - onSelect: (String) -> Unit, -) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { - Text( - text = label, - color = Color.White, - fontSize = 16.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 0.dp), - ) { - items(swatches) { hex -> - ColorSwatch( - hex = hex, - isSelected = colorsEqual(hex, selectedHex), - onClick = { onSelect(hex) }, - ) - } + val positionIndex = POSITIONS.indexOfFirst { it.first == appearance.position } + PlayerFieldLabel("Position", POSITIONS.getOrNull(positionIndex)?.second) + PlayerChoiceSegments( + options = POSITIONS.map { it.second }, + selectedIndex = positionIndex, + onSelect = { index -> onUpdate { it.copy(position = POSITIONS[index].first).sanitized() } }, + ) + Spacer(Modifier.height(16.dp)) } } } -@Composable -private fun ColorSwatch( - hex: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - val color = hexToComposeColor(hex) - val baseModifier = Modifier - .size(24.dp) - .clickable(onClick = onClick) - .background(color = color, shape = CircleShape) - Box( - modifier = if (isSelected) { - baseModifier.border(width = 2.dp, color = Color.White, shape = CircleShape) - } else { - baseModifier.border(width = 1.dp, color = Color.White.copy(alpha = 0.4f), shape = CircleShape) - }, - ) -} - /** - * A typed percentage value (`min`-100), for controls where dragging a slider - * is more fiddly than just typing the number — opacity wants precision at the - * low end where a few percent is the difference between legible and not. + * A typed percentage (`min`-100), for controls where dragging a slider is more + * fiddly than typing the number — opacity wants precision at the low end where + * a few percent is the difference between legible and not. */ @Composable -private fun PercentInputRow( +private fun PercentField( label: String, value: Int, min: Int, @@ -493,7 +206,7 @@ private fun PercentInputRow( text = (clamped ?: value).toString() } - // Dismissing the sheet while the field is still focused (swipe-away, back + // Dismissing the menu while the field is still focused (swipe-away, back // press) tears down this composable without firing onFocusChanged(false), // so a typed-but-uncommitted percentage would otherwise be silently lost. // rememberUpdatedState keeps the lambda pointed at the latest commit @@ -504,23 +217,16 @@ private fun PercentInputRow( } Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), + modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = label, - color = Color.White, - fontSize = 16.sp, - modifier = Modifier.weight(1f), - ) + Text(text = label, style = PlayerType.FieldLabel, modifier = Modifier.weight(1f)) BasicTextField( value = text, onValueChange = { input -> text = input.filter { it.isDigit() }.take(3) }, singleLine = true, - textStyle = TextStyle(color = Color.White, fontSize = 16.sp, textAlign = TextAlign.End), - cursorBrush = SolidColor(Color.White), + textStyle = PlayerType.Value.copy(color = PlayerChrome.Paper, textAlign = TextAlign.End), + cursorBrush = SolidColor(PlayerChrome.Paper), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { @@ -530,78 +236,62 @@ private fun PercentInputRow( }, ), modifier = Modifier - .width(44.dp) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.4f), - shape = RoundedCornerShape(6.dp), - ) - .padding(horizontal = 8.dp, vertical = 6.dp) + .width(56.dp) + .clip(RoundedCornerShape(10.dp)) + .background(PlayerChrome.Raised) + .padding(horizontal = 10.dp, vertical = 8.dp) .onFocusChanged { focusState -> if (!focusState.isFocused) commit() }, ) Spacer(modifier = Modifier.width(6.dp)) - Text(text = "%", color = Color.White, fontSize = 16.sp) + Text(text = "%", style = PlayerType.FieldLabel) } } -@Composable -private fun ToggleRow( - label: String, - subtitle: String?, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = Color.White, - fontSize = 16.sp, - ) - if (subtitle != null) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - ) - } - } - Spacer(modifier = Modifier.width(12.dp)) - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = Color(0xFF06B6D4), - ), - ) - } -} +private val FONT_SIZES = listOf( + Triple(SubtitleFontSizePreset.Small, "S", "Small"), + Triple(SubtitleFontSizePreset.Medium, "M", "Medium"), + Triple(SubtitleFontSizePreset.Large, "L", "Large"), + Triple(SubtitleFontSizePreset.XLarge, "XL", "Extra large"), + Triple(SubtitleFontSizePreset.XXLarge, "XXL", "Largest"), +) + +private val FONT_FAMILIES = listOf( + SubtitleAppearance.SANS_SERIF to "Sans", + SubtitleAppearance.SERIF to "Serif", + SubtitleAppearance.MONOSPACE to "Mono", +) + +private val BACKGROUND_STYLES = listOf( + SubtitleBackgroundStylePreset.None to "None", + SubtitleBackgroundStylePreset.Shadow to "Shadow", + SubtitleBackgroundStylePreset.Box to "Box", + SubtitleBackgroundStylePreset.Outline to "Outline", +) + +private val POSITIONS = listOf( + SubtitlePositionPreset.Bottom to "Bottom", + SubtitlePositionPreset.LowerThird to "Lower third", + SubtitlePositionPreset.Top to "Top", +) -private val TEXT_COLOR_SWATCHES = listOf( - "#ffffff", // White - "#facc15", // Yellow - "#22c55e", // Green - "#06b6d4", // Cyan - "#d946ef", // Magenta - "#ef4444", // Red - "#3b82f6", // Blue - "#9ca3af", // Gray - "#000000", // Black +private val TEXT_COLORS = listOf( + "#ffffff" to "White", + "#facc15" to "Yellow", + "#22c55e" to "Green", + "#06b6d4" to "Cyan", + "#d946ef" to "Magenta", + "#ef4444" to "Red", + "#3b82f6" to "Blue", + "#9ca3af" to "Gray", + "#000000" to "Black", ) -private val BACKGROUND_COLOR_SWATCHES = listOf( - "#000000", // Black - "#374151", // Dark Gray - "#1e3a5f", // Navy - "#7f1d1d", // Dark Red - "#14532d", // Dark Green +private val BACKGROUND_COLORS = listOf( + "#000000" to "Black", + "#374151" to "Dark gray", + "#1e3a5f" to "Navy", + "#7f1d1d" to "Dark red", + "#14532d" to "Dark green", ) /** Compose `Color` from a `#rrggbb` string. Falls back to white on garbage input. */ diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt index 6d9c25d528..525e20ce9b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/TracksSheet.kt @@ -1,57 +1,43 @@ package org.siloserver.silo.android.ui.screens.player -import androidx.compose.foundation.background -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.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.Check -import androidx.compose.material.icons.filled.GraphicEq -import androidx.compose.material.icons.filled.Restore -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Subtitles -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.Translate -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Sync import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.font.FontWeight -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.siloserver.silo.model.catalog.AudioTrack import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.playback.SubtitleTimingActions import org.siloserver.silo.player.formatSubtitleTrackDisplayLabel -/** Combined audio and subtitle picker with adaptive phone and foldable layouts. */ -@OptIn(ExperimentalMaterial3Api::class) +/** Which list the Audio & Subtitles menu opens on. */ +enum class TracksTab { Subtitles, Audio } + +/** + * Audio & Subtitles. A segmented switch picks the list; the subtitle list ends + * in Find more / Translate / Timing. Docked beside the picture (landscape) a + * choice applies while the panel stays open, so the viewer sees the new track + * on screen; as a portrait sheet, which covers the picture, a choice closes it. + */ @Composable fun TracksSheet( isVisible: Boolean, @@ -70,219 +56,125 @@ fun TracksSheet( timingActions: SubtitleTimingActions? = null, onSyncSubtitle: (String) -> Unit = {}, onResetTiming: (String) -> Unit = {}, + initialTab: TracksTab = TracksTab.Subtitles, tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } - val selectAudioAndDismiss: (Int) -> Unit = { index -> - onSelectAudio(index) - dismissSheet() - } - val selectSubtitleAndDismiss: (Int) -> Unit = { index -> - onSelectSubtitle(index) - dismissSheet() - } - val openSubtitleSearch = { - scope.dismissPlayerSheet(sheetState, onDismiss, onSearchSubtitles) - } - val openAiTranslate = { - scope.dismissPlayerSheet(sheetState, onDismiss, onTranslateWithAi) + val controller = rememberPlayerMenuController(onDismiss) + val staysOpen = controller.presentation == PlayerMenuPresentation.Docked + fun choose(select: () -> Unit) { + select() + if (!staysOpen) controller.dismiss() } - - LaunchedEffect(isVisible) { - if (isVisible) sheetState.show() + var tabOrdinal by rememberSaveable { + mutableIntStateOf(if (audioTracks.isEmpty()) TracksTab.Subtitles.ordinal else initialTab.ordinal) } + var showTiming by rememberSaveable { mutableStateOf(false) } + val tab = TracksTab.entries[tabOrdinal] + + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + if (showTiming && timingActions != null) { + PlayerPanelHeader( + title = "Timing", + onClose = { controller.dismiss() }, + onBack = { showTiming = false }, + backDescription = "Back to Audio & Subtitles", + ) + PlayerMenuScrollColumn(horizontalPadding = 12.dp) { + Spacer(Modifier.height(8.dp)) + SubtitleTimingGroup( + actions = timingActions, + onSync = { onSyncSubtitle(timingActions.key) }, + onReset = { onResetTiming(timingActions.key) }, + ) + } + return@PlayerMenu + } - PlayerModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard), - ) { - PlayerSheetHeader( - title = "Audio & Subtitles", - subtitle = "Choose language and accessibility tracks", - onDismiss = dismissSheet, + PlayerPanelHeader(title = "Audio & Subtitles", onClose = { controller.dismiss() }) + if (audioTracks.isNotEmpty()) { + PlayerSegmentedControl( + segments = listOf( + PlayerSegment("Subtitles", subtitles.size), + PlayerSegment("Audio", audioTracks.size), + ), + selectedIndex = tab.ordinal, + onSelect = { tabOrdinal = it }, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 4.dp, bottom = 8.dp), ) + } - if (tabletopPaneHeight != null && audioTracks.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - AudioTrackCard( - audioTracks = audioTracks, - selectedAudioIndex = selectedAudioIndex, - onSelect = selectAudioAndDismiss, - scrollContent = true, - modifier = Modifier - .weight(0.44f) - .fillMaxHeight(), - ) - SubtitleTrackCard( - subtitles = subtitles, - selectedSubtitleIndex = selectedSubtitleIndex, - onSelect = selectSubtitleAndDismiss, - showSearchAction = showSearchAction, - showTranslateAction = showTranslateAction, - onSearchSubtitles = openSubtitleSearch, - onTranslateWithAi = openAiTranslate, - subtitleStatus = subtitleStatus, - timingActions = timingActions, - onSyncSubtitle = onSyncSubtitle, - onResetTiming = onResetTiming, - scrollContent = true, - modifier = Modifier - .weight(0.56f) - .fillMaxHeight(), + when (tab) { + TracksTab.Subtitles -> { + PlayerMenuScrollColumn { + PlayerTrackRow( + title = "Off", + selected = selectedSubtitleIndex == -1, + onClick = { choose { onSelectSubtitle(-1) } }, ) - } - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding( - start = playerSheetHorizontalPadding(tabletopPaneHeight), - end = playerSheetHorizontalPadding(tabletopPaneHeight), - top = 8.dp, - bottom = 24.dp, - ), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (audioTracks.isNotEmpty()) { - AudioTrackCard( - audioTracks = audioTracks, - selectedAudioIndex = selectedAudioIndex, - onSelect = selectAudioAndDismiss, - scrollContent = false, - modifier = Modifier.fillMaxWidth(), + subtitles.forEachIndexed { index, subtitle -> + val presentation = subtitleTrackPresentation(subtitle, index, subtitleStatus(subtitle)) + PlayerTrackRow( + title = presentation.title, + chips = presentation.chips, + detail = presentation.detail, + selected = index == selectedSubtitleIndex, + onClick = { choose { onSelectSubtitle(index) } }, ) } - SubtitleTrackCard( - subtitles = subtitles, - selectedSubtitleIndex = selectedSubtitleIndex, - onSelect = selectSubtitleAndDismiss, - showSearchAction = showSearchAction, - showTranslateAction = showTranslateAction, - onSearchSubtitles = openSubtitleSearch, - onTranslateWithAi = openAiTranslate, - subtitleStatus = subtitleStatus, - timingActions = timingActions, - onSyncSubtitle = onSyncSubtitle, - onResetTiming = onResetTiming, - scrollContent = false, - modifier = Modifier.fillMaxWidth(), + Spacer(Modifier.height(8.dp)) + } + if (showSearchAction || showTranslateAction || timingActions != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 14.dp, top = 8.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showSearchAction) { + PlayerFooterButton( + icon = Icons.Rounded.Search, + label = "Find more", + onClick = { controller.dismiss(then = onSearchSubtitles) }, + modifier = Modifier.weight(1f), + ) + } + if (showTranslateAction) { + PlayerFooterButton( + icon = Icons.Rounded.AutoAwesome, + label = "Translate", + onClick = { controller.dismiss(then = onTranslateWithAi) }, + modifier = Modifier.weight(1f), + ) + } + if (timingActions != null) { + PlayerFooterButton( + icon = Icons.Rounded.Schedule, + label = "Timing", + onClick = { showTiming = true }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + TracksTab.Audio -> PlayerMenuScrollColumn { + audioTracks.forEachIndexed { index, track -> + val presentation = audioTrackPresentation(track, index) + PlayerTrackRow( + title = presentation.title, + detail = presentation.detail, + selected = index == selectedAudioIndex, + onClick = { choose { onSelectAudio(index) } }, ) } + Spacer(Modifier.height(8.dp)) } } } } -@Composable -private fun AudioTrackCard( - audioTracks: List, - selectedAudioIndex: Int, - onSelect: (Int) -> Unit, - scrollContent: Boolean, - modifier: Modifier = Modifier, -) { - TrackSectionCard( - title = "Audio", - detail = trackCountLabel(audioTracks.size), - icon = Icons.Filled.GraphicEq, - scrollContent = scrollContent, - modifier = modifier, - ) { - audioTracks.forEachIndexed { index, track -> - TrackRow( - label = audioTrackName(track, index), - attributes = audioTrackAttributes(track), - isSelected = index == selectedAudioIndex, - onClick = { onSelect(index) }, - ) - } - } -} - -@Composable -private fun SubtitleTrackCard( - subtitles: List, - selectedSubtitleIndex: Int, - onSelect: (Int) -> Unit, - showSearchAction: Boolean, - showTranslateAction: Boolean, - onSearchSubtitles: () -> Unit, - onTranslateWithAi: () -> Unit, - subtitleStatus: (PlayerSubtitleInfo) -> String?, - timingActions: SubtitleTimingActions?, - onSyncSubtitle: (String) -> Unit, - onResetTiming: (String) -> Unit, - scrollContent: Boolean, - modifier: Modifier = Modifier, -) { - TrackSectionCard( - title = "Subtitles", - detail = trackCountLabel(subtitles.size + 1), - icon = Icons.Filled.Subtitles, - scrollContent = scrollContent, - modifier = modifier, - ) { - TrackRow( - label = "Off", - attributes = "No subtitles", - isSelected = selectedSubtitleIndex == -1, - onClick = { onSelect(-1) }, - ) - subtitles.forEachIndexed { index, subtitle -> - TrackRow( - label = subtitleTrackLabel(subtitle, index), - attributes = subtitleStatus(subtitle), - isSelected = index == selectedSubtitleIndex, - onClick = { onSelect(index) }, - ) - } - if (timingActions != null) { - PlayerSheetDivider(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) - SubtitleTimingSection( - actions = timingActions, - onSync = { onSyncSubtitle(timingActions.key) }, - onReset = { onResetTiming(timingActions.key) }, - ) - } - if (showSearchAction || showTranslateAction) { - PlayerSheetDivider(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) - } - if (showSearchAction) { - ActionRow( - icon = Icons.Filled.Search, - label = "Find subtitles", - onClick = onSearchSubtitles, - ) - } - if (showTranslateAction) { - ActionRow( - icon = Icons.Filled.Translate, - label = "Translate with AI", - onClick = onTranslateWithAi, - ) - } - } -} - /** * "Sync to audio" and "Reset timing" for the selected subtitle, stored or a * file next to the media, with a running sync's progress and the last result. @@ -290,47 +182,47 @@ private fun SubtitleTrackCard( * actions give way to a short explanation. */ @Composable -private fun SubtitleTimingSection( +private fun SubtitleTimingGroup( actions: SubtitleTimingActions, onSync: () -> Unit, onReset: () -> Unit, ) { - Text( - text = "TIMING", - color = Color.White.copy(alpha = 0.48f), - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), - ) if (actions.forbidden) { - TimingMessage(SubtitleTimingActions.FORBIDDEN_MESSAGE) + PlayerFootnote(SubtitleTimingActions.FORBIDDEN_MESSAGE) return } - if (actions.canSync) { - ActionRow( - icon = Icons.Filled.Sync, - label = if (actions.inProgress) "Syncing…" else "Sync to audio", - onClick = onSync, - enabled = actions.actionsEnabled, - ) + if (actions.canSync || actions.canReset) { + PlayerGroup { + if (actions.canSync) { + PlayerActionRow( + icon = Icons.Rounded.Sync, + label = if (actions.inProgress) "Syncing…" else "Sync to audio", + onClick = onSync, + enabled = actions.actionsEnabled, + ) + } + if (actions.canReset) { + PlayerActionRow( + icon = Icons.Rounded.Restore, + label = "Reset timing", + onClick = onReset, + enabled = actions.actionsEnabled, + separator = actions.canSync, + ) + } + } } if (actions.inProgress) { SyncProgressRow( percent = actions.percent ?: 0, label = actions.phaseLabel, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), ) } - if (actions.canReset) { - ActionRow( - icon = Icons.Filled.Restore, - label = "Reset timing", - onClick = onReset, - enabled = actions.actionsEnabled, - ) + actions.result?.let { result -> + TimingMessage(result.text, color = if (result.warning) TimingWarningColor else null) } - actions.result?.let { TimingMessage(it.text, color = if (it.warning) TimingWarningColor else null) } - actions.note?.let { TimingMessage(it, color = Color.White.copy(alpha = 0.40f)) } + actions.note?.let { TimingMessage(it, color = PlayerChrome.Faint) } actions.error?.let { TimingMessage(it, color = MaterialTheme.colorScheme.error) } } @@ -340,177 +232,11 @@ private val TimingWarningColor = Color(0xFFFDE68A).copy(alpha = 0.85f) private fun TimingMessage(text: String, color: Color? = null) { Text( text = text, - color = color ?: Color.White.copy(alpha = 0.50f), - fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + style = PlayerType.Footnote.copy(color = color ?: PlayerChrome.Graphite), + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp), ) } -@Composable -private fun TrackSectionCard( - title: String, - detail: String, - icon: ImageVector, - scrollContent: Boolean, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - PlayerSheetCard(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), - modifier = Modifier.size(36.dp), - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = detail, - color = Color.White.copy(alpha = 0.48f), - fontSize = 12.sp, - ) - } - } - PlayerSheetDivider() - Column( - modifier = if (scrollContent) { - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(vertical = 6.dp) - } else { - Modifier.padding(vertical = 6.dp) - }, - content = { content() }, - ) - } -} - -@Composable -private fun TrackRow( - label: String, - isSelected: Boolean, - onClick: () -> Unit, - attributes: String? = null, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) PlayerSheetSelectedColor else Color.Transparent) - .clickable(onClick = onClick) - .heightIn(min = 52.dp) - .padding(horizontal = 12.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = Color.White, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (!attributes.isNullOrBlank()) { - Text( - text = attributes, - color = Color.White.copy(alpha = 0.50f), - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - if (isSelected) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(26.dp), - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = Color.Black, - modifier = Modifier.size(17.dp), - ) - } - } - } - } -} - -@Composable -private fun ActionRow( - icon: ImageVector, - label: String, - onClick: () -> Unit, - enabled: Boolean = true, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable(enabled = enabled, onClick = onClick) - .alpha(if (enabled) 1f else 0.4f) - .heightIn(min = 50.dp) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Text( - text = label, - color = MaterialTheme.colorScheme.primary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - ) - } -} - -private fun trackCountLabel(count: Int): String = if (count == 1) "1 option" else "$count options" - -private fun audioTrackName(track: AudioTrack, index: Int): String = - listOfNotNull( - track.title?.takeIf { it.isNotBlank() }, - track.language?.takeIf { it.isNotBlank() }?.uppercase(), - ).joinToString(" · ").ifBlank { "Audio ${index + 1}" } - -private fun audioTrackAttributes(track: AudioTrack): String = - listOfNotNull( - track.codec?.takeIf { it.isNotBlank() }?.uppercase(), - track.channels?.let { "${it}ch" }, - ).joinToString(" · ") - internal fun subtitleTrackLabel(sub: PlayerSubtitleInfo, index: Int): String = formatSubtitleTrackDisplayLabel( rawLabel = sub.label, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/VersionSelector.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/VersionSelector.kt index 760c2260df..3cc708064d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/VersionSelector.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/VersionSelector.kt @@ -1,39 +1,20 @@ package org.siloserver.silo.android.ui.screens.player -import org.siloserver.silo.model.catalog.editionLabel -import org.siloserver.silo.android.ui.util.formatBytes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row 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.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.siloserver.silo.android.ui.util.formatBytes import org.siloserver.silo.model.catalog.FileVersion +import org.siloserver.silo.model.catalog.editionLabel /** - * Bottom sheet for selecting which file version of the title plays. - * Shows resolution, codec, HDR badge, and file size for each version. - * The server's streaming qualities are a separate menu, [PlaybackQualitySheet]. + * Picks which file version of the title plays: resolution and HDR as the + * title, codecs and size as the detail. The server's streaming qualities are + * a separate menu, [PlaybackQualitySheet]. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun VersionSelector( versions: List, @@ -42,111 +23,32 @@ fun VersionSelector( onDismiss: () -> Unit, tabletopPaneHeight: Dp? = null, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - PlayerModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - tabletopPaneHeight = tabletopPaneHeight, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .playerSheetContent(tabletopPaneHeight) - .nestedScroll(PlayerSheetFlingGuard) - .padding(bottom = 32.dp), - ) { - PlayerSheetHeader( - title = "Version", - subtitle = "Choose which file to play", - onDismiss = onDismiss, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - LazyColumn { - itemsIndexed( - versions, - contentType = { _, _ -> "file-version" }, - ) { index, version -> - val label = buildString { + val controller = rememberPlayerMenuController(onDismiss) + + PlayerMenu(controller = controller, tabletopPaneHeight = tabletopPaneHeight) { + PlayerPanelHeader(title = "Version", onClose = { controller.dismiss() }) + PlayerMenuScrollColumn { + Spacer(Modifier.height(4.dp)) + versions.forEachIndexed { index, version -> + PlayerTrackRow( + title = buildString { version.editionLabel?.let { append(it).append(" · ") } - version.resolution?.let { append(it) } ?: append("Unknown") - if (version.hdr) append(" HDR") - } - - val detail = buildString { - version.codecVideo?.uppercase()?.let { append(it) } - version.codecAudio?.uppercase()?.let { - if (isNotEmpty()) append(" + ") - append(it) - } - if (version.fileSize > 0) { - if (isNotEmpty()) append(" - ") - append(formatBytes(version.fileSize)) - } - }.ifEmpty { null } - - VersionOptionRow( - label = label, - detail = detail, - isSelected = selectedIndex == index, - onClick = { - onSelect(index) - onDismiss() - }, - ) - } - } - } - } -} - -@Composable -private fun VersionOptionRow( - label: String, - detail: String?, - isSelected: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 24.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Spacer(modifier = Modifier.width(24.dp)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - if (detail != null) { - Text( - text = detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + append(version.resolution ?: "Unknown") + }, + chips = listOfNotNull("HDR".takeIf { version.hdr }), + detail = buildList { + version.codecVideo?.uppercase()?.let(::add) + version.codecAudio?.let { audioCodecDisplayName(it) }?.let(::add) + if (version.fileSize > 0) add(formatBytes(version.fileSize)) + }.joinToString(" · ").ifBlank { null }, + selected = selectedIndex == index, + onClick = { + onSelect(index) + controller.dismiss() + }, ) } + Spacer(Modifier.height(8.dp)) } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/CreateProfileScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/CreateProfileScreen.kt index f0b0c49d62..bffa84d84a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/CreateProfileScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/CreateProfileScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.profiles +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,7 +19,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -310,7 +310,7 @@ internal fun DropdownField( onDismissRequest = { expanded = false }, ) { options.forEach { option -> - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(option) }, onClick = { onSelected(option) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt index a13ace5025..499d1def4e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.profiles +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn @@ -37,12 +38,10 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Dns import androidx.compose.material.icons.outlined.ManageAccounts -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -174,29 +173,19 @@ fun ProfileSelectionScreen( state.deleteDialogProfile?.let { profile -> val deletingActive = profile.id == state.activeProfileId - AlertDialog( - onDismissRequest = viewModel::dismissDeleteDialog, - title = { Text("Delete \"${profile.name}\"?") }, - text = { - Text( - if (deletingActive) { - "You're signed in as this profile. Deleting it removes its " + - "watch history and preferences, and you'll pick another " + - "profile to continue." - } else { - "This removes the profile's watch history and preferences. " + - "This can't be undone." - }, - ) - }, - confirmButton = { - TextButton(onClick = viewModel::confirmDeleteProfile) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = viewModel::dismissDeleteDialog) { Text("Cancel") } + SiloConfirmDialog( + title = "Delete \"${profile.name}\"?", + body = if (deletingActive) { + "You're signed in as this profile. Deleting it removes its " + + "watch history and preferences, and you'll pick another " + + "profile to continue." + } else { + "This removes the profile's watch history and preferences. " + + "This can't be undone." }, + confirmLabel = "Delete", + onConfirm = viewModel::confirmDeleteProfile, + onDismiss = viewModel::dismissDeleteDialog, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt index 070b8ce859..6d4317f8b5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/MyRequestsScreen.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.requests +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import org.siloserver.silo.common.requests.rememberRequestRouter import org.siloserver.silo.model.request.MediaRequest import androidx.compose.ui.graphics.vector.ImageVector @@ -20,8 +22,6 @@ import androidx.compose.material.icons.outlined.HowToReg import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material.icons.outlined.Inventory2 import androidx.compose.material.icons.outlined.SyncProblem -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -186,7 +186,7 @@ private fun FilterMenu( tint = Color.White, ) } - DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + SiloDropdownMenu(expanded = open, onDismissRequest = { open = false }) { FilterItem(label = "All Requests", icon = Icons.Outlined.Inventory2, selected = filter == null) { open = false onFilter(null) @@ -208,7 +208,7 @@ private fun FilterMenu( @Composable private fun FilterItem(label: String, icon: ImageVector, selected: Boolean, onClick: () -> Unit) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text(label) }, leadingIcon = { Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt index 9093373f94..46d3111377 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestApprovalsScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.requests +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import android.os.Build import android.view.HapticFeedbackConstants import androidx.compose.foundation.layout.Box @@ -7,9 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CheckCircle -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -68,17 +67,16 @@ fun RequestApprovalsScreen( } pendingDecline?.let { request -> - AlertDialog( - onDismissRequest = { pendingDecline = null }, - title = { Text("Decline this request?") }, - text = { Text("The requester will see it as declined.") }, - confirmButton = { - TextButton(onClick = { - pendingDecline = null - viewModel.perform(AdminRequestAction.Decline, request) - }) { Text("Decline ${request.title}", color = RequestColors.Rose) } + SiloConfirmDialog( + title = "Decline this request?", + body = "The requester will see it as declined.", + confirmLabel = "Decline ${request.title}", + dismissLabel = "Keep", + onConfirm = { + pendingDecline = null + viewModel.perform(AdminRequestAction.Decline, request) }, - dismissButton = { TextButton(onClick = { pendingDecline = null }) { Text("Keep") } }, + onDismiss = { pendingDecline = null }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt index 333ec80065..1fc0a703c2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.requests +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -28,11 +29,9 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -124,17 +123,16 @@ fun RequestDetailScreen( } if (confirmingDecline) { - AlertDialog( - onDismissRequest = { confirmingDecline = false }, - title = { Text("Decline this request?") }, - text = { Text("The person who asked for it will see it as declined.") }, - confirmButton = { - TextButton(onClick = { - confirmingDecline = false - viewModel.moderate(AdminRequestAction.Decline) - }) { Text("Decline", color = RequestColors.Rose) } + SiloConfirmDialog( + title = "Decline this request?", + body = "The person who asked for it will see it as declined.", + confirmLabel = "Decline", + dismissLabel = "Keep", + onConfirm = { + confirmingDecline = false + viewModel.moderate(AdminRequestAction.Decline) }, - dismissButton = { TextButton(onClick = { confirmingDecline = false }) { Text("Keep") } }, + onDismiss = { confirmingDecline = false }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt index e40d1c9f68..9449bbe807 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestRows.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.android.ui.screens.requests +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable @@ -33,8 +35,6 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -201,8 +201,8 @@ internal fun MyRequestRow( else -> Text(text = "›", fontSize = 20.sp, color = SiloSecondaryText.copy(alpha = 0.7f)) } } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( + SiloDropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + SiloDropdownMenuItem( text = { Text("Cancel Request", color = RequestColors.Rose) }, leadingIcon = { Icon(Icons.Filled.Close, contentDescription = null, tint = RequestColors.Rose) }, onClick = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListScreen.kt index d6b1ef6ec8..5d7614cd30 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListScreen.kt @@ -1,5 +1,11 @@ package org.siloserver.silo.android.ui.screens.servers +import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem +import org.siloserver.silo.android.ui.components.SiloDropdownMenu +import org.siloserver.silo.android.ui.components.SiloDialogActionStyle +import org.siloserver.silo.android.ui.components.SiloDialogAction +import org.siloserver.silo.android.ui.components.SiloDialog +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,9 +32,6 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Storage -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -36,7 +39,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -175,25 +177,15 @@ fun ServerListScreen( } removeTarget?.let { target -> - AlertDialog( - onDismissRequest = { removeTarget = null }, - title = { Text("Remove this server?") }, - text = { - Text( - "Sign-in credentials for ${target.displayName} will be " + - "forgotten on this device.", - ) - }, - confirmButton = { - TextButton(onClick = { - viewModel.onRemove(target.id) - removeTarget = null - }) { Text("Remove", color = MaterialTheme.colorScheme.error) } + SiloConfirmDialog( + title = "Remove this server?", + body = "Sign-in credentials for ${target.displayName} will be forgotten on this device.", + confirmLabel = "Remove", + onConfirm = { + viewModel.onRemove(target.id) + removeTarget = null }, - dismissButton = { - TextButton(onClick = { removeTarget = null }) { Text("Cancel") } - }, - containerColor = MaterialTheme.colorScheme.primaryContainer, + onDismiss = { removeTarget = null }, ) } } @@ -276,11 +268,11 @@ private fun ServerRow( modifier = Modifier.size(20.dp), ) } - DropdownMenu( + SiloDropdownMenu( expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, ) { - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Rename") }, onClick = { menuExpanded = false @@ -288,7 +280,7 @@ private fun ServerRow( }, leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, ) - DropdownMenuItem( + SiloDropdownMenuItem( text = { Text("Remove Server") }, onClick = { menuExpanded = false @@ -344,44 +336,26 @@ private fun RenameDialog( ) { var input by remember { mutableStateOf(entry.userOverrideName ?: entry.fetchedName.orEmpty()) } val hasOverride = entry.userOverrideName != null - AlertDialog( + SiloDialog( + title = "Rename server", + message = "Override the server-provided name with a label just for this device.", onDismissRequest = onDismiss, - title = { Text("Rename server") }, - text = { - Column { - Text( - text = "Override the server-provided name with a label just " + - "for this device.", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = input, - onValueChange = { input = it }, - label = { Text("Name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(input) }) { Text("Save") } - }, - dismissButton = { + actions = buildList { + add(SiloDialogAction("Save", { onConfirm(input) }, SiloDialogActionStyle.Primary)) if (hasOverride) { - TextButton(onClick = { onConfirm("") }) { - Text( - "Reset to server-provided name", - color = MaterialTheme.colorScheme.error, - ) - } - } else { - TextButton(onClick = onDismiss) { Text("Cancel") } + add(SiloDialogAction("Reset to server-provided name", { onConfirm("") })) } + add(SiloDialogAction("Cancel", onDismiss)) }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } } @Composable diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt index 4fb02bbc50..5a19792111 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt @@ -456,7 +456,7 @@ fun SettingsSwitchRow( /** Apple's switch: green track while on, a white thumb in both states. */ @Composable -private fun settingsSwitchColors() = SwitchDefaults.colors( +internal fun settingsSwitchColors() = SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = SiloSwitchOn, checkedBorderColor = Color.Transparent, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt index 3285fc2868..ae386291cb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SignInSection.kt @@ -1,14 +1,15 @@ package org.siloserver.silo.android.ui.screens.settings +import org.siloserver.silo.android.ui.components.SiloDialogActionStyle +import org.siloserver.silo.android.ui.components.SiloDialogAction +import org.siloserver.silo.android.ui.components.SiloDialog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -178,39 +179,33 @@ private fun ConfirmPasswordDialog( onDismiss: () -> Unit, ) { var password by remember { mutableStateOf("") } - AlertDialog( + SiloDialog( + title = "Connect $providerName", + message = body, onDismissRequest = onDismiss, - shape = RoundedCornerShape(SettingsDimens.cardRadius), - containerColor = SiloSurfaceContainer, - titleContentColor = SiloForeground, - textContentColor = SiloMutedText, - title = { Text("Connect $providerName") }, - text = { - Column { - Text(body) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - singleLine = true, - isError = error != null, - supportingText = error?.let { { Text(it, color = SiloDestructive) } }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { if (!busy && password.isNotEmpty()) onConfirm(password) }), - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton(enabled = !busy && password.isNotEmpty(), onClick = { onConfirm(password) }) { - Text(if (busy) "Checking…" else confirmLabel) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) + actions = listOf( + SiloDialogAction( + label = if (busy) "Checking…" else confirmLabel, + onClick = { onConfirm(password) }, + style = SiloDialogActionStyle.Primary, + enabled = !busy && password.isNotEmpty(), + ), + SiloDialogAction("Cancel", onDismiss), + ), + ) { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + isError = error != null, + supportingText = error?.let { { Text(it, color = SiloDestructive) } }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (!busy && password.isNotEmpty()) onConfirm(password) }), + modifier = Modifier.fillMaxWidth(), + ) + } } @Composable @@ -225,57 +220,48 @@ private fun ConnectDirectoryDialog( var username by remember { mutableStateOf("") } var directoryPassword by remember { mutableStateOf("") } val complete = !busy && password.isNotEmpty() && username.isNotBlank() && directoryPassword.isNotEmpty() - AlertDialog( + SiloDialog( + title = "Connect $providerName", + message = SignInSettingsViewModel.connectDirectoryPrompt(providerName), onDismissRequest = onDismiss, - shape = RoundedCornerShape(SettingsDimens.cardRadius), - containerColor = SiloSurfaceContainer, - titleContentColor = SiloForeground, - textContentColor = SiloMutedText, - title = { Text("Connect $providerName") }, - text = { - Column { - Text(SignInSettingsViewModel.connectDirectoryPrompt(providerName)) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("This account's password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = username, - onValueChange = { username = it }, - label = { Text("$providerName username") }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = directoryPassword, - onValueChange = { directoryPassword = it }, - label = { Text("$providerName password") }, - singleLine = true, - isError = error != null, - supportingText = error?.let { { Text(it, color = SiloDestructive) } }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { if (complete) onConfirm(password, username, directoryPassword) }), - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - enabled = complete, + actions = listOf( + SiloDialogAction( + label = if (busy) "Checking…" else "Connect", onClick = { onConfirm(password, username, directoryPassword) }, - ) { - Text(if (busy) "Checking…" else "Connect") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) + style = SiloDialogActionStyle.Primary, + enabled = complete, + ), + SiloDialogAction("Cancel", onDismiss), + ), + ) { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("This account's password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("$providerName username") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = directoryPassword, + onValueChange = { directoryPassword = it }, + label = { Text("$providerName password") }, + singleLine = true, + isError = error != null, + supportingText = error?.let { { Text(it, color = SiloDestructive) } }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (complete) onConfirm(password, username, directoryPassword) }), + modifier = Modifier.fillMaxWidth(), + ) + } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt index c59578d9e5..836773a532 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt @@ -1,13 +1,14 @@ package org.siloserver.silo.android.ui.screens.settings.diagnostics -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import org.siloserver.silo.android.ui.components.SiloConfirmDialog +import org.siloserver.silo.android.ui.components.SiloDialog +import org.siloserver.silo.android.ui.components.SiloDialogAction +import org.siloserver.silo.android.ui.components.SiloDialogActionStyle import org.siloserver.silo.common.diagnostics.DiagnosticsPrompt @Composable @@ -21,61 +22,41 @@ fun DiagnosticsPromptDialog( ) { var confirmAlways by remember { mutableStateOf(false) } if (confirmAlways && allowAlwaysSend) { - AlertDialog( - onDismissRequest = { confirmAlways = false }, - title = { Text("Always send crash reports?") }, - text = { Text("Future eligible reports may be uploaded automatically until you change this setting.") }, - confirmButton = { - TextButton(onClick = onAlwaysSend) { Text("Always send") } - }, - dismissButton = { - TextButton(onClick = { confirmAlways = false }) { Text("Cancel") } - }, + SiloConfirmDialog( + title = "Always send crash reports?", + body = "Future eligible reports may be uploaded automatically until you change this setting.", + confirmLabel = "Always send", + destructive = false, + onConfirm = onAlwaysSend, + onDismiss = { confirmAlways = false }, ) return } - AlertDialog( - onDismissRequest = onDontSend, - title = { Text("Silo encountered a problem") }, - text = { - val reportDescription = - if (prompt.reportCount == 1) { - "A ${prompt.reportType.displayName().lowercase()} report is ready. " + - "Review it before deciding whether to send it." - } else { - "${prompt.reportCount} diagnostics reports are ready. " + - "Review them before deciding whether to send them." - } - Text( - if (allowAlwaysSend) { - reportDescription - } else { - "$reportDescription\n\nThe report includes the Silo app version and build, Android version, " + - "device model, crash details, and diagnostic logs. Its pseudonymous credential is not " + - "linked to an account on your self-hosted server. Username, email, profile, server " + - "address, and playback session IDs are omitted. It never sends automatically and may " + - "be retained for up to 30 days." - }, - ) - }, - confirmButton = { - TextButton(onClick = onReview) { Text("Review") } + val reportDescription = + if (prompt.reportCount == 1) { + "A ${prompt.reportType.displayName().lowercase()} report is ready. " + + "Review it before deciding whether to send it." + } else { + "${prompt.reportCount} diagnostics reports are ready. " + + "Review them before deciding whether to send them." + } + SiloDialog( + title = "Silo encountered a problem", + message = if (allowAlwaysSend) { + reportDescription + } else { + "$reportDescription\n\nThe report includes the Silo app version and build, Android version, " + + "device model, crash details, and diagnostic logs. Its pseudonymous credential is not " + + "linked to an account on your self-hosted server. Username, email, profile, server " + + "address, and playback session IDs are omitted. It never sends automatically and may " + + "be retained for up to 30 days." }, - dismissButton = { - ColumnButtons(onSend, if (allowAlwaysSend) ({ confirmAlways = true }) else null, onDontSend) + onDismissRequest = onDontSend, + actions = buildList { + add(SiloDialogAction("Review", onReview, SiloDialogActionStyle.Primary)) + add(SiloDialogAction("Send", onSend)) + if (allowAlwaysSend) add(SiloDialogAction("Always send", { confirmAlways = true })) + add(SiloDialogAction("Don't send", onDontSend)) }, ) } - -@Composable -private fun ColumnButtons( - onSend: () -> Unit, - onAlwaysSend: (() -> Unit)?, - onDontSend: () -> Unit, -) { - TextButton(onClick = onSend) { Text("Send") } - onAlwaysSend?.let { action -> - TextButton(onClick = action) { Text("Always send") } - } - TextButton(onClick = onDontSend) { Text("Don't send") } -} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt index 6843261465..15c7e64696 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.settings.diagnostics +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -13,7 +14,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -22,7 +22,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -195,26 +194,20 @@ fun DiagnosticsReportScreen( } if (confirmDelete && report != null) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text("Delete this report?") }, - text = { - Text( - if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { - "The local evidence will be removed from this device. If this report was already " + - "submitted, its copy in Silo Diagnostics will also be permanently deleted." - } else { - "The local evidence will be permanently removed from this device." - }, - ) + SiloConfirmDialog( + title = "Delete this report?", + body = if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "The local evidence will be removed from this device. If this report was already " + + "submitted, its copy in Silo Diagnostics will also be permanently deleted." + } else { + "The local evidence will be permanently removed from this device." }, - confirmButton = { - TextButton(onClick = { - confirmDelete = false - viewModel.delete(report.id, onBackClick) - }) { Text("Delete") } + confirmLabel = "Delete", + onConfirm = { + confirmDelete = false + viewModel.delete(report.id, onBackClick) }, - dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, + onDismiss = { confirmDelete = false }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt index e75877e7f0..eb82c8a5a3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt @@ -1,18 +1,17 @@ package org.siloserver.silo.android.ui.screens.settings.diagnostics +import org.siloserver.silo.android.ui.components.SiloConfirmDialog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -249,21 +248,16 @@ internal fun DiagnosticsSettingsContent( } if (confirmAlways && state.allowsAutomaticUpload) { - AlertDialog( - onDismissRequest = { confirmAlways = false }, - title = { Text("Always send crash reports?") }, - text = { - Text("Future eligible crash reports may be uploaded automatically. You can inspect pending reports and change this at any time.") - }, - confirmButton = { - TextButton(onClick = { - confirmAlways = false - onConsentChanged(DiagnosticsConsentMode.ALWAYS) - }) { Text("Always send") } - }, - dismissButton = { - TextButton(onClick = { confirmAlways = false }) { Text("Cancel") } + SiloConfirmDialog( + title = "Always send crash reports?", + body = "Future eligible crash reports may be uploaded automatically. You can inspect pending reports and change this at any time.", + confirmLabel = "Always send", + destructive = false, + onConfirm = { + confirmAlways = false + onConsentChanged(DiagnosticsConsentMode.ALWAYS) }, + onDismiss = { confirmAlways = false }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt index 0aa2e43505..9ed215a3ed 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Spacing.kt @@ -167,7 +167,7 @@ object SettingsDimens { */ object MenuDimens { /** Popup corner radius. */ - val cornerRadius = 16.dp + val cornerRadius = 20.dp /** Menu row height. Clears the 48dp touch-target minimum exactly. */ val rowMinHeight = 48.dp @@ -180,7 +180,7 @@ object MenuDimens { * otherwise size down to a sliver; Material's own menus carry a similar * minimum. */ - val minWidth = 184.dp + val minWidth = 220.dp /** Outline separating the popup from whatever artwork sits behind it. */ val borderThickness = SettingsDimens.dividerThickness diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Type.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Type.kt index 4d8ecd8c44..721233a83c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Type.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/theme/Type.kt @@ -2,13 +2,27 @@ package org.siloserver.silo.android.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import org.siloserver.silo.android.R // Mirrors iosApp/iosApp/Theme/Typography.swift — system sans (Roboto on Android, the // closest analog to SF), no custom display font, exact iOS pt sizes mapped to sp. +/** + * Inter, the face the TV app already uses for all of its type. On the phone + * it is reserved for the video player's chrome, where tabular figures keep + * timecodes from jittering and the controls should read the same as on TV. + */ +val SiloInterFamily = FontFamily( + Font(R.font.inter_regular, FontWeight.Normal), + Font(R.font.inter_medium, FontWeight.Medium), + Font(R.font.inter_semibold, FontWeight.SemiBold), + Font(R.font.inter_bold, FontWeight.Bold), +) + val SiloHeroTitle = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Bold, diff --git a/androidApp/src/androidMain/res/font/inter_bold.otf b/androidApp/src/androidMain/res/font/inter_bold.otf new file mode 100644 index 0000000000000000000000000000000000000000..07cd5f081094eca107a3a5def0e4fcc8929d9f61 GIT binary patch literal 631880 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

MHog>Rj2i|;Mpd%ll-U-*vtPWjIJuK0fP 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%0DhYR{lfz9~GxmL{>yqL{}tK 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%0AUro+{;A5*Z3pvJ%)+2iv?

W${=_r7+)#lEVqUEe{plV_d555=CKZ}sgq zaS7j5#Ev7;NI{7|bHQklRbr#q&fxBeFA`|E?}F%YPL>^KfC#Zb&cVM39CSo=SO$=g z#1XF-#9b9h>z?9Q?tCa}DCA<{h@aeKRR1nm-p+>nwO^%*Z>0cIS-s?Y@bM-00*CX% zy@~5L;(Gic{%s-b4{(;v?qns!>p`NTcGjlHIN2@LIEzNf*O`V-yOEaw^nqn94u{@^ zCFUnl4~U9UxQJv6fMF9akw>{>2;kQNBFih+Z3=h761?sqq*cJxNb@Qny-LWJ;D!up zJo1%yALZQ;^-*36Di3&!z7mo3!7MM5%5x0yl;|t3ILd2>^4yi3!4JfNAn5wPvr*=N z2kH%e`WU~0qz-jbumzXnbsA7ohw#%4!F27$Txakq=W>K&joNiSrB{3D{$}5Axe?kc7AxEw@EfZnv#>@fqs;K-G;!E z8ntfIn7WyM&_iBP34#_QD`oz?j0HUE#mi`$mQWy>7*{Ym9GZAO`Cr-(o*;c9<_2!@DZH z>(EhTH(v{Pa`*}NGa_+jOdJt(L?qb6cSaB8&$atJfJMf{)`BsG= z-@E&F7@gLA%EF^tTcBhwiflDZm`$h5WxOqnpxs)qZMX5Z#g@twB=blQZ8_>i@yNn%LnILyx(a&bgl$eolq~=wy)Ps80?~Dz)SWWi* zcSnU2dIYSiMEXB9((W#9FJgDCvdc+!eF<_6+uf7wspahZ=qqlgUbov_!w|962<*u& z>T5McSM=MytE0V%u^PVw7CPlt#TNT?uYqDof+!&=ZDq$V)ns4>U=I5QiEB!slo z1q{Fk#(mcUCSkOMIl?_%6>m;Od1a9GLE+yr6oqfdz zQ}kNAf+yu^xlz?$K*@;saN|>)7Q57vyv+&Z<)PBJ2-=x)mn*kw0B~ zWWn#oh$Z^qSwItA0zCX~q26pW2<|=TxaSO+7o#E>2n6uyUx<~8JjmG~WZO_nm=Pdw z-!D(VW)xkP0*uIpfZ|l>Lhh2wWhF)6>8|1$vahs}H<2m?IXZS>W*cLG6+KdHlWVjv z+QTFC9sX%;9N%Z2*v4^>%gj>S;Q4gPZSb5Yy^Sk7MlD5rFaS9yIMD6d`1kKavDb0d zbpRu_7qrt-nD#r-@B?Phr&f{amFf3%mf9C1_@(iSyM=!%GjC(&Ell4&Z|el5U7@rF z82P<6bMBZe!JgSc3}#w&HUt!wUIxt~#;#ZJSn-!B!Hz*zTiZYF0ShSK% z;zhfdk5H6*VxsG%m@~-eFhaZ#q>TYGAu?CmwxgRrVrtmLJ*F+immKmQ6X0*gLJHYL z04Ja%cawjk07$5rh9$pbzhl!?WH?eE0AC)dS>X8{5=Mx}vPEhub(46JZ4o6li~2h- zZFkA8NwVHH0FiEyFEZK8bxv)-J0zKSi`$b@C@H|UPG*9$4xK1~NgTz*6X(PoOxGnq z`8xzT_C8W&9J1*vG35$ykt|koZ4{E~jgk0j6u<;3z-6*;0wrSt;VL3YRUGGl8HyA@ zlbT?ED4 z(V}i?MSco`-FUXP8wl~PqXMZVo(-4k8%VYQRL-KCY68AcTw*#TNWKj-<*=EuU^S@o zDWW5gutn}iwFku{IbG~TWAY;gW~&G~rWmiaKST%)AZ&0cPS=U*CiFHhIOZ1KcVwy1ubLy&kKfND_i9fO`f~63u) zRIWKPnHTIGhFMMwgY&tQwSbAD4`l8E`TzzUHk^)I7%iCd9^9(TlAgPb#ovMwr1+16 z)!C2na({ZcKfP?WYKEN2-1q5Sq-8zJ+4&l*dIU>7O;=)>juDa5kvxv3nQt6Coy?n| zEn+5$GpSDHnd4v;XpRn*XpWavq_{C_1VDf%m{7HFT$q4(sSkft3DcX49~l36v?w%2 zD9Xm@T@AN(QZ$!=DC3$qe;85&u>IOjTO00tZ6mTl2o@{C#yrGQ{El9oz*0XZw&^K8 z2)YuWGe_c!3)H$s=BM7Jvnpv+Vk|ltlQ(v zvF7hJ$f>pZ;}-eP>@H5w$eH6YGdp!>26mzkbPr@uA$XXZE6KB9QkQC@Rg1kj8OUNS z1M0qS8Fg`j0M37P!7>_1t8!r4sx+6825ud|yaH--<{0Ry&Z({R)t9q&T7B9Tl11*t z1nJw5RZn!$KL;C^RVgXRSa$N{E%D5WJIdHvv1NcT%~K09@h+yDt03biVt*QYRykhg zNBOVwH5KF|7>ike^iXbMmUiumDd3oeIzxu(%~b3hRiT;1W08@iWa|*JnV9NoNG}HUiR)&;2kaBXxXjonP2V$a4|G=fH zydHRPEv4R_(&CxOlgQXa6N(tMUe%2^tLUiLn$Vb#HEWiYIUAHlceV%s7n+=rO7$GFL zq@oFjBS-{6K0~8Q!`Cr8LSu16G3j0k_qTI77}=EM1~h7;7z~8`?Kt$~P1^p>diK@2 z*jHm|U)AgDn3G;Pq5=$><~%b+rjzoSTb9TanlK+|Nn|>nCNjlh%FyificH>8 zO_3>!QavKmyJ;d*x^k%*mMG>GnY`tiBGY`UwGf$J^NLI;$Rjc}_q^#5nY0F`$Q0}C zY9;e!@Y?A(siYH`F0%9IJR(!9S7d@N#*_%n!bl?YE6o&{{7VZEnauJ$A`|v!kI0m+ zMDHkgM5aX~GWAarnK0$YVXQ2XsXmEJbJIkocfBIhT#v|9Ix^BHGFjB}icH>OJt7k% zF%qo;iuH(0pd$&hm@}Ca{Om+;!IsE0pG2lXX(AKi;YmL7h)gKSBQm{4)6GXYkI00V z`_s!FkqNDu0eQ$0nPM%GsV1vlJy2wtZpnmcK`;28oVNcHAa9>N!>IOJgN-MY#Zu-wBzH+!Nf$M|o3Rh3K#W z&kha}SK)1L)7IQkHKy5^7HrrUR2?0e5llZr06T@eu$`C@Vle#C7z&b|aKi{@2E)U4 zSCFMC3UTHbZ4)8*p}JZ)k=zDc9 z&XA7ycSuGfwQr6FU?xjbAyA~<@8=9@4@(tpjHeBH`YcEI^%tU4&|zne4YtG7mJMjjU}s1? z3rk(C#%YjN0eBl{K)%doG*D~uW`_tmD4i|!kn=of*;cD2}oJ^ z3-PuLiMPLDyj(MBgj-o(GJ8ynMGOdNJCnIc!JRrY(-+ZC)inzuWhs8?QoJY$S$GyC zx>8jy$x`u3D(=AGR1GO%O5qfNJF?`ZYRG)2Y!GABe({s1P5m5iL^A%x^y!~ZRa`Kw zeZi+sp4t~r<7&UjllvmWui0Sj6wJ5p`ZxNYs9RfOb%~;J+2Oz<-mrlr!XAXtz>}{*vm0!j>b1X*4xiiOS zHmC9|a^#liBHWeIUyER_ZmwRcQG?#CL66kH!vV!-+64Utyywp2&d1zgdOoIy>CV9Sv@gZC zsuoF2NNbyGps-`XQFVT8bGn|UixGYoUA^hS8uYSX<76up=vu9qXenMtQe+r^ovuf8 zu?1ahK^I%%qDU2P6$lZp;TgISEn6}0PnU}>M$^R>bg>0m{3_xW>zo#q&WO5)z!Vy2 z(eTx;4M?UlqP8faMcZ5#w2BmB55WFnNc;j&uP9ku6qAjh(uqR;99b-0BEOPsgcK*m zfS!W&TnDLr8XPAJ*VU91xPe{f#~Z{~goUeKeW>EKYsNMe1{#IQZwWsP=!DEI-NN3^}?S`H?U;hMXw%t7zsr*#8q@pLBtWnmgXSxW|X_u14js%D;kf%Sw> zCmSp)rMqJ6N*(2SC)#`mgwRXE2zJDxf%cN9q`mZ4dXL`O|L^rqKRyMTk>_x987JNe z1>Dd$C(9+qI?lP)3{~}UdK_S8VE1as#dZv2J&Y_8BYgS3jFR<{cdd9yR`d=)-bAEa z{}Hg>8Y;4ZjGQXB!>PBSqLVG!xS(BP6|cN6ie9^{+`KOGPx%q)hUUuYaGk)J?x!2V zIptbaR6lBlE=0qCF^$lXUhEd42UGr&XAin*KRFT z)sl=bCI{*-GA&6f$lRE2vJZaAUhWs65rKVRy8#u@VEnS{cvp9eT7U6jy zrj3Be(81M#o`f&9)?G9T(w`UOwD%#hWc`LQ-n~rK#%tr5Ho*XNL*Ui6VR54pM+n_moZAfjOiX?ETa)8&$er z!ZucdX;e4z+uQXX{UID}L_|S5e10X`1&IOX1^U@mY!K~i+K&E+Vv7f`Ua5EiNK{dX z!NKMKvG*qMHdXKc_`Th`*o8~BlW#dBGl^uTxF~aIHqWU{Q9_7o2wBF=ArzGo5`}JD znTHPwnMH;ubC+lDv-Y|D-fQoD&e`XjJGgb7`oCWP@8_#?&pG?7^-Swo&wAD~;OE8g zuQ`>)6?6+&9?k92d-91E$n7sHnvYjd>ku1%ONJO#W<7zn02^^SJ7;oHHIuTCLzKT<1ooft|!Jf z-p!99j%xR=BY@HT4F-R_apM{=ETXxqru~3K3NRk!yUgH{$D1^Me4LUJ?TL0hF%Cl@ z6zixt#Bn?@9tIsi)lPEI5~JZ8v)3Yiif?M-r{fjl3!?zO1?DEHTEaxg=Vqs%9NW8N zPsC_<>0Mk}d#xSQIvDMwPY@SrG5TH1dzbev^V!&*1CaVL!v8#7P5b#t`XHA(R$juu z_3wslB}W(waW?02Gk8W_MtjU-bij z7a}nzB_Wo3ZhTr*s9$>Jl^l$Db&uy*!;OZM8XW7=pKH91Ee`bs&fsbYRRU&!jpALVHV$Jx-gt49>+Fhbk@2s^)Nnf&5Rw81rwBL9|$p?F*@3lYq=TCMTuLPH?#0>08w1* zrK3jSd01v1X1t4|Q`kJ+KsPv*&(P^{fZOaCCq4(J18{e5;`)}Xxc&u3SO_b=H_e%g zgHmp$y^HghA^*b#hA#Xz43}fvD26Y~(`nGdOe2Zi;XALmm3yT}zJxr}^agXRGYw*F zyaRCp*N+=hUB+Z>D$^!w)0k&Dp1`pM#ZspsQjMlDR1(G8%JM#9u9%7c$QnVFgnIf<@}~ zl#ychowkIAYuN{&`t7J_1nQ#~qTZaULiVyTcP!|`J=YC91^qo0 z<=u)6we|y`X3QgjOQ~pj3%EOWB9yI^IN)QifbZ} z9zYPM?SXk2`xgz1{wwA$ncZ+LAqvsN1KxCLL$%TH{>W#?vy?S>6~mamjkEFIh34mR z9!1Y4H;QI7EXMOaO$DUcndd${Det!BDrr9*TNz1(Ki_58ACvYxaE3YYc^Unz?x8hu$@bj z-x+dJc`{3PuvKQ$Zy9V83`{6+=&XzJWUiXGi2I%^(I4YK5TD83A})P4*O{J;>$4QD zf8fQR-r2azn$5l0z1g@I)j7Shbo`0u%w#uj5$fo^8zE=WA5$my-7lcHZRQ8!)Y0WF z&5h5sFSuS36PMQTr;eBaf3R$l^d9N7a+hNonvT^ zal_?SW3RV$tmRmLf9LXexN$}3eu&r^Iy-NCtPzgD8E#DB)pcnov>y_Uo3D&nK_C;+ zxW9og1;e6oVU9akKL-UjL3ayF)HB_xyJ(X(C?bfy4Ss8_I4!27TvTNH>Ar@^o@0)Ha5-HFT;EeArJ4VvOds1c-s^^OQO z#yFtkK{}!gC-yeRtCoZ5$Rv>A#$NN)U^+^5n&HN{F7b1rhZ(;NM`Hl)o)YdRk%9Xa zsZ6#?c+&}we$4?d)&}jOA=)#J_3=LK(EHQ(+7}Uld5&Bn_sEmQaIO`ACO~KDC}4mP z0XoFl81bCQCvu8cvE)a|m|-~X_Tj00Fp!@@(uzXW5dvLl6_R0S7iXPl9irDe3uLs* z^|Dc{;Kl%)M|#k5u?F=C10vR=zn&Xhx-!70@wM%=MK45Kv>g$_+nYo3Y6~#cb2GgV zUcETz$@lVDB;+5)73P&+XjiO&ans5%H!D2ihugE5wt7B^2+UO0ogIfUDPY!$4w9sC z$aL~Ur9S{l=BFRf8a)zrR4YgkZHF&PHX+!EOR2?7$SCcU&ml?0>eP`f?(r}FpqWec zn?>JB_$~>JS01OGL4x6=E&?I2d{;&U31g3c`68#FRXRBZQC3dDQ!=MuiZ7=CZ1L-% z7ikLoOaX2Tqb+V$Ju+B4FO{dQ9A$nB&R++^1UPmBD!t92IoyO%8Yw9Xaj1?_L;%FjbVa?+a9P`X|YjlTMKEfN%dX;S_+Po$zE3ZJ0KEzs;D$wltAKK`6k3#TvCkgI(iUYbjV()y)}k*>N9AkHZ~%7jg$My9n41Y zFEMQOgK3;LfhPM+lycB{BVcCVj0mR2V#z_dQD}o1ErgK%fg~qbSM(h=$hF9x>L#S! zcm&^U@xBLZc>=nN;Y~AsrUdR3V0o3PSQLG!0-GXOH-jeXJuGX$T$Mw>y_w+N`&gB~ zAO$$HM{AzR6R|Xz|KR86p7;V~D+EfjufU4xP9HNQN8fiA?bry;N2C~SM2b-%V24!q zkQ5{2sHuM?JfeN(!nN^42B>?D>M=%^<3Z9<`9RW9`BajQs&*0gK2=Tr9?`B32J|VI z{TXqsgM-<{ZDyxLhS|&f%>K-b=B5<}{`d{V`AIv%sP=&iR1&l{IsXDm=3nr0z706v z!O!_>7UxS>tRsyfgeLT`soe#Ub;E9IA!8b44JmCD+dh2zo(yHJc`Wq_;&)5)PU`KgS*n zSn@EkwnWI#GH$xF&2(wFCxi#`VcRUkE~>S`CpeR!)R#ly)}fEwu}A!yy> z91+PJr`2(3u^b7Ty*c3q&(nsP7Gg?Wm{)bt(Cc2a=tpxd;$&MXRI&8bwV!B%Cyvs4 zqBlH;0VNc?Ek?={Ctkt=(~83TKh-qA&tYW^_I6?IMVa;@zNP4(w4`=zscu^;gBudP z3xjTT>IXL)aqRJ<0dvNLTiyGQ6{h+dr*Q}AZFI_ePSN7ro}p#|QfjSFWS+Gr4Lb+5zom{LR9^0>_T1O*SC)Z%b; z702nEcoMPupD1SQ!gz0A)>F9O5S89Q09aot+lMN3C$}OR>3z+Pm7+18=q?KO5movk z8{rLlE@ODm9Of4_S`-k6}yPt z2pxjE`eS`2(*{U(UID{vV83{(@GDPrsiAZ|{_2tXw-oeyn#cq}EQTNTQo03T~~Wv?{s{dDigmEK2s?dY~w5tB7s9pFYuOH8Q}02~Jc z1ZMfY6Om$uu@hjsV|!WZOIpb#cu8Jjw6gQSwu4Cr6z!aT6q>CmG@A@pL@cw{-Q5- zWSlbbPxO^;hyHLo44^*ZOE>_rqfK#%ro03-<%AFgI(8jL)h5`;n(*tez{GKEOvj1~ zB;^Gw!q_de&}UxCI{TYFvK7ofRw^0ZOVA|qq<<8mbrKn$xcbIf0GH`F3Smd8$>g!g zzZl?k_Hgtc_1O-k>03nIe1}kgNPue0gJ{XbFHVz~_a3~?U`VFNz1t_gzA z5HBN|57kb^PA*=Hu;*q)^~woS3BN$S@!19JGj)V3-tC9r+KKz(Gk(t;QTy! z6vFE`#3kX?^9nQI+x5n#^kjP1$u6TjKAef-9v!u!dJePQgU;jm)QnaCtC}W@qM99Rv^g=ylBxfseJE9kq2u@}x4{%n` z`w^p0+OV}-&#pxI71+?|(GbH$MR4`8hlJS$80B(`934$}D`GuoQ^Zu<%QS&QIlxtk z`R6G8umD#LSnuWyQw$}+k9JaP*2rg@SCCva~>y-bH6EyanUotO&}RDs9u zi3Z|6tBLTsKhd0_70|t#|I&C7w*DkkM1jrO0kLmup{|ifIZSNe#(PlL-_X}cpWeik zu{7GHdXq#+6kd@H8*r10SYbxFaK=Ja8c8@1t&F+)5YQHb$X@F*7uV31gEsbOr)a!I z`}N9^(T0_!eXtKf0C%}#e**8Pv6MxS0Wq2BFXMH%Q=#?OT2Mc&B)#KjWiZ7+csND>et;&h|OU&L$-UU>CDaq&=!euwkX?>_R zc&6Z=cwLr79|UexMN(`5daS#GuE~H|H0~v<@F2bc-yxt2?X^$KeYn31sjMZ=q2vwT z{NJsMCP#5{jz(l-emv?b_81;eRPO?=Kk5?s_@gL~mjln{Ja-JLQh~}mUjwQVkLD;> z72gNeMm)`-k3i5R*dgNmYvX&YixXl4ZHU;~=!Mg?hI`dvw2^BFuhdpU-!>H+C!jLH z1kn#QiUeHbVkx~O+S5FsR25$FV|&S^Op}uGT@lzJC}bw?;Z^s(c~cP^Ia?w)bwEV@ zI_X=9pRT{>l4~PM)+T!xv?&AT+-*{_w9HM;g%yYiO5&`YD`;Va8f~X-qb)fiYhyTs zOx8xqjM3JNzqFv!SBh3ppUpn6K1ZM$sFV_)ttLhxcD2`uAvY~~@|ks8`TBvYUJgh` zkN%#aG=|>upV2*6V9Irf2-Y>ELXci=^H__JlJ=sgC{!Ho4@HIui7|}wa=Q1c-3J2j zN?WHL)rCubvUr2ukb9ie^lmo8wsa^0odCw|3yH0r2=!{2lK56Lt%Jr~7_K5m*Wg&?^@O?w#I?C12k{!@guILmJNE8cInAP&Mbb78kU4@{-qB40Js zsQ4VOMpb51#48T8-$*A6j67o#Y#F~rR+mO;a}XV}(l3U3Nto)xVT+ZFh$v2yT7D}> z;oOd#Hi4sGixyN@e8QFI`AZ`@3|6%K+Abz*mWj&-vTE-WFE*2U=20+jAB{!Ccp_Kc zqfstVloyqeBS=f5hyT1qMWMus@$2vf;}h|4ag;IfM?FZ{q8xt(l`3`Hfu+H<*krBLbH4(+k_BKNpzOJ~0*# z1M>-D6bg0n8zS1mvrJJKZL;1>Fd6Ftt?oW>u?VkQpO;YZ z055rJEWk4R7%Q;YhK;1` zQ>fsH)2PB$3(T5~Ewzk{WkGRG(L+2q8U-;3fp?QB?%YlkE4#*zH{{qH%hYdD;Tc85 z)qbiG$B9f`pF{lhzvOHtr_ie?u z=JPL${){kAGd%AfAHT!-vcGGc92~y-Hh9=47oM#&%Cp^$dHO@IpbA4 z0XaZOw2oIFK>iR?Jr^C78)ig&AX`0~T%L(WOUUHc%-fKbF+K|sO7fLfvy_Pj(xL3b zs~C+j~(HWJeq zgJ!WFX-eRN#WX1(a@KpCpYgemfDpG4K9Tbzp`zEOh>%dI+H4n11bo^iICUkQBbXsy z@m|Q!jZM~jgn`TN{qQEyRL_RU5>uOi8qThiv49G>86Ld)pb3ayghH&1C~b?&^Qh-E ziZr0&YRaQ2r~f@*=z?CsMNcEhO9@@ePAJ+e$#pnTe^vV59P!)Bl6-;mAyDKdEIDAqkVOA5C6+?+4^ z%C`Aplp=_Y$mM4B%p!3g)K=>Kfx2pSeMRETs)e#7Tbf1UKJXWb8zm1xsT7H`QG|$@ zViw7qk2f`>>1IW_Gnw>49sdm!76OGVsTF3S5b-_1+s++3Le>HLjCmT#IzVmwb$~9) zIzYaK9O-l*Bb%Pxt~n&j2$2NKQFEv*q2^F_S!Bp8LDWXp9K!ebe_3IqYbgd-6mZh6BmAm6Z5PvM zkG6_BB$fgBhg3QtHB@<$wngHj3IUOJ3_A^Fo`e2b|!0$}@7qSiCQ;HUg3 z*HapqF^8?p7?+(HgVt4!%$SOHW=su4v{za!Gh=pg))|>GGwsY6^mX_lwagel5yYVW z+EzO)2y`$N?(;|s8UQ+shO+%7vdUJB0V!w^?pWyI71T;mHzchP#~Ffw5xu@FkS_$P z0JIU}d7+9Na6c)i;DU(X1{tOdNy4CdQ%K6tH%J-Ei<&q(mRQ+E7Cy!7>>?kJo|4%`c_NO` z3AsZx#2S!Un)&hSNHp>j-jQeoaHS@rqgJ93nwfM)qEST6M(qvB1bG`9@th++MP%-v z%{gtXopS`G9$^JmSP~*@(~m3+Lyh5=?DV4-B8+0n6}f-k*-RuKPyLxlK7;r#J2_=z zgpqdZGbLaDaZ-|%n&M}uFDc1qi!_5RB9oHZOIsu%k}KFe> zl1~I~Ap>MMlF@cXTm&Xc5ireeWGEpJ@pe;{wtDSUB~;##NorxKN*1nhcB+!kK)+|&mgFZ*CV{<+#;_;(6ksbnbBn7fWwHH(PZnpe}P4M zUtr+Qn&c)6r#yCYlW&V#XUEB|i7<048#a?6vzf?q@=Kwa=j7Y5j^TWkJHnWHubVE3 zqfj5|B|P~=&PsUlvBzU4Jl!2(pxyd>NZuXIee%nrKljPD!98uX;)75_8@W%~X_(Q`SHREf0Gpz82sUm8@gYKTltHx0@s+>SIT+XJC3KxTazq(QBs zRXPXpkdYKY;<<5e{z#@XqPnD!5#^JRKt_~L6SJq05fyQ_kQgLIBhuMX0V?>iqwIBp zJ`?`zDD6k^ISq;n@Q^^ zIPdCfdCPR0c~>Y&IvdD06AREh;c28S78>>Gg{riY6dbfB$M?;&xotPDjl1$99O(IWa;I!LH%ql6- zN{XZ|b9*%X%5CRp^%M_|Nk2y`ckTK?Ia>atH8V%cnY3o+Xjw^XeyMllXxWQD@*b*` z{c?v&Ctd3+nXZNCw6xN-WI7fy7ynVZmPrxMG{jpYCrU=`>51sey*9#&-#Z6YYF%F0 z9zrH=;nf#6E7@X26aFO=x9UU~Fx;Garrbsjmq|uHtZvqTj%c0;!)hD{s2OH+nULxW z>};<27I7ml{BC`fz96H-E=xFPAhF99P9)V#^h}B{T@e$ky^`4+FAMaE$nl!+Z;{wY zcE{?W*$x?7Jk){v5sUgw~gKm>>AS|jGkNj(H*-ai}J!Oq08RcA9)2m6M2-3`#mSJC_&6( zQ!#&nm~GqqD`>ezIduHIleuChw#cy@CG#x*rIZ>mNvoDcd1L%&f35CaRd~rWl zKH~GQ^sSA6BdsB?0|&L8K4Cyme!&xIrWdpe$u7t!?ILZ2?CgSa zz(@Thvlw-i7*Pqcfj~6+3vQvjw9;%ZXhl*W!n<`k{Uf5S{t@+M|A@)H{t*bl&<48c z+su-;unEh(R!n$a3Ct8)sa})44pWWJ)}rJj>V=R@O*{hO}3l{gmJXj!5M@^j`D>>o`;w1M_IkS)&W_E&aU46BC59s>h?QABAW+= zM51ngbyS0PQANjMXtJd$SJD8pt+^NuNic6%A(VOPUi#n9wDO^wsNl9uy`?|#R%k%u zi*_0I0w}|tH&BNCj8%sH5tLz{k$M^Sgy1slll^7be|MB&2e4Kd_TeFA*gg0{F>a=U zoazZiPrW7iFl(lgP{Ts7mdyvx4cL$9xt))a=0F}b%Ew)@_#-F5PG4fQ+i1&VT$Ry8 z&&9MV+S^QA#k;$Vx@?dXrEi$P(^{-4tj29qx!p?-LFIT`JA^eal0a9AMUY-yKE8Ptn(nHH z+oECP&@qF@_TWl${?-z{AI<+_$C$XGl5Y#Nq2;2@pQULaC_dC1>rz)`64mKGdNZ2z zjtRzB;wws8ODlf`#s{@{y?{$S$EnoW(_n#uVsnQO*!!UDg2+sM=qLk^68>eWq8Nhm zf)`QSUa-fSqUx7=WDE^v>LIZl^h&v5K7Iw%3a3@N`roK$gQ$CG&X56%0Hgwjd_v!d zZvf&Bzhvlnn0lJigV%mX-CN~bzID_mC^m-{vJ05{Yo94yC-nr8^73N!I*UTGc%J$b zT30jmNAV43tK9m-E_HV%K@;~#&~8v4y$kiodyA5$(aK%4Rqdzk!%|$!(QX%dp%3=C zibb!8p5H2>-Pi1)g)gt%x$=DbtTEH1b2N?A%hB6;sV*WPUYA>eUfYBI?k~%=_n{?u0R4YizRg}w1-OdT7|B=CTR_yJcmmbivs~u4e_k@eO z9lnTe>eNk97of^#wF4*~hbn7zp5mx!TY6uEi(L7fe>8R82g=}5EgFh~FDh|{a~Sax zT~%9{Ljf%RV7E6Ee%QfN({-62`k7x+_@&xEqY-KuUOS$n`AnHZ@u-L9pAEUCm-1p8 zx{7`#+0|FI0RaA6g4?-ZQj0x5+ab}FM~i_x07*;SY5R_hUz zh!p5xD2kk{gQcY;^MgO4L><3ef8Qfq>B;+zczcL4=gQVSRB)rB^&v(6sGR>tCsO%Q zFaG9;K|_=e>>tIIcE`?;Rk8E>W>@>@-Sz7(dKtXJa(@}iU2-bQ&nb0<&8sDnS96ph zC7U-C?iUrKuejS{vEyd|x+sqiW$+wja7iD7QzY)cXyL|9noVBE&g6{ao5AKAqf zvqzEfp$v^MiebwX3CO@Kf4E4@SM2Af7b8;Z^H4eOPLUx;yzVii#@R^{mfP zDzl6zNH4)WK*`HzQa<&y!~K??`aY~3E+7%-Q&22Tc4fofjjLW37luJC+7zwboK9cO zjJXZCw^JF^-Xxr>iU|DY1B{FUrK>5C30R9_l}KcK6r=7bU#AONYH*=9_Z9fj3JyR& zs)f#O9sS?6i=2?UO^^yt;8ET_5t+`m}Leo9#1H6JPf$8hh5Gqd< z^GXmst@hz~+x zG)Z)mwq*r*#Cb^`Q%FFb)Pw{Al2ucM%Huh-tMK(|2Gtju2j?BM?J8O|qo$4OmFH*u zZY-iSTPUZb(LotGFd_A2yz~gaM4&PAKdw~+pLDk2!StiZ*32r3dtb7rv~_KNdq#gP8`yQ5%$Zlups!|_-Vo1V z$s|X6<$Urz+AEu{j`qrLLz+Fo+IKb{^=V&xEmqswccPoHxtb#Dg9KBcp{UtO+D~;+ z0gqMM7>3&sCd>)q{X^!o5QorZb~d^y=W?#VFv9+WvEAAm z!L(|+z+bpWlFA%FLHNzL=yam3J zE^_ZR)md+I`<>exhj3jo%*0$4S&^MoWQ%5**lY%R%0Fa_X8DEPq8ak>nIwWk9IP$Z z@j)B~&uY>cuFP%tw>97>yxl2r1PKumnrGW;c7yAGJVBVGc0m zrwdwB)4R1rQb=}u#~*64F~Oa!R|X{U4=7C#g%;!u44RzUpdLABpH$2Slg^eMfle-p ziyA{Nn@Ah*UF-$^X?)BUTj2J*mdXrbiv$`D27C3X*G&7sTi+%v@Wk=$e%gi+wxo_Y zeqprm4*?I7H3W5a3}LaRK+q!lyRQ4DVQebwQ^QCI2efd^e<0B{)lft5D70(}e541` z9|V>ApY;!d~^M60;IB7 z%HE9oGcW(U?U{R_J@aVUo*8OPFB`g`^!z@S1)!Bw2tc|w2-4p&hV)R@KpMs^N(`(_ zDO|g$A&A-VPTY|sgp!F8ePqJoxYLt|WBxyL_D5}F?>-nD@``VWJmAq_+8LuSfys9V z%dv=}ZU#M<@fk7PN}>OS*r;Dvo6zTx0Q;t!!4Pn4P^s)#e)tO6M?{boFggy2a{()I zK<_k>RS{3$xUmFBz78d6eO*dq(%%HZ&ta152#YnYC4mv@*WXtYyftKCnut^NY zmB(O?=3wyC9fY5Dzrde(P7#lUA?BCc9Q3$&oaUhd;#@R&u6dQ-k+`j(IdHmHNAb?N zq83$I1`$3pHUvZ2Ydi_P#?c}&YrrJviOw!{|15N9JpIQ+dO~)rW9pe%W176(y&dSY zZEOSH{5m@MfrbrnKU1UZ81aNc2vt_MHJq?{;n%Aq5y$T0|0^ah)Qn+THv2^Kj_EH^ z&wPeM$vgI%#%EXsaXVn#+wJQ8#yz|ZxBzxuPTzhXd0C}|;hh-q~FPTPLFb1>ZN7S)5-*s%~GsFQ6;HPQ|l zRZL)1Z-Qw?Nb_0CN|LCaC|c_|A8e6%6O1ZhCfT94tiS-+;HyWy|0}_3jR=8DIMft^ zJ@(6gD?Xr%J3E@{&H}YYp{FOULLU)=FBZWxt7YF4I0)dS6-5NvV?9U{UG$kWdC{f@ zuW^1vrBg`acnm_BM>>Z7Qy~yl-PuM43-cjUj>=hVG9mv3kmvl*L0-|Q5xf9`0GGZ1 zQ?x*KvB^R}_5P=5uL=>0gX$gQ&R#H>AJ^p><{)w$qzhL=;VJ>=ntJVLeCQ~DW%>+a z4>GNdl))+Ja$OsOdO>R&YC~VQ)r2`_Cdbp-N~73!W(^!ThxWJ1YxxdXTRK?QWe2!8 zZs;TW-)6K=!Oh^@#g0Ky76kXYRYuT|OT@tt4*+yh!Ncv&%9rWd?)B%s4 zW$@gR@VuL87Xb}5IZ)mrnR(WAfS0A@GHTk;!8?I18fN!`pbz3GV$>2Yw{qDf-ZFJ5 zdYH{-)D|236q;@7(f-h*swm6UTO!%$nm$b<2sOaD4IVeQ-9RFb&kYnzS9#-QOQCE7 zVPAU9m8%1>OIfu%aBf-cRz7tZKZC~d_6x6Sa$2`vLFbkriL^XMk3wZW3U6UnfhWpI zVKfal+i^~V!`1<2G-oY`gMbk_v;F~7J;}58{tvB05x!s<{!XjkK+kn3jk>_Tu80%& z;;kavu&1w9QUT1rDB94oq{5Jw%JL%R{L2;*{ zj+gf;VzSni2_48Lrc{CQ6`eg;z782p#qkw1=Wj4pH{!PTg7)`jy!Nh~Fc>F?5Lk$pm9l62c9@-(hje3W??cTw6@rW6-ucOX?MBkGa`5_H{cXW;+g6zncg>Y0Vfd z3!P=%Y>#*$5||`#xBo5trjDcz^H?FhzV;QPx*;^NV_2Gr9UHPbu|wP+cHbygJ6Iw2 zE!>=KZHfS!3kMPFfdW+WY=FkGjC*=AlCScLMt&})Sntu!S$?l3Z+A1_{g+eYB3#Cd z^}%agV7ABXTQ$dw$(fzegBjmU#q=|0v;uXk8@EQ#NlksGRsaSw#N~|de3aSQFVJig zVdvrztDUlzm;TA%-lv1jpW5co<>(dhcz;h3dIP=zE8cA{{0y;FZyIg;h@KR65J0@1 z){7OiLOnWGYl@id>YSnuZ2VK14lb=5i}oWZe+{PS?}9NOi=l{V8wJB>A{Pxs%(LP} zg+6V^KHmS+W>Rp7I=X;j5zWof#d39f-e<>L`t?gMfcHqAz9RGfq z#eZ!E_^)x|udYcrMX%f*Uf)EC)t-GB?1Hr&m=?1yfNX4?H$XvHCn{lF9|V!g6xS0A zr_c374Cv>&nwaOdv0j@%I5>6+zC>CVFWQELA{xyFLJ^&ZF()Z(ro)9b&%y4*gfPU1 zBxDlt^%<}`5d#Jg|32!r5l>8bOmf?)MME`RDl7PSI7$+NO!4w$1}Gg5Lg~cqqjWqW zY-KJUNbibv{XKbT6dj|5Vg!O8@}b)5TKZ0WM@jNZd?=iBOA_?a16}Hpc$&ef73yJW z3KC56A`RsQTu>?ESzb}F&W|Z0puS;R%A_szwFEP=MeUwI-^s1E0h7D&l7!V2eiKpD z1d4KLe0h4;B_81w=>bmdaTf0IE9FO$i$WFnNl}Q4mJr)kRmFqiQ9S-t!VA(>7{rpr z=H^o&fP^O)0ra~+3HXmBN%%7X{MsZ7_;pE6;EyEvftQf0$CJ_r9`gp^7Y}>O1>0oJ z-=YM)7OK}_c|;$lt-ngww1SNh=_k)n@zpd8xkl^Rn<7`OIs$v1sKl$%L!8=zQtf}C z2dNS$rSOCG770b6FE${jX^?mpQP(o3=^RoskR&FqfP|L_ex)fUjIMU}~=@lS1YJUFPZ;o7r&Ax}2>@ z3}T@`JVa;l0ypx4-wE8nv08KlBP92KliYt4I3d)amGp$1z}?AXz^NQRjj&pr@40AS zNjv}|E9%!#;(e3-rd4a3(5bN>(1Yc_IDr2pN&+u8F(>@%E%-OwKK#E#IpIH5!`hl7$V$pimqw?eJ zWGc_Yy!gdY)I`yzfaJem(d8BIAR%oHzB(ouKa`8hpx#X|{tZHFX;JkycX~m!j6Ym6 zsnF>wh;vrJnxl_?fXrW`P|D>H_}>D^KG3Ctw?X&q?5duH@T)h)6um(zaEXVtYe9Rm z`X{!ckTBEsX@DxJ?8!0gHQAk4R+)Ki^?;~FXVG8A_@#2F1o?P@c(iuimNqOzm zi#CV^5J}TV$eh2S5Vk{%ANg|ad9JMF%SSF6rEJ7KVYeAo=VOhEE?hh+?%`sD!Yeb; z3QxdV09a5#U8M?&Q@D|h zI*58S8Is+IpZN`{>?8S`&iGfdTz#o%ke*h5_v zuBuQTq_RR?J%5r4I@MKcEV#Cn&`nW}QqJQ`s)4Ek+ZwF7VX;MCY~z*{>F54TXU`4Gp1C$A)EVmhVQre*oRXSJCtKJr zldB8_6a3akXg-sf|g&X5)G~cM_I>C>9l=!LAgdT;I_8Z!FcyF$>;ctG* z*D7jX{^OP(ZhnW`Qj5?$Zgv z6&X~6j)Zy1#bpEQCakyMH`NNcO+R4>#i? zFTQ-*F1-LR6OBRXg+gYx+;T|!eY~lNS6-r8<&F!OO6rjW@cAWi zzey0EbItzRu84Ig~`)FNCAsujsh0nnFTDqbr!JrLDotPGMk(`g`gOSBx-4xSV}%gTZc}+s@J!>*$6I#8+qtC*<(c6@fp+Sy%(-Qag-sN^s#w zS3@QxOM9aAaYRJRBV8qaLKMTn5%$DO)kO(BqlluOl}<+;j?(_LE(CJ5+K5v-~qBFigNKnCAc{ILLu>Ki^ zpo44;|3=V(AA`Ut{*FsSIZ0Qi1r;Qhk&?-B-V(vuVVHAuV0l_T4NyZgqLJ!l$w2i= z5DV+QKqbmtQ`8f5|BORW98kFQ6cZyM-l9F@z1|+&9h&trx(&H!Jv!bDf*!*aj{h5n zMR9f)>Z1&sh_b!SJB#~B5^wd2Ii3#=$;#TNyw{;Cs|0b+TUN!U-)YBXWGkX>(;V8i96?97(yQB~rrfE@P3kR2?Iuys9edhE zV{j%|K|7c@f~~ZBh@Z=k0jks4k={V!OZqGkNc@r>5_bv(CjtfB*$TSMdvG(ddv#_a zy91a>_g#Q9m(oK@HSQEz>Ln*)rNcbcC;%oGSh+w2uz9q7DVXq|fVZTK;&w({1cNJm z{5FYk-ZsI$nJ$^VcZ|$8(TK zs02J`c*WMVFvy6E3kC-|emk@*&Y@Wk%{61XgvNR0fXZ!B3M)A)w{^qDlJ%9_)C)#c zw`W(dKArv!O%gr*?iTEANunq$`uea+iqS@~QdTug(554&sTQYfXLizKiaIc1k{H6& zx1-GX3h8H&?nG|^rml_RqEv;-LWMPzm)AatJwf4mNvralw+%`jV!Qo-9zm%*S#Yp_ zlo;q{!I5v_{BgkOb7!RrXJUK^oI~PGIEN&J!6`3M(}goJ-U;Vm6HbISm?n;0ET>t< zJ~HcgS;a?UX*whc#2(2K7~&C|yoW={yT&3fA`fKvij%xYG9vHcAo3!_ppQ}4hB~cK z_x}QQk=*fyKL&n^jDc^1Ez}tUpBFLk;SunmEQtFrAR|ROBT;D<78xG`xfvR7FGe%c zqdp1&wsS1X#5|$Gp2rgQ!eLUASU4kL&*K#Kw-dCA?(AiQLo^1(hv)f*Q{Pl&!Q#G~ zwym=T{X_`F-7>Lc4x|lyLLxYF~t&P8W*$G)z4a6$(j|1&Y1mo?vQX z!nF@}BceVx4v=p#fr_Ij=gIh62??l3sh*4y5KyV{G6EF~HlXg-M}-0FV7ou4Ub6im zu)009x1;Lkptz!?i|;Vo*@<8eDcJ6kk@n7`U2~b>YHp}j(ra8dli;{+xq&$QjtD!| znjPio)@d`ckIy?d;wF+h?VsU)mOQ^4iB0b?@?L{?Sqlfw+g`$l79zbhvXL!rv z1XS;caEpiosn-cTH)?$x?9kiDDBws_PJcX%rL_Va#ht9h(bZt+Pm-bZ3R|^*>HaT% zRF*!qtlsK+V5WG55mG&bSKGtrvbKf^JXu6M$vV|}wQlz(mF_>icA)b?r35|6#9@qa z-dM%xOTBrJWfy*P?*Kz<3VvlZ2wzDEoQ|{}MlBlVW_E=^v|F@=Ij2$B_06DzLyy8g z+mF*d#P(7nMSI;H`>VBLT4KfYc)0Jw{YonRuMGI3*Coe_sg`n+jcm?_ir8x1M6RVu zw`uHop$D>}mR83^7j^NgLvhn+W-Q0+RHn_dKkPLAQIe1)uCfqmFPJ;;ZqAfQ84X-c;K5F+!g2MY!|#h+SAq zYt@4-w55naI@l1AZx?Q@KCjG_{;!5+2-TM@=zAa`)!=%GR121XFAuipa`v&12nM)E z5kb`!_rFuIs~vpuq7^|^#TP-AQ?HZBrs?FaBblwjh2DBg+xxA7+a%M((=|4>3k z_#Y~q8T>^)&*y1fp}Y;v<*ST0I_wI?5hNLiODS!i3hs9xxinrP>BO+Rg<;6G;$@RFgyjs8#p*qC`KFZ|##$Rs>8=GuGV?(F3-J%Xap@*xUsIJO~8FD0YU zaC2Zrp)FmD)*mto?c-6(4~Rm0c}BJQW#Pv_6k2&Z3e7E=2I9`-jYBGNXJWx9vonPD zn;8>fp7~`=ggV6893nBp<`9UrwVOjEYrlmAPUJMVp(jE*P`n46{&oinb(L6RGz!+n z=yV}{Q-OJ`C<}Q$k#i+F5jvhBLWzEaZn?8}4L)bAGxCK+Vi;STUcx{C(}jQBFaTb` zcG3^$&}!l4Qt_z?djrt0r1oDEL?F7^mX+wuJp{b$q)L0sQd@ zrj$h~l1(>=c~DHt^Xh{m$gSsuMb8pgZk=;8WKfyxww!l{>ANstvl8fNMp((pX(mf5 znv}9UcTUK%j7mmr`!o&r1ptEkGo%(pWQZj4xwF5Ou@6qYbuOd~MHngLA_FiW$(XCt z!u)Jb;QUTDFgD0gk;?!9B3ZEx5D4aB!CD*&N6R8nS7^<6^H4Ge zNeyqIf@c*@%V`bQ(AEe&Vm8tWqaZedGhECNT{?-i3hhEKCMrVgSE>QGMq5rLMS(BH z7$xO>c?@laWS%m(3R$h8#J!de8*q0qRumHj(4I&^0UV)F{8;LusJF&@;28?roEuu*)3muM;-%3V_ap*JT|(9+Kol)P?3p;P=VV4MXxHZvoN2u3 zUChXU*XX2nol!eHXW-SWV}RFA_~K`LphcmKLkIm+rZq$nTq6gDIU&cZ2D{H=Ow>P3 z1x5$ZahnIA0D1))pcSOCCP9%MKewhJ}5((mBF{gs4A8OjQV>`V&dH zb%5>z?Ops=#5iTgi7H2l8lpJ%|sidAHW0YICCqK!jD13HZFuA3?K4*V>O$;lLF1zfoXcH_Q6-gHf&-RccDd!HkI z8ofwln?Iw6cQk8JrioxpM0sTfm6(bsMPwN19hi_!0?nKQ=|QfI(!eL_flUc{#S^r|>U*(+IHS-G&OS#N5pr=G0=8DD1S;|@7YT~7 zQ1X3(*%qdkm@W#7yrMmR+JFo7R1bPmk<}x<6iaBZn4-nu3nb_c&p@`3P)Q5iv0Kg6 z7fr&_6EU<93m5B+`l1f=)l>A0qJ83y{fddpSUjZ-#_EeYtwn{GMSi6_73e_aYf)`Q z)Xy-s5_XjEUd^IHI@GHr%^JO{~nTTyRPYwD5xCFfi7NO{-xVbP@| z&<#$n(=(hlVx_MYeW_86LJA3W~GnH;`NRo8xT z$M%!8B}ILa>v@6Rl^?fcOHUtNn+&0Tm|uV7ebGem$~48QP}_N-3!lcGq!11x-H-}K zP$F~_XQwv=(iG8VVv4tcYnUihxs;5lM?Hln&JUpyN@0C>P*8|!gohny6ChYkX}I>Z z#YYq|MNZC!<|rDHEbk4HLHTuBQ}!KTF>Jqp^_JGk_kTjo6~ZN#=GK-maS1CR(RiA* z!qZZ^WcC`~w#3KZ%lCgk*%dUFLG5Z^>NtlxL_FN%CfX07VPY8Fq{4{QC_ooQe-KoL z1R=|IZeQAlVnt}Lw?ce`y1L5}v_(T)>axQ0smudLa_ozW`X#m)cZEO2Q}Y!*nW@X- z>Au;Q(17tikgo~q^}10fLy_qQ)8ViZMc)&x6?spK2Pgcr>)T(r@(bVClGjss&Bkrf zHwxoD282X?c2+)A8138s*P5eAaJ{ErfDc9Oolt={S~u<|MW#*xq6sJ!R!fwpWnB5) z&Co2sA*bQ!$}9n2l94FJra<#*6QVvDnH>&3wNM8%=b_?+hG)?Xxfel|vuPx|LhiHVh z(XXSEL=BsZHE`I{^}KY*wSjMaf8z|Ov5QuYjAW|J^1!lqe%K46p2J+lAV!?>Lqq4b zzsn6+zaX?~BS@us%gE_zoB`=zf6@qAi+|Sx;H;*(SDHeoo)us#JV~ zu&YhpdiOE4pWid64lX zHX-!v{I{HGXQGS_rZ?>qa9uW(!>j&)jAL|spdyxfHQGVdhnylf`(E0`1hSFiLYQPh zii$Cdk=C}HK2hIHaPsQwgbbMVCT;?3$F#vDuS|dzOO>&ZdCEc;+W^Y z?t9;k6Te@ud^H{#Sa>JjQ(kIHb>L*xy4|t}S>*N{?ycS?*`pJi!UqXwIvzp*_(s?~cb% zeSJ;v4UlmJS+u?{u^SwhdcjVsHI9fM(JSo<+LKrQT2!Gb>M5!(essZrqv!b5o1EHV z^FsKQ{F&Ip)O|QYFy4>xvM;x+41`X>2LcE%r1n%=^Kk8{wC0sR3e0^i{kboM%zX`W zb5E(@v2~cP7QUp>arrMszY&bLH(u-s`jMNp4EZF^IKwH?9s4=%5bMzKzf!g&MEw>!`cH{ zds$?M@sGiUVjDJ+vQMFcCr;nuR11Wjj7YvR;%jWjv$^Og9vmZ9DYS~S$rN{P=OvjN ze7qsY>8(utCKaB+vA1^pMjRd;^+FG$e=q>X5$)RmKQtumrOtRC1FC@e$B|Gy5zI#t zD+6iOYnA!S>-p~r{|IH!P1@Un!^L4pL%XXFK}M7no<#h@k|-%6aX6I*7?3gXAw4LM z8YDi{JqX+|`SErzr-+dP9}~>mIB$o5OydDtreiWK)8-tOOs6(aDlqxxwMgol`x>P? z_if3vw?lwJ7BHR{f+>`;Ht0QQiZG@inrd}|o(O}V-o#1AZ=I~LEE9DWtuPNj4&x!* znrP`aCaMK&i8RL3%*^U^_n(%g9Z=102DGGmr4^9IfPWi6@vA%5NX43f^K>qP(5mCi zA&rSD>XsI~s&Y#d@*vA23s7khViAPvbab0H*%6KGFzYO7({SD-O4xaSR|-9)QH>jS%})a9nEUO5IP0aMCjJb;9Tc`^L9p> z{x`_&t!n=$4QgvGg9nw4#+cjSqs3Tx1=0oSB zO*qH9Q0;`iBuMr)St?wXQI; zbDV2Y0%I=4v~p#<54dA@nTe;-D-ln+9`9T<7FLy+?RivkAvD`_t~>U;FV9npq9q7^ z^}4fV_vcuyAb9t{l#I@bLU2(`t827nd67w?oeGIEB>Uyd83e_uPOIlslps z>;M)!>c-Ad4|^)Ovlj{uPB?27V|AbvPQ~dKv(p??c|T;#@a&;5+8KADYmD7k;xgb2 zq`%Bn59OOtv@kV~Tg(h%*gqYDZN8^DL@{_TFYo zJ9R>oUIb6N)TRkII6C^xjZYYjesl8RSWsFRR=>H2qi}GnY4w|{1`S;xN>*BseuoKB zI5-xQeuwZf3uZ2k6XE|faJ0zyi(=u=e3xPZ-Kou1hvN|#kehPTNJ_*Vn7$krp1AB@ zNW-~S07s~1%!}K2j;vE$Mzhi4IgwA~6tCh)HBxpdY`|$Bp4x}K^eHqVQ*f-I*h;Gf z8ZzwUC}X+~EfhN=Qb94?T>Kzba0A|3qX#V)YY^3dYB;D-`5Ba1aef#ntK~4%Bqk=w z3EyRK&SKkRd;sHhdUb9DhNL9)fF`DIoya&!;fLXKH9oW!{x9)q7Q??9pXJr?qtnCg zN>T`RSL03Wy85vzYc`xVgIXZtCYw|v&@S)qHcfsN)sE68x+erGLjqOU4^w`cg5s?> z#-jnvH%^#54drHu(*61Iibu@rRl1h>xzJMAC#wMh?53`^oOVS_gV!X23|G+WFn(qP zRZ76!@o%Ep4!Z~rmxMS#jD%^H4oxebJAXUt?X;4o~8W_%eXz`T%Tg)lt^Rcs6g7e za(V*fLw=B>t!8>=KRPG|3*J%)E&gvcq!am#i)mvipqiaXdL@pv{5Y1CcbC56dr20K zY6@6s-4|u503Y3y0+Kep{gCwTj2I9#MM8qm`A|WYS0+m=K}MgMcr#i`-n02DeI_Fo z{I9eZ=Zz3SOqPO(U~%6(c|royd)ZhYclz%yrQ*JK$BwbiG;SmXos06z4vIjk*Kv^_Qg`PZ zK0@7{8>!aa`5j-r#MFyXX5F2$35(o%)wDT%Ha>*aXU)A2wMcxRMhD8LthFHAA>&)p zt3`6BDv>zrkmSIMO>;+2Rgt0)hYzRh&SD&mZMth&Uk)VMDB$+E(}pwEb%s=dFgWFc zmT*GdBdg^frwrI|rKx9b_?Mey5GBjy1`m)Au)H|jTXw=Jobc08B>O_7>y)WTO8b1S zc@byl^Ys?Ww69m>4jTv25Y~7XP)Sn&LD}% ztzV@*xQQtxF)^L-fS|Id45$17530=mT2K z`qLQjz-mOdETd9s|KcnnzpP(;<^P2Rf_)ie6Vpiq{c74MWf2j)rNfAz?$f%Zi~Ok& zRQ3V+ICG$2RB*3VC_NNXz1IpJ$GRc`Kp4k&MRLk2oKbClu`EJ|A(43=c}UcT zN6?)ORYj+WDKy8^5*MsfXo_iMqMYhxb*^3#^C94Lv5ZgchS;300)HCM_P|S80oWA8m@!`=tx0H$M~417OjSk$lB_ z+Cpd0j z6_&)mil#HZ9cSL35YL`CNGpcQRb>Xa2$#o0sKF$fX$F&z7O^6#E&|LI1ezDa89Ns+ z$(?)}8D@%lRz{>Fpo+-|b@c*byhX_?ND4|^BIxyf=7umUpd9U~w<>1yc2M!%p=skSFDVm9~G=VkQ6~C>~dgZsVSKmWO zt~Sp4of{=X!AH0zei5hHhL`p?-`P@W@>1QWTfU?iQAT#KX%TWOXcrC9o}rJUuh92; zKiU?8ln!kLIKb8cO31;v=p}@}EG#;bCIWB8AUJvOb0bb?$q=b7#;?FqG&vMTG4zG~@32o8x(+`+5+xOv< z{@|aqjx}ix9`BzNBXVofXRJxpA#Q!=d{?T@rMT))9@p7hDm2b?-xJ$(y(9I`Pgw-*%D zFEdJ%i4SYsvERD*94w@Dm#7LNZq@g?xV>$+BDFh5! z=*^|mrv|bDc)a`VkPe>^Rxc@@qc`F?aI&j8cpIWWI5RO#Ft?Mr%9A;4GEaL$tec(Y)Yn(eSU}ZDfW*8ZGJVCwME9iT#9owp2Hf z46Pc9z_uNWjyy;bAe2dg59zQvOh=88bjW?rYpCCKM6d}tLs;{?=**B) zfe>nzjX-5u`gfLMw8L=XUjW7ZnRthhTsn|kJIq8RGdZX-kZ1<0q~_j69{DKl)HTgL zWQIzIU4s^K(Hw|od$QGVu)TGuQlpi6O=|gMwY?C*G3fyL!d#PyI0Aac^qv&1b*)^p zDQ8W~C#%1Bi@F3jOF9t$YE&3Bna59&6a6*O-kq@=s3f+BeK>8yjb8RrILK8nVnh-Z zsIe2Yqvz>Sj1i*AIN@F^jfxOjB2*&ACY3BQuv#}H7hz16ccnOV?D~2_xDpMQM--r9 z12owHL%CJeQeRqcs82N4?+hI$8qGz2L~+Jg?1L9P0jI4Z9D=tS^LRZr%TmZCp=FXm zeG9eHaP;i*cL=0)&aH%#-V?AwT;BQPqU$ItpZpm`p=Uhaa>51Ryu^Vs1%Up*-|3i< z&!_=q`#5IAG52G$ptqK;^2wavNXo3qYinbFo`Y#HTBtb_REYXWs>vACtcQZgfh=T> zGPSVd+lRX=C1`nL#ppJq?K_TIs!uWtenY(CLs&#$7;?* zylph>T|W-m*oI>RRi>amD!)zWw$$|tiyaL9{y7kKys z;bvDsX-qXo(C2~JCyuSBdt<8J0V8Ei6?6=rv=V~B2}ty9C81Da=BPue4t=U_ zW0)2}Z@@ZIUu&GfF6POROOzfRS5+=irKQS%@^QZkZV&OSw0|{WE<`x6IW}~9A&Jsm zsQ6#oGxI$Dnr)k7@1ZMhdVj}`!;&jIJseaUhfHbgV^kaBtC++!gU2KJDT72cxdn>B zEMp%C&K(y*FQkLs!(bsS&S{o4$Lo}~?$w+UdF7@0xOhpV$kWs(vIIGz5o#&_z_NUw zTEOMAe23PZ#tW-eeHE`xL|a(Xwa}7ZsXKx|E}@A>kZBR^r2XiVNQmKeRQoHK{u>bh zi<(X`>qIz?(JZGi!=C#;;ShX1p|R*YIebz$iX0?WO%~$uTGbzMtyL)k?<^xJ`)`La z(B{XUfQNRfd-Z)nMPa43j_E~XYQ)Q5HTmF3JZvLZSkXg3wG~D|ugPE99&e_p#p)y)1qB#N2nX7hZ6%Q-m_@R8 z^Ofd=k!59>dDRwa#nlk;OGkQpAR;?h8Uj0d1xZWM^kK;9UPet_uF;RiM@Z?8@aM6V z3cwr;WxO%fDg_TKOu%pk@CKU(j<^ zgHRc(V(t8ayHLji?uF(!uu**uwzOv%JKOs+cGmM}>>P!SqXDz<+tg!-zH)c8D~@!X zU*OWh%>{nRNHwLFm-AgNOTo?m6TQm|5fbtbyhk+C+zavlV!mvhF3K4}T}}Ad9mab! z?*YwsQYUkZzHH5=rO>&L$sP46jSAH!jE|l$VZ2sCWiH1rfx-;5Uxs1~Y2}#83{X9b zVW{2;p-;+F25tNl{5M%6K~*;GKLyyU+}L|nCyjD~S1|N)!PDwP44y~kD5cV>Pp^Nff zr6|XJiZ?!?RZau>yL44k_F)+~6OQb@n2wnj!%PV0GS1Ay#U^IafcX;9020&#l6?`% zyDhPEek#=goZGlx9i(zX5`vqy@9~B-jv_WfwFN5 z>@u3tE~j^+^@fg3PgFXG@Vo5!zDMrO8S;yX8Vy|w-+4nAaWpv>6*_^;pWWDH&H?^) z)SzgEJk{{zsh*St6fa?_n0C3wnHI~#S%$;Uk;2U2Eu_b&@-rt7D z*kof6>#QMU2&Y`@XoY+@Do~hBIhZa_u0p&%^U0tU6|W)YNfj4YkmDs!acYC2#+#*d zi?yy<;xo#A;VfrkLOAf=jI*&PyQ~gY9~$jRU6RH)kf$)-NdZ~D%hnp-vN zS&@m$M31RGCUwWJ5jatSAW`MfF~Rh1ZsymfN|MCh19y?KkgDmj6Y`q3=vZ574Q2aP za)&0jtypt(ine;<%CYMQ@r((jG2SxtIc#2&194ISDvO=zYx)}b!rt=)L}GCJLwc>w z`|}35e#%;e>Lo12*w3PjAPtX_TAYlV9E)FRgy=f9_xL_iD=3@Bp%UDUvoMf-F_1M- zS4|C;s)nk;yT1FeQAp+13(K0jVjf{nKNN?qz9>&Ll|foe)+$XQ0lSh14hNc-{lUVw z{@GyRhkp|+>@q$^!q3Ij-snOD121?|e*n742MDe{&$D#!6uPPH`y3XhjjHu&WGlt| zJ|^SlZCa!?#fT0Z6FwUK5G)7W>O@g6zIGIK;T&lQI}UFb8g0Q&asa%Ztu%G z6}|y_B_UdAkUv=-Ocv@q2BF{_tf2}PfvEvAA3Ic;YlG=y&9&e1KpAaC6^z#rcEE-n zZ&t8O+%j`o9-S^C;}|v>`>>jTt);$-9SwtmQNqXpyBph45+Q|4HAR@I@i|JU>*^Z5 z!yF1^G}V`Pv9)j#GnF-KX`3g01g4e<2el23l*`AJa%R7y{s~q zeU-WQPgX`{^rTtwFPL{8VgW>9iMBASLsWV|GjG#gM5!-y8pj!hTN^fsoewT)ltTpw z3WW3rB}<6KduJS$M=|Gz;%hba**pzq9V0I^Mn>xTIuy}*uc1RDh5AK7u2S6vBwIF? zJ<2bYOOwN*hff%$Wj&`do>Ubuo>qlgBFIV9gSP0cy48+gjqoFxZIy&% z92>hFpj3^o)bw||cN9%=z#t27%ps?ln@1j2yFpZ8I`%Urdx%o0GggGCXB%TN zDG*Lefp>4)hoo(v>JE8}9tk;Jm}yXNnj#W$u-nF|>{ed3_^Bhku<?|G(+S*mt(PYJHZQ00W&61+1Y_(OwZ{Z2( z>qm0`f<75fAE-|ds;{}!2k$~o>(FCz!_c6v*D#9BS!3WmeFD5w`cjr_vM z!QYV<9?|7eZSh~f-Su2%Tw4b1pDZWty%$pAUbZvhexE!c?!A9FAMTxh zCmpUUA1k;9BnBgtc5kLQr%~59ppW2AI3Q>@J`uT1gN`BW10*d|*pvwMM3tq{>H)^+ zGh_o(h~^lf!DA(++&y>j4-9BExs=V;`8Amzofy(qeWXf4pU5ZS+o(Y`yb$C9{pGo1 zy=4g;L+wz~@_(ox?BXbPf&d*7VpDNE3gW+@fP82-3BM8kLXob55lF<{$#58RsCyuU z%H8K_i#yhwLA&*sO4ZuI&5}oETQ`Q%B2<;_SNxLlfUGuz8mqC(tOjM2)Ckl2d+W7q z(X>*7_K*D?orI-te^dB@k59Uxb++b3zWU`}9Fa?VIrp8|X%lqs1MT>PmN zJQqh*;b&4X5TA#9L-ZM?RUK#)wvNhn@>CS0s@myOZL!iEZ+s~u0+D29cRaq`c$2@LvT1Os!*WAzD|`BAtk2eCv`Z^~LfjQdSfm>-kXS5yx%74y+|IGD=O z*l1hT{Y1pT7nPPesK_Jeg`_F@vndb^L5YyX`Ue8u7YE1EF0~RG zZWs80i|Ev7I~6)W=}&3%y3S}V8=_&g)jLX9ED0e7YzJPtD&GjVQ0y0z)%}(#0!OQ>X6l307Jl+hINY%Hk zk_i{VjDiK*9DFI@j*?5ze>GPjxFLk4?#%;N0DfSd5Z!x*4XuMC)J*5ElLH2`UGzPj zpo_HA>8<7o=%HIEc@5MlOGn7mXRt6%rsF6ZF1y#y9)~o23@mNw%Hz9+-$Bt2rSLN? z#?aMTnq`fZ=)EYNOo&dZ9@Z7U=4pl1kcKtUisz%LB^FbCo6H}4D`;Ko(DM1Pu?X3U zQb$uBTn#5T2zdwdGQ6L_uB$unQ_F)EVmnz;v<;4!frI$ZA?mpY5zZ~dI zj4W?jAocYTdXMXqH3%@a>*k znmfnm>_{qLJ83+rQ~AtI9)+w_Q@Vs!?8t`to>0QOPexOlLiGkXo5q>%LF$X7(GHr0 zZF4O*ca}#~fmDi9t|;oyryPG;XrAhM97^qUsoj?k<{p9}prMcj>o&pJ+FtgW+<%G| ztp;!1*@Qz2UBrh7jXskAg28-6yn@y{S{+|eY6oV3A zKF%i)h=6XKkF}b3TW>qMawM!&MP>J;G3a1pp}mA%)>lJO@qA!jE&}C~D$sVd9ecem zJs7|e5E0~eLLOPa2bgc=L|x_zpn|a0SRirj9gMiZ&9K;f zKqDQ{O16XTWQWsOVzkqjEQ^~D(900lC>LZLKLBsS(cj}*|XMKgmTwvA6HMM2JAw_FD?V}V{fxC4T3Jxo%=tYy8845_hl#xy7?yd(=C#kf_IVnp(RB# z-BsN;XsYE}k!5cC4RWvM-jSp-IOUN#vyt9p@zpEr%|KM;@3f2y2|>R ztP*45=A~Gk8 zHjsnKv1|ADi}8sAc*+D2fdQvDALSK0rc(V;4W6kA)fq9vh=OA{ErWsj(dI+Ey2T3T zC>%4kj8VJ>OtYy@1EcfD@uRSs!vgDmC~1MJ47<@(tOSr!y02dh-_yuWpRV#}Ys~Jx z32L;CHu&&?R8l&tSY>1M!2uPt#DEGT%pYhKpN2J0kkqOT7e}Y0s`+3 zR6-3$WT=Ln_4dw_KWgp~GgTU4I}-2XG&~-1BaE=)z7-^`{9vskx zq;^9dZoL*<5LQ{ysZ6cbd=8YBZ76FDRnfdR;q+8h+Q5ESlFyD>u^rE&TII#M3x~Jd zz*`H+J?g_~)yUsSZXjdXleh1(c?iBepLp|y_>213Hr;29e@o_TQKbZC<_OXRPGDG3 z)8k~WaxKqJ`mOMyf@c3n%>`n)oCWNb?5Ka7NLrpg+KxH<&iVZHx#FV+$7dQY-}mEUKR7hOHzg zAZBS)lUe9HDzfC4AAC@@hEOeEV->~fg(p@dy3jcj1#^P9VK|O&CQE3tLgO~@H>r8x zY(Tk!n&9G&`|@C0SrKM|`fYM!7K2m}ftTDSEE4gB zB`#a4O^Vr&UWYJCL7ZKlz|Ogr7HiW=7Oq~|XG^>df~&&A_8h|%_Qdq5!5AH03@ zsMb9`aT~+fZA4+Fd*?AdC>jFN!|qbdIkI=hQa@5p)rijGkf(7v_QE_6kelF3lRJ-KE@H33ohE080A0Z6y+<_`go}_h zV0^&+zF&4H{eri;zKvilEF1Q}N|1AW?q03#rQEhisIrzUq256sXN0@=HAzqsZ(>U2 z0GZ$bnnSN?xe;$}l~wNnRPd{`dixx{52+(0(lco2UMwr{jM={VNNoFcRBJeV?^;#; z4&R{>j&H$=U>sq=|03qrHwh!eu!+NC;FMs0FS^6$@9k^*6Z+$o9pRLFaGO5YjB=iU zwL+c8F5fqyr5LqSs4WR@y;w|@o$T`c?O6OZcKIG@EVIGoTdi3sHqrdA#pj0+6lI1I zcH#6eoUJfCYCk;Pd+A&Ct?@pT@!js9%n<6OLv3wLZ_3|*--l!XH=|n;&K!ET41!-b;cQ2k&)(C`nSync9`L9R3`BM+Ecb6=Bjo91re zxyYv{T>Qa7a8=uH3Bj{&yh4-Nn=6;W9u8ooq5{3M4Llhl43K!-A+qCHhE(Nv*srHMnRW z&3$wQQnoFcGJnznshyGQTgEUd@T`p>%2B1BN(1-~z2<$(cXZYI)A11iJBimKQ(=NK zQ`b@>+>7JBOC;8UyD3{;pvAuC-_FN_w;LTj9VU3=V;dhcLn4qPAC%IS*wdGSo25Xj zYNa*zjA@CLb!m1NtBxO73uYxa`1h#Hu)}Jy3EK0yDyH(Naj=s&0Uu>gig2v*1Ppe< z>`OmMahQ4vdLYFyh0hz<5#IDdMucJYenL@wC4IP(a;%0>v|0tNQtxe4g*De_ur;)V zI91BGR~tonk9*PH&{7z-QURr$?GH~`ElUXtzq^dg*d|{GdLSHTehf|q+F~B1Sodr@ z&dc8GYcZ(n_c2s+JQzgRs$x4;;VtlOQwcE0+JOCHppbR1jHUEDsrY*6#p6}*7*$7R zg2aJ4Vtu7rVOUeSK~39LIlHRz{lFMJfU4R4%k4>v z-m$9<7~W&Nh$8*YfJi}4`1X}p_BEh9=Er~49Fz`5YNMU;u?{vJ<*3l4l7=J3Ew=AH zwc@W}4_lh{At zNqbn~WM-*lkrgV-?67>vpfiJDL7|WdTLgt0NWlW{(vNauJBc&0 zUYm}vb;00{&h1M#sTp>q?hd*@;m)hb4e-Ud`Qsr^os)+*Y=NOtWkgrCI2{TjG`!5; zeZF`yHom4}(3pPXBBWMT_Shu%fIj}HuRQ4Q;jLQqttPcDvh!!XzJM=f8K^4ZfX_@(AN*VrmsK-vwBz zEwv4#gB0$bcg$|!g7J&_Bs;NV>m|({wnC*7;N*wVZj0Rr_dGU3_?r0e*o{rkv&gu1+L(T9UAn~+pTNHCpy8%O6}>0P*|R> zP>4pVr>{I4My0ah$OwO~P%JsyKX8V%VBuam)7S&+r|mQF4t-h^v_H)F;4kX^mmxRY z3GV_VhEGJ?So~>x;CmN!m^aXTizXUCAQnq%*i&Q|0%8%nB#cH0_#+Wpc$|qqqQz!c z0U)KYN+GKQB6smNe4?|kd1uzCQ&OXsqtWzr@*Xxnf?Lod`f)b{-hn4m_VY*li^^@U zD%T;b_MsGQOoaCpOJkoAMJMb~EPGrG=q6a-jBIJf_`&U%V_Q{Y_;Fyx1M3k+FJS_} zuM=$_9o3OPL+H$fiJz-*7FC-?$w>$z@rEOS|K9a zTVG*GgOgwnX|Im@CelI^PW=e8y@3A^UMn?8RLa=7O#6~rhs#~QulxrHWeL2Ie_&1x zP3R$9<^Z5AJpsQ$FyNVxkG?hXGGOyVqdRo{v(y9n;&u#45Jb7StBPLMtojm!?OmSdy`@tZe#I>xQXPS7$ubWlN2*gM`>;nZG9n(Gh2VUG zH4)Y0p2c5yk1@--mIPtHUlbq_%-(!C3-C_JaCeuvB!a$CLkF6#LBD;S7BM;*z2n}9 zuq5+$-Ks0aN(gzGVcCGG6oyzhP_{yLqwVWNSvm^yd?gVj z;G)@B$nB`Hc`m|Xx=A{BV17Uzq#Q;&!4im1L(G!;C{=obDJzIu&vvilFE!)Sp0Qo?5NJ4(+f zuNILIiKV(FeD|Nj{kuTI+t5liMKf}lMG?0FmDR|RP*f;uEKqs?kL=XUS;lA`;cJzI zfdNxdRwYD|2aHc%A9N3ER!{3>g75$Tu1<}tRFj{tQv%DlsO59Lk7jzM=u}}yK?sY< zDjShh**u8VF|y-8aw5p<1KKibIv_9K&~JdKO@;aDKK11>MKGM;R2}XIuwk63BUM^- zm9t`LcJ~1Ui}j6tKL@VgheP^dZ5Xz2T`yRUy=2u<6m;f~a!3|IK#TRhdWTwtY2fq~ zq!7=h|;j5fn4 zquT))qlEhjom8s?+?8B~BLpAQbi}2g=c0=?NP48M;0(Gz3nJ{(J`ldd)$WzvnVx_s5Z)!kis7hlOUr?jAwB|~nS4GQ zA7Y-p56rx~$I?rSH$qASFc$V^0nxy{QgxCDMiu3dmkN(AGy3WT0ZNr&v1bDwysnPe zGC@ZEVIKAaPwKRs#2bG9g(gK{AGJ?lxZrULFT*MPiA0~Qkj7xt+n1OJ$SUg-#H>9# zQ{!=_;#Y4bpl@H9_Pbl|zDa4ndm!z19k`e|pS{6pzoY7ik#^c|*GjGPG?CcJD4`?% z6uNK)iGV;!XApB03je9?L(K;?nZik;PQ#Q|0rzRHy>N!5BiHqlzy}T*Xy)?erwTNp zkeKS8ksjw*@h1M@qd}O>T1foRrfN!DsRlwe zb*4{OGOF$$lE2(nq}76EsKu1fT?$VfTptj>Bx!r zr|@$S+I+!nET};|FUs3Xl6XGDC!QC^ZxGL)lir5;SGjQ?Bm%~LY_ofDpRrdM8@$B? zrv*{ttq!6~)n2uFmDYNG-|JAv6F{^Y9fUG5KX)V+L!aU$G?~J@b0Zun-vdrHJI0QK z=vU*tkN=I=%FI4zDML}$V&sfHvj#30XugRvD?LVSF3rM89S3%<9qtixy_Qlott4+d@tR4ZF*q_$a)g~j*gI(J0UGVn{nzal3fVJ1qBn=ih(H7wKfFkUOK?*}; zefqE~jnlkept|6olSVG}Ic>7u!}^AOZN8ciW(5I%IQB~bX*9W07)4YweHCChS+G%d z#2Op`OpOYeq}oFXto&>b-RJy^qdrAayCnJ997UmD)85&Tpuwek)s_!+J&q;k74yAo8qkp*3=%&RZ z5uX7+u1{@(loG^K6Ox2`EkMhAkfVgJ1zZ=2qot^xupIWAUg|-o>|j}d-yPFyEPw)F zU*~jRY%1ILr=u%}{$c7DM}d!@%l3u*U2cO-5Hqadwt|Wc61h#NFPFk0fGl7C8yUb{ zDbpn4ybna0#37h^S#j9kf!O~)Op{3OgM96ZJbp}G(&VM8M@ulwylQn>3Q*eP)vm&H zTt~k2dWsoW0c|lmQ(P(9TMBKt{=b-QUh8#pv3~Vg6N05y;#m`}pb2$f(S+nI6fA!Q zLO6-bg~O}}B(MVhlg#}8H_M`#Jj>J|Jn70_Hx}< zUlj*6rUa9t{Y~<}e=74I;+aJMm$n0ZkgseC+el2H-^_RXW)do4me&m@RLlX$zWGXv zNf(e}f~o=sCq#dU4o0Z?&a15_6|fmwsd%Gr3poOrugr_IfGu8dUhQ(4Rp~=J5aX?% zk0W|-g8JPC1xh19aJtuUAhy$%vvvBeFh5XMNlPFS&wiUUh4>0 z_aqR>T*7IdPO1%1g{+!TNopcEItF?a%tZem8Bf$rcx3{BW3$b3)DpY{I=0Vc^R=&z1# zRz|~&;;iHTyV(WRQBS}lYD&?SP_gNp)a zQUPeqL1APu5RU%@0{2O)r6|U?wX_vzve;zJcKUWnZ#UM!8SQ<~_Cdzc721XbIS7bY zk1$GbLZrL84^%1#VBvZ*7`6qCrm`ctBtu!H+0O6mZHh5}y-xWoQp+sso(0ww*P05> z)hH9}Yjw-RBAnt{9A9gP=RO0WT}iwZ#4nYRGf_1fXmo<3Wjsat2sh(k-wW7cX4KUM ziDZKXe)$=O2!;q=C^K6*?5vW*X&NlyrI@)3%UHEk4Lc)64%{wm+%r1)D#??a3&nSK zBe^%OP+O?EYQ`&-aelR^{4AQPF{W~`N>{9+a^P)&vcNBWsfY$(ns5P&;e7<{y4y+|@{Y94Y7pb~i zXM|!Lhk;LSTT&YdNP%G({Pz|9NUGEBkzc(~r}Hyc$IZ|XclWGL%TcG(s8iUGEt*qx{LS3w+#x=J$(8dY?aoA%q;wO!_S zSP1Xaz(4=|7&3dRpWyvJM2&eribG)H22^VefLQC@ab_V@48|C)m>Uo)u9{nFi~Utw zgHKeA-6s#e9jQJ(d&+IzNl9P2Syj7u|{x zb?bI40jr#57Bc#Atq<59H5XFT7XxazF#h#K6?@{739C*p64wYL9qi&WX`9=MGzP+2 z;`T;v<%&m&Rw&epEF;@3mT{VlU$^*-)V@WPm%=c|SPDiCeOTyc_2FjCosf#stUV8m z2!2V0eGs*ZDx42_lobllgL{xs67vA&8`@@`6JM@cFbfbq1UsU8 z5rLQ)?F5c5RhgFX5x8SjDQmwNiQh(>+iiOpt>mrp<+eT5+HVfKI}l7ZPgCgz8aa)( zEGHevLcst>`|{}ik;=%KWDa}G=CCtEC!&J&o!lI7Ta{MR{l zjTdt4KK`2=yHAqm*hNt(bL=Kdusc!J0B=_iMGu4xDHzp?`~~oqXC|>#;|m<&xH;Qcj>?_ z=5v`6gi*;8@TDEWp=w(fM33zKg8FCC`u*PbVUJ(oHcGO~R`RG$!qv$Rf#dV#x%Wgp z3wo*xF}MKl{wFX`U-ruUI0@jFLMs3d>4b5j75;%bP6Jffz;6Z`pMpef6hZ>#QcvTI zZ@`ArCimn>b9d69oexQ7w)Br0EMB8#KnzJ6$C=+3?h@`LA>E3WZUsn;$}O|q9z!)W z_lb<=R22RLR(c?i&$SeAX2=w-nyVUZ)k@?7p4#F9?^UWNS8*yl64_|lXldPLd3bwG=v! z;^wdw?7x_Ln~Vk~48F-c%C9tjCCQGkeU*XboiiNU^7WsozGAZ!MC{T)w><`=H(7e) z5H?D|-DMl4AZYuIQho5(Q*hY7obvepF*ibTW(EYIK|>$wS9i1cSsHHtvn&mNm(u+k z^T^9}Uh9=AZ}O?f%DeFe#g(PFGBAfd{x42DFAX%SzO?SItodIjgwM#sQJmu1fEh9! zMq^Y`M);8bJR^L_Ka~+aB-w$*Y2}|~gjYR0BF#(9LY&k7RhA>aHaCFk+Y7RkalRB( z%E$kv!A%UzbtfoebPvMJ1BE{UsbD;ds;@G)RYMT;(*kO3>MLhi zL&0fwC1?S1N3+FCD%e51QVdl2gEJ$B0OzI*zR-`-;qey2eP=U(DRpsj&c_MpRREc} zUTDr!YyQ7t3hxT7^SpfoAxzCSmBvF5v<<&Fl;dG6O>qh#f|=!m8xr;{JX` zy>j-Hg)%#d-Zch9I5v5&J0BTH)i({I&3D!$L8nmgThv)>JiYStitn_Ql~#vtLg3$9 z0LdddMX0j|Zo=IN1rW@54uIGIq3~2@RZ+Ap-=#vgidwf*UC*>Tt7Qil8QV@M&cZD- zAPm+(?CwVZXtS*Dp!H630dng?dK2)HTO#z|oo=bFce1pYLN`oDup z9w;yuLp0%AgZ>EHcQOEESwYRIw=k+Eye|-fwHCA)44ph+R1wy9=1;=dZN4v9=d~PG zh{&PdsGPTY7HtZFy2HrrN3%V}Kzi>o{Gun^ZO}8eGw=MMr|=682Kg7TQ>(^p5LwQq z-o+4jGu#C$ASuR8Z5Sr_J$h{XTW~@fI{lNc8mkfSY z&HgC{hI!EP)d1@0O>c|TaYjk(%CVGzwHvnH7=W}5+MVTQSMW_;lK0l{qCl7dR;tuD zYZGGWV9UZB9VyyKQAhUshKYMjkdE+^2{~$=jm)%wf`~BMep$0<%AV(*gfCJKTLbaA! zeG~;F4N8oK?mY__030}xW?8IU6X8NmK44ybysT&<7Lj)LG>pU=6zLzp+oW1!^0q9l zGTHDkO_n{|4}|(^MN^UNNU;k;y!#9wx_w(kDsXVxQUQ9l9%b7^BmWV#U8;Kk+zbOa zQJY{i(a*?zdzp^uOufAoq*g)Jt1l}c_;Fu-n7@2=>Wn&d=J=BGh{JdP*E?dc(SR$p zPXZp5vQnY@>T#u136MfW0y!1wb7=Uc7#}(2FkxV@*_vnBpJ$$aPr$+M;;CK}7?cLK zu?%c}-&<8cUdTIIE@PiCiW>-{{1dZfztuosf0jmrr)Qac_#);8m`$+ofcmeI`cAl> zz?N@xq%lW8>QR7t1!CixpeDc>kpV3K-GRS1%@{AZ>NCZtbZzn#1cmstALg8gIla^U zkw35;L32Pz0Wri5jxK`mt#mLJYRZdLhH8JbHA|Dgu~;p^r1$aX)ilw<@fLuY^cc+_!d;UC{`|qX9UXP zH)EJ-n8x@~LSt}!C2Wf=X%`mF7KxDkIx)0WlRt{(pRJp|?MuxHK?o;`cw=+*yD}&=^z*!7pAlU z>a7?A-%Ye!z$jNk23n0VnBx1xFp?ZmACM@6?;3rB=s#*a-Uvip(+{9@Z9#m*e6t=1 zCb|i9?{<}8u1byEV(9UXHSL7X@K{y9&-O%SbzsAhRNNVrtLMZf2WEwu98=g4R|Do! z>g{NXi?&CA#V3s33>xUoME%$e^8i%Msc4crAANzX46X&EhnX60*&^JN@UHz}@<5%p z%F|QUeSueX6Ma7I6+MvY8*43-53qAO1-(hYfDOg*9FqWkEV?AzjU_d%OhpjIsx_6B z$8?O+CaP{?!Q{o05V>X~3^no7qiYm}?|zJW320i85w>Z(*sUG5^p!tr}be~aL9#7H1)c9;4%S{2d2uLuBm zJ@vLKjZjjxZa9R}KG>l8!&Q38ERj&cJmS7(+@k)v$`;-9@i1o9dI+O6Ue;My_?$%_ z?7dE(#Av=e*N7*EKS|vIHj3@=M}3p`1eI5jHeo9+LiUB6RG=Sn7PK1Z3yMU~9D(t5 zpL?b88zPvO$!k=315%N?TOLyfZQz_LV01gwo>ooKRsvEe`n$JXUomp;jKLUzW#x*Q*}INnpSqSHxiVc?De#g#Nm`1?LP_JeF?OM%2u!@shZBxS?Z z?Eb5K_N)i%bqKGof?x+uHZVN#lFACDO`%vUJnrz%?gTr*PVmUZyTAV@%FXK$Kc_6$ zeN)Y{zDYKAM|@*f!v_cCu`@l4rAr#y7q7dF+Cyoi_g!BqN?++;Eq+AWC}ZSZbLJ9F%Zc(ZDWu3qp$23Nc`b4 z!k&B3e%O<`*bBFMRFwT2M)PTC(_g*O#A1t918n<&FE}jR6EFlsELkA3e1=yD_zOYW zlmJ}s+Eyx8YZcN=wE@v`s}@wDCB#w{#W8wtcYN+S%SaAlaGN5 zkbu7`ln2s1?C%QR@oyIF2^jIhjIez3DMe+>WMM`)A{e3r2o!|(qvEtLE^}RlrT zCSmprLtJJ;N3+NCv*N!V3Yyd?PwL({`2P7|Q;%r;LaRJtfPZP)rSd%bZQt5pM?^}xL>t3jf5zm!lgCN%ypYEcmtXFk_f!e*S zgA(t&s`g(YR!((!p#|>#UL166W=shYac_KzJL&hZdlK#ho&u}Q^L;zW?%S94T_(Hh z97{a`lW@xW%PYw3KZ$8ni;bGu`v>*QqV)&Lg-c6{J_M*SWxYKCzxhYKzftY^it~%y z8@nd~W>Qw%6L90NYyPr+b$RzcS3YbiDQDjRcG=KvbfDd!B#e!F)6))23Ec6BywBC+ zg(>15@*hmsf-=N6UC-Go#*@0$D{78OYq!KbAmneFuqfm|Z-Z}|CtH;642-y&G+{n@$p@9Xn6`|8eORoL};!1ckZdqsUxwA&+e z|G?g2_z0eUsZa1(yK(1B{D9nUZ}xws-6_Y+<(>0vOrF;5+2;LUuj7l$>pwXSo@*4d zEZJf`YucdSvL#KP5Vo3vKc0DAV0`#5J3w4b27!gtXVYU6(o4Wb_ksanJHW!*021C| ztQe$&Te61yg;H(0f#gfV4f_l7dkwQ$mia)po!!=6F-nT|*9#N#@gsqcRP6w(wcz5~ zAuSCyvM}Z*c5l6fL|VuJQyd9L2}TfVdc`Ky<%E4dfiMKh5!^+E)dwJ2!pZp%695v% z5Zj}+7DU$ZlmIT@L`VY&sKFfNjx&QHoPBWlEZhUX$7l#;{T{Y#Fi!d&{1g|*sXR!^*A{ioheSoCYpM{f36;%vSuxP>9Fk|Lw=33xsnNX| zSZd*3ZAVDZ1Q^@BYd{p~-Gw+G%4rPI=ff-AU3CTNm}X>#yPxLA7+}hz3~-}VfGu@_ z^)U)bkgBQb*HdlQxC*nW!j)gC7OVpkRvddpU4|?FRVd?QARtG!DT}4 zc9`p6U!lj|bUJb+_ZMb?gxq#doB7!r*^PM^n<9!8iBMp7aI+i@DMHO&Gfa71RKfj+mF#2nti94n~SkENeb3Ecc99DN}x)Zs) z5bYbP%E7`+i?C)=1GK%GV1*1-2;XX{n)5mehOs)1-Z74?O!A;0ak(_Ibn{Q3lu0K= ziBjU%AaMoWgdLJOb@^^e&3CznJ-R1~T@@Di`I6p!<)X;)&%FEhd!p!7VKg>V;pG_x zQc#Mkmqhl5qBsnEOo|J5Ijqd0_*Ic3ySP%yUug^-DvwG(2ogVBvF{+hlhTSGN{EMc zgeCjA>HR>hrB>URdv(`nEZ`vqXN3hOPq<@TYJ)Y*`v4jjwbtJ-;R_N*s+qAN86*c2MM=tfcV2u=_$yGBUyX(%U1-Q5VM^PwKW#PwE1m zfZ6)*5acp~t@!L8h{)HFx>=pch%5~yGO6?Zalbo^?hNAl8KnU0 z*}V|PDc}jlDZsXt>s$ywqsDv0BO2;h>22@yj`5`aUQcY#eLGZm@#!)=hY|MC0ud=K zXpIB@v1?QiTrc_QXQwfX+URtQV3a?u@V^Qaf~?qZOyIX80+Czgg+k6z{tf@D#54A| z!Ug`Hxr>hJvgGkv52g02Jbp-C<{0UN0K{aI)oQ~in%G(DF(r@j^@sAP?|L=g_3E{w zOKHYPYlt3G=16Vd3l1{P%2oRX*=3E|(ZS$YYNp4OK2pbb{QwiCU#WTs1>kzE=o09x z1J)-Vxa9BAJzsy#GcH-5=!MNx_8w0)6H*Cx7_jF_eVDQbllsr-Z4IXZzF{`K)^Od( zB@ry+f{P}nmIdIbOcm7l5CsD-kN2R{%5xs+OR@d8ZGi-EZ;L!s7Fs2K{uiRu!G?K!U7Cq zP*2s{0@=jh7WWjQ*7#?a(Sxh$NnMKi!@`!jozr~sTwODxt{I=J>qn^T8!78L&XdN{ zEf_ziD4`0#K}T-{AydW$ia14=F?WB&8B>lv4%Ht0D%4wu-dj)Utz2;36=g9X@Xyz( zA+D&rDr+~?9dDdL>={y9i6Pw5QE1ZL6fMooE#;qQb4zhw%9c*!mh%6ew$wdusKWT$&oQ08JY?u`>PjLC@KuAZ-~_EH zf8p{u9QVD|9U7Q4bCjlA1*DpUoN;-Psd0x+@IeNGv zsK#6wt^S_0pE)qu*1|im)@jV~qyeGzTHNjVqn918W@N-2DSvXQ>&7T%waQ|abef0E zK6*k)uD}={o!~33Znla*{{zLBW41TM>8&UA#1MWuukhNR!v8&&{T+OnFz*+0fC#GH zcht&hO!7wP!z+tnCxv>?0B;(wd$clPl&GNciJmowwvpW+B78v3I_z^mNZ6ADb^zGQ zq1(Hi&}xwfNIWxIs4vk|ITQ2ib6bDeOZJ01d0hXVoZe#WHxg-Q7tC6^V5^<-*+Ch- z=bh#QbB7MzgVN#x=A+t!>5ZHSxv)RtHFecRSUT`i8TAf?5ZnZ@GEt~+v9nf$w1Vfb z#SY~-FPP|Xx+4;J7k)YmsSD4Az><{TCv1OA+nm(SOw^fQ=BQ!Wv0ko(yjBhR)j??u z&1nn=P6h5>)l>Z{;Lye)w{8fujV|_oqv2aWk`QbLyB}VHYaQ?u+oT1f6c`dK(5PrS z;LGl^9}{Slx*ub^f;iDL(<$Q2-JSbuL=&<9T_W1ifG9Coy;D6{y~j+$Ynkn{lf!%m zIU!!BZcY%hKPLP=u0*V5x5N`|I$S9S=lls10wz-4` z05Q_UD2t>#0c(Ks$PFSeaG!K|)oYU1)jTfnqJqG!ZS+6(!_(4{N+fyd$sWUOG)|jS z98b(IoQB6Zty4=hc&#}_z{4a|F{`#1)l@|80>zh6Tc{p9KJOb>OsthqbD`5)-?)P1 z-Bkc9lv^~PE_O_3zRUtxn_Wc0LIo^^YLW603dJMlLil|_4~=_>@4?WtPzA)!QKH!> zVYN`X@yxS|nYp=QTpzn)sG(Y)Fhvk*Lxqew;)`8kdQ%X@c1BVhaOm!)CY+%L?6Wy| z*vHu<`n=fysDwhR3JaT#%j#)uH~Kj!&{*rFi`I6XB{XVo^h3f%6yQ`##psSAY90P$ zA2o1UE^96=q?!&&Wh`>ye=RT{k$+?nzZCpstMPl z1okgw*Qh4Eh`zdEpXX~h3EWeBwc~3@6^qw>Z9Z$fNmkM4oVaQ9b zyAtzw?|?V4D7^uA#@pDyJGf8MdM}u5A(^^=Y$3a>l(EQ@Ah)kxvmJODdP4wbl()Dq zjY8Vnr71PmO=3~=btJwWLmvh1hV2@R&h8&~o3HD5R~{THoCP7X=Eeb>MWv_ItWtuP z4w%Q|GQP2_%2%p2Zd8@UjgLT?wk>KJ+_2_OcNmu6d%9w{a};;bQMJwvtA+r1nAQn7 z%{~=yDnY7MfLumW=qajz#6)IUr+MBZ^6Lp@xt~LY$)85B|9b`dd&=<+4m8Wcz`!d<}8fstk+OAHqHm?7urBesHPPRza!}M707RVeN-d1@~IBvHD1l zsok!73y31&K{E75+qM6p%%R?j1|ukijuvv?`jy*oc`cy%n}Z233;IAl;GF9S|FdR! zL@lbqNU?@aPPugi86)S6&)%Mxz zthTy=r){^t{Wc)#@pin;zRP|Y6-&Cy$YEdQd`W--p}i}t22hqm4Hv^Y8q$4g1bXo^ zm34jx=je3_vlspfm*6vmwT6FFZG^hd&n`s?q1>!SPV%QHQ^63V4 zL7QyD-W5j)-!6qu>`|CD?3&t5V;vx5MxYK7QJ^wc@B|;h>+$Jfa{$~SxE0w*lHX>V zMm&&V72k)m*uZo48@*wFai>Bp{Wsb#Mph>Odl9LE1VJynIuV2v_5-7QHNgv>K43iI zbf3?vY}dttdCYzw%J+cvBkts}^Fye^hF1W|Kv)jS3lR;!<%Ymh9EszGJ35;=oLvn7iz4DgtO}0BQ#kGS%ROJk zF4WrMyc;sE-vnUAkPm76lb#5Dwh9&G6#@qJ8EET%MB>y4q>R*SF?DwzG~FOM)hP^9 zBz7XQ24}~fjbsYQ<^>274xdP5H2^T?E_e)bcGe_JC0M&4^%Pn8iCnVnX=MDx7LjJx zIRpc^51yCtEhdLx-|f#~HJzxO`2kII0K~Wh+|Nrf$XL2gegnXX_(cBT)|-&x9O*P* zxqA#{yR#4fI|Et%vI0e@sd3oSkP}VqfW4}Ws6V>Krpc95Vena$7M`Zm%I$RpA zbDAqoDZ>kbl@iXw1DLWf?B7R-gj@J-y$61pnDW2J5TJg@%^gi2oqY&)3uu@!zQJ7N z$7FVc{y6z6AuR#3k;EhUwQ4XCG0zwcF+r^WIYSc|8qq1mg^72%0Am6KswA|eniS^v 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�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

R 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 literal 0 HcmV?d00001 diff --git a/androidApp/src/androidMain/res/font/inter_medium.otf b/androidApp/src/androidMain/res/font/inter_medium.otf new file mode 100644 index 0000000000000000000000000000000000000000..8108180e92360cad0c7792945fe072c69f2087b5 GIT binary patch literal 627988 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Ƀ#x3hE4cehXsVcXIMb^`rKi36@BTq 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

0MI}eT0qZbBuKGwh@Bv35Z;Nw^;=G7sT&z#QU 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Ⓢ(E6l9r0YQILwG^jFM0 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 literal 0 HcmV?d00001 diff --git a/androidApp/src/androidMain/res/font/inter_regular.otf b/androidApp/src/androidMain/res/font/inter_regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..13c3ec48572e2247472fb60bf2b495654f2c2879 GIT binary patch literal 609600 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

  • ZqgOdP)zPaRy~fdB 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

    xdK|Q0NoUzn*wxGfNl!V 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 literal 0 HcmV?d00001 diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayoutTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayoutTest.kt index e4a897a0b3..4080984659 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayoutTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/layout/ResponsiveControlLayoutTest.kt @@ -2,8 +2,6 @@ package org.siloserver.silo.android.ui.layout import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue class ResponsiveControlLayoutTest { @Test @@ -29,11 +27,4 @@ class ResponsiveControlLayoutTest { resolveAudiobookTransportLayout(availableWidthDp = 300f, hasChapters = true), ) } - - @Test - fun toolbarCompactsWhenActionsWouldEraseTheTitle() { - assertTrue(useCompactPlayerToolbar(availableWidthDp = 328f, trailingActionCount = 5)) - assertFalse(useCompactPlayerToolbar(availableWidthDp = 800f, trailingActionCount = 5)) - assertTrue(useCompactPlayerToolbar(availableWidthDp = 328f, trailingActionCount = 3)) - } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAnchoredSelectorMenu.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAnchoredSelectorMenu.kt index 43361ee626..f1bb70146c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAnchoredSelectorMenu.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvAnchoredSelectorMenu.kt @@ -27,9 +27,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -73,6 +73,7 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.siloserver.silo.tv.ui.theme.SiloOnSurface +import org.siloserver.silo.tv.ui.screens.player.TvPlayerType // --------------------------------------------------------------------------- // Anchored selector popover — Compose-for-TV port of the silo-apple tvOS @@ -307,9 +308,9 @@ internal fun TvAnchoredSelectorMenu( // The Material3 DropdownMenu is kept only as the anchored popup host // (positioning under the trigger, focus capture, dismiss-on-Back); its // own surface is made transparent and the content draws the same - // Skyline glass panel, dim uppercase header, inverted-capsule rows and - // hint footer as the top-bar cascade / For You selector, so every - // dropdown in the app reads as one component. + // opaque Skyline panel as the top-bar cascade / For You selector, with + // an eyebrow header and two-line rows, so every dropdown in the app + // reads as one component. No hint footer: TV viewers know the remote. DropdownMenu( expanded = expanded, onDismissRequest = { @@ -347,9 +348,9 @@ internal fun TvAnchoredSelectorMenu( // it the handler below is never called and the d-pad falls // straight through to Compose's own focus search. modifier = Modifier - .widthIn(min = CascadeLibraryColumnWidth, max = TvCascadeSelectorMaxPanelWidth) - .tvSkylinePanelChrome() - .padding(CascadePanelPadding) + .widthIn(min = SelectorMenuMinWidth, max = SelectorMenuMaxWidth) + .tvSkylinePanelChrome(corner = 20.dp) + .padding(start = 10.dp, end = 10.dp, top = 14.dp, bottom = 10.dp) .focusGroup() .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false @@ -370,10 +371,15 @@ internal fun TvAnchoredSelectorMenu( true }, ) { - CascadePanelHeader(label.uppercase()) - // The rows scroll inside a capped list while the header and - // footer stay pinned — a long subtitle list would otherwise - // grow the panel past the bottom of the screen. + Text( + text = label.uppercase(), + style = TvPlayerType.Eyebrow, + maxLines = 1, + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 6.dp), + ) + // The rows scroll inside a capped list while the header stays + // pinned — a long subtitle list would otherwise grow the panel + // past the bottom of the screen. Box { Column( modifier = Modifier @@ -433,7 +439,6 @@ internal fun TvAnchoredSelectorMenu( modifier = Modifier.align(Alignment.BottomCenter), ) } - CascadePanelFooter(caption = "Press selects · Back closes") } } } @@ -520,15 +525,17 @@ private fun ConnectedSelectorTrigger( } private val SelectorMenuGap = 6.dp +private val SelectorMenuMinWidth = 300.dp +private val SelectorMenuMaxWidth = 400.dp -/** Six rows of options; anything longer scrolls within the panel. */ -private val SelectorMenuMaxListHeight = 230.dp +/** Five two-line rows of options; anything longer scrolls within the panel. */ +private val SelectorMenuMaxListHeight = 270.dp private val SelectorMenuScrollEdgeHeight = 26.dp /** * Fades the rows out toward whichever edge still has more of them, by masking * the list's own pixels (DstIn) rather than painting a colour over it — a - * painted fade can never quite match the panel's translucent gradient and + * painted fade can never quite match the panel's vertical gradient and * shows up as a band. */ private fun Modifier.selectorMenuEdgeFade(fadeTop: Boolean, fadeBottom: Boolean): Modifier { @@ -577,11 +584,11 @@ private fun SelectorMenuScrollEdge(visible: Boolean, top: Boolean, modifier: Mod } /** - * One option row, drawn with the cascade's row chrome (see `CascadeRowChrome`): - * a leading check slot (kept even when unselected so titles stay aligned, the - * way the cascade's leading icon does), the title in semibold and the detail - * dimmed, inverting to a solid [SiloOnSurface] capsule on focus. Disabled rows - * are dimmed and skipped by focus. + * One option row: a leading check slot (kept even when unselected so titles + * stay aligned), the title, and the detail on its own line so it never + * competes with the option name. Focus inverts to a Paper capsule; the + * current choice keeps a soft fill. Disabled rows are dimmed and skipped by + * focus. */ @Composable private fun SelectorMenuRow( @@ -592,11 +599,10 @@ private fun SelectorMenuRow( modifier: Modifier = Modifier, ) { val visual = tvSelectorRowVisualState(focused, option.selected, option.enabled) - val shape = RoundedCornerShape(CascadeRowCornerRadius) + val shape = RoundedCornerShape(10.dp) Row( modifier = modifier .fillMaxWidth() - .padding(vertical = 2.dp) .clip(shape) .background(visual.container) .clickable( @@ -606,34 +612,37 @@ private fun SelectorMenuRow( onClick = onClick, ) .semantics { this.selected = option.selected } - .padding(horizontal = CascadeRowPaddingHorizontal, vertical = CascadeRowPaddingVertical), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.Top, ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = if (option.selected) visual.content else Color.Transparent, - modifier = Modifier.size(CascadeRowIconSize), - ) - Text( - text = option.title, - color = visual.content, - fontWeight = FontWeight.SemiBold, - fontSize = CascadeRowTextSize, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (option.detail.isNotBlank()) { + Box(modifier = Modifier.width(28.dp).padding(top = 1.dp)) { + if (option.selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = visual.content, + modifier = Modifier.size(19.dp), + ) + } + } + Column(modifier = Modifier.weight(1f)) { Text( - text = option.detail, - color = visual.content.copy(alpha = 0.6f), - fontWeight = FontWeight.Medium, - fontSize = CascadeRowTextSize, + text = option.title, + style = TvPlayerType.Row, + color = visual.content, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), ) + if (option.detail.isNotBlank()) { + Text( + text = option.detail, + style = TvPlayerType.RowDetail, + color = visual.content.copy(alpha = 0.6f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvDialog.kt new file mode 100644 index 0000000000..211cc92c54 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvDialog.kt @@ -0,0 +1,238 @@ +package org.siloserver.silo.tv.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.Text +import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.focus.tvModalFocusBoundary +import org.siloserver.silo.tv.ui.screens.player.TvPlayerChrome +import org.siloserver.silo.tv.ui.screens.player.TvPlayerType + +/** How a dialog button reads: the safe way out, the recommended action, or a destructive one. */ +enum class TvDialogActionStyle { Default, Primary, Destructive } + +data class TvDialogAction( + val label: String, + val onClick: () -> Unit, + val style: TvDialogActionStyle = TvDialogActionStyle.Default, +) + +/** The one TV dialog surface: an opaque card with a hairline over a deep scrim. */ +internal object TvDialogDefaults { + val Scrim = Color.Black.copy(alpha = 0.78f) + val Shape: Shape = RoundedCornerShape(22.dp) + val Width = 470.dp +} + +/** The card every TV dialog and content modal is drawn on. */ +internal fun Modifier.tvDialogSurface(shape: Shape = TvDialogDefaults.Shape): Modifier = this + .shadow(elevation = 30.dp, shape = shape, ambientColor = Color.Black, spotColor = Color.Black) + .clip(shape) + .background(TvPlayerChrome.Card) + .border(1.dp, TvPlayerChrome.PanelStroke, shape) + +/** + * The one TV confirmation and alert: a title, what happens, and a row of + * buttons in the order given. Focus starts on the first [TvDialogActionStyle.Default] + * action — the safe way out — so a stray Select never runs the destructive + * one; pass [initialFocusIndex] to choose another. + * + * Drawn in its own window. Callers already inside a Popup or an in-window + * overlay use [TvDialogCard] directly so they keep their own window. + */ +@Composable +fun TvDialog( + title: String, + onDismissRequest: () -> Unit, + actions: List, + modifier: Modifier = Modifier, + message: String? = null, + initialFocusIndex: Int = defaultTvDialogFocusIndex(actions), + width: Dp = TvDialogDefaults.Width, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + BackHandler(onBack = onDismissRequest) + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(TvDialogDefaults.Scrim), + contentAlignment = Alignment.Center, + ) { + TvDialogCard( + title = title, + actions = actions, + modifier = modifier, + message = message, + initialFocusIndex = initialFocusIndex, + width = width, + content = content, + ) + } + } +} + +/** The safe way out: the first Default-style action, else the first action. */ +fun defaultTvDialogFocusIndex(actions: List): Int = + actions.indexOfFirst { it.style == TvDialogActionStyle.Default }.coerceAtLeast(0) + +/** + * [TvDialog]'s card without a window: title, message, optional [content], + * and the buttons, with focus contained and placed on [initialFocusIndex]. + */ +@Composable +fun TvDialogCard( + title: String, + actions: List, + modifier: Modifier = Modifier, + message: String? = null, + initialFocusIndex: Int = defaultTvDialogFocusIndex(actions), + width: Dp = TvDialogDefaults.Width, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + val initialFocus = remember { FocusRequester() } + var focusedIndex by remember { mutableIntStateOf(-1) } + // A dialog window or focusable popup hands focus to its first control on + // its own, so ask for the intended button until that button holds it. + LaunchedEffect(initialFocusIndex) { + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = initialFocus::requestFocus, + isFocused = { focusedIndex == initialFocusIndex }, + ) + } + + Column( + modifier = modifier + .width(width) + .tvDialogSurface() + .tvModalFocusBoundary() + .padding(start = 32.dp, end = 32.dp, top = 30.dp, bottom = 28.dp), + ) { + Text(text = title, style = TvPlayerType.ConfirmTitle) + if (!message.isNullOrBlank()) { + Text(text = message, style = TvPlayerType.Body, modifier = Modifier.padding(top = 8.dp)) + } + if (content != null) { + Column( + modifier = Modifier.padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } + if (actions.isNotEmpty()) { + Row( + modifier = Modifier.padding(top = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + actions.forEachIndexed { index, action -> + TvDialogButton( + label = action.label, + onClick = action.onClick, + style = action.style, + modifier = Modifier + .then(if (index == initialFocusIndex) Modifier.focusRequester(initialFocus) else Modifier) + .onFocusChanged { state -> + if (state.isFocused) { + focusedIndex = index + } else if (focusedIndex == index) { + focusedIndex = -1 + } + }, + ) + } + } + } + } +} + +/** + * A dialog button. At rest it is a quiet capsule (destructive: bright red on + * a red tint, legible on the dark card); focused, it fills Paper, or red for + * a destructive action, so the button you are on is never in doubt. + */ +@Composable +fun TvDialogButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: TvDialogActionStyle = TvDialogActionStyle.Default, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val destructive = style == TvDialogActionStyle.Destructive + val (fill, textColor) = when { + isFocused && destructive -> TvPlayerChrome.Stop to Color.White + isFocused -> TvPlayerChrome.Paper to TvPlayerChrome.Ink + destructive -> TvPlayerChrome.StopRest to TvPlayerChrome.StopText + else -> Color.White.copy(alpha = 0.08f) to TvPlayerChrome.Paper + } + val shape = RoundedCornerShape(22.dp) + Box( + modifier = modifier + .height(44.dp) + .then( + if (isFocused) { + Modifier.shadow(elevation = 12.dp, shape = shape, ambientColor = Color.Black, spotColor = Color.Black) + } else { + Modifier + }, + ) + .clip(shape) + .background(fill) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ) + .alpha(if (enabled) 1f else 0.4f) + .padding(horizontal = 26.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = label, style = TvPlayerType.Button, color = textColor, maxLines = 1) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt index 8b675748fc..2a9fa587db 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvOptionDialog.kt @@ -1,15 +1,10 @@ package org.siloserver.silo.tv.ui.components -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.heightIn @@ -22,40 +17,34 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector 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 -import androidx.tv.material3.Glow import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.siloserver.silo.tv.ui.theme.DarkBackground -import org.siloserver.silo.tv.ui.theme.FocusedContainer -import org.siloserver.silo.tv.ui.theme.FocusedContent +import org.siloserver.silo.tv.ui.screens.player.TvPlayerChrome +import org.siloserver.silo.tv.ui.screens.player.TvPlayerType data class TvDialogOption( val key: String, @@ -63,6 +52,11 @@ data class TvDialogOption( val subtitle: String? = null, val selected: Boolean = false, val enabled: Boolean = true, + /** + * Marks an action ("Mark as watched") rather than a choice. It takes the + * leading slot where a choice shows its check: a check means "selected". + */ + val icon: ImageVector? = null, val onClick: () -> Unit, ) @@ -110,37 +104,26 @@ fun TvOptionDialog( .padding(start = 36.dp, top = 60.dp, end = 36.dp, bottom = 42.dp), contentAlignment = Alignment.Center, ) { - val panelShape = RoundedCornerShape(14.dp) + // Opaque: a translucent panel let the page behind it (the + // synopsis, a Continue Watching card) read through the header. Column( modifier = Modifier - .width(320.dp) - .background( - color = DarkBackground.copy(alpha = 0.68f), - shape = panelShape, - ) - .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) - .padding(horizontal = 12.dp, vertical = 12.dp) + .width(380.dp) + .tvDialogSurface(RoundedCornerShape(20.dp)) + .padding(start = 10.dp, end = 10.dp, top = 14.dp, bottom = 10.dp) .then(rememberTvDialogInitialFocus(firstRowFocus)), - verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = title.uppercase(), - style = MaterialTheme.typography.labelMedium.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - letterSpacing = 0.8.sp, - fontWeight = FontWeight.Bold, - ), - color = Color.White.copy(alpha = 0.58f), - modifier = Modifier.padding(horizontal = 6.dp), + style = TvPlayerType.Eyebrow, + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 6.dp), ) LazyColumn( state = listState, modifier = Modifier .fillMaxWidth() - .heightIn(max = 240.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + .heightIn(max = 300.dp), ) { items( options, @@ -152,6 +135,7 @@ fun TvOptionDialog( subtitle = option.subtitle, selected = option.selected, enabled = option.enabled, + icon = option.icon, onClick = option.onClick, modifier = if (option.key == focusedKey) { Modifier.focusRequester(firstRowFocus) @@ -185,13 +169,18 @@ private fun TvOptionDialogRow( subtitle: String?, selected: Boolean, enabled: Boolean, + icon: ImageVector?, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val shape = RoundedCornerShape(12.dp) - val restContent = if (enabled) Color.White else Color.White.copy(alpha = 0.42f) + val shape = RoundedCornerShape(10.dp) + val content = when { + isFocused -> TvPlayerChrome.Ink + enabled -> TvPlayerChrome.Paper + else -> TvPlayerChrome.Faint + } Surface( onClick = onClick, @@ -199,93 +188,52 @@ private fun TvOptionDialogRow( interactionSource = interactionSource, shape = ClickableSurfaceDefaults.shape(shape = shape), colors = ClickableSurfaceDefaults.colors( - containerColor = if (selected) Color.White.copy(alpha = 0.13f) else Color.White.copy(alpha = 0.04f), - contentColor = restContent, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - disabledContainerColor = Color.White.copy(alpha = 0.03f), - disabledContentColor = Color.White.copy(alpha = 0.38f), + // The current choice keeps a soft fill at rest; focus inverts to Paper. + containerColor = if (selected) TvPlayerChrome.Selected else Color.Transparent, + contentColor = content, + focusedContainerColor = TvPlayerChrome.Paper, + focusedContentColor = TvPlayerChrome.Ink, + pressedContainerColor = TvPlayerChrome.Paper, + pressedContentColor = TvPlayerChrome.Ink, + disabledContainerColor = Color.Transparent, + disabledContentColor = TvPlayerChrome.Faint, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), - border = ClickableSurfaceDefaults.border( - border = Border( - border = BorderStroke( - width = if (selected) 1.dp else 0.dp, - color = Color.White.copy(alpha = if (selected) 0.22f else 0f), - ), - shape = shape, - ), - focusedBorder = Border( - border = BorderStroke(1.5.dp, DarkBackground.copy(alpha = 0.82f)), - shape = shape, - ), - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = Color.White.copy(alpha = 0.14f), - elevation = 10.dp, - ), - ), modifier = modifier .fillMaxWidth() - .heightIn(min = 40.dp) - .semantics { this.selected = selected } - .then( - if (isFocused) { - Modifier.border(1.5.dp, Color.White.copy(alpha = 0.98f), shape) - } else { - Modifier - }, - ), + .heightIn(min = 38.dp) + .semantics { this.selected = selected }, ) { Row( modifier = Modifier .fillMaxWidth() - .alpha(if (enabled) 1f else 0.72f) - .padding(horizontal = 12.dp, vertical = 7.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.Top, ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 15.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.SemiBold, - ), - color = if (isFocused) FocusedContent else restContent, - ) + // Leading slot, kept even when empty so titles line up: a check + // for the current choice, an icon for an action. + Box(modifier = Modifier.width(28.dp), contentAlignment = Alignment.CenterStart) { + val leadingIcon = icon ?: if (selected) Icons.Rounded.Check else null + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = content, + modifier = Modifier.size(19.dp), + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = TvPlayerType.Row, color = content) subtitle?.takeIf { it.isNotBlank() }?.let { Text( text = it, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - ), - color = if (isFocused) { - FocusedContent.copy(alpha = 0.70f) - } else { - Color.White.copy(alpha = if (enabled) 0.56f else 0.32f) - }, + style = TvPlayerType.RowDetail, + color = if (isFocused) TvPlayerChrome.InkMuted else TvPlayerChrome.Graphite, + modifier = Modifier.padding(top = 2.dp), ) } } - if (selected) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = if (isFocused) FocusedContent else Color.White.copy(alpha = 0.82f), - modifier = Modifier.size(18.dp), - ) - } else { - Spacer(modifier = Modifier.size(18.dp)) - } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSelectorRowVisualState.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSelectorRowVisualState.kt index 9607cd1d0c..89ab9b868f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSelectorRowVisualState.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSelectorRowVisualState.kt @@ -30,7 +30,7 @@ internal fun tvSelectorRowVisualState( FocusedContent.copy(alpha = 0.22f), ) selected -> TvSelectorRowVisualState( - SiloOnSurface.copy(alpha = 0.14f), + SiloOnSurface.copy(alpha = 0.075f), SiloOnSurface, SiloOnSurface.copy(alpha = 0.28f), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylinePanelChrome.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylinePanelChrome.kt index 720ad301db..0b6a1aaaeb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylinePanelChrome.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylinePanelChrome.kt @@ -16,13 +16,14 @@ import androidx.compose.ui.unit.dp * selector + flyout (§5.3) and the Stage-5 profile dropdown (§5.8) — so they * read as one family. Port of tvOS `TVSkylinePanelChrome` (`TVTopMenuBar.swift`). * - * A near-opaque vertical tint (lighter at the top, as if lit from above) + * An opaque vertical tint (lighter at the top, as if lit from above) * finished with a gradient hairline that brightens along the top lip and a * drop shadow, so the panel floats over the page on its own depth rather than * needing a page scrim to darken everything behind it. * - * Compose has no `.regularMaterial` blur; the dark fill is opaque enough that - * the absence of a frost blur is not noticeable over the dimmed page. + * Opaque, not near-opaque: Compose has no `.regularMaterial` blur, and + * without one a 92% fill let the synopsis and Continue Watching cards read + * through the menu as a second layer of text. */ fun Modifier.tvSkylinePanelChrome(corner: Dp = 11.dp): Modifier { val shape = RoundedCornerShape(corner) @@ -45,8 +46,8 @@ fun Modifier.tvSkylinePanelChrome(corner: Dp = 11.dp): Modifier { .background( Brush.verticalGradient( colors = listOf( - Color(0xFF23252C).copy(alpha = 0.92f), - Color(0xFF141519).copy(alpha = 0.95f), + Color(0xFF1D1F24), + Color(0xFF17181B), ), ), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvExpandableSynopsis.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvExpandableSynopsis.kt index 74809b1a28..fc9dd05c6e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvExpandableSynopsis.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvExpandableSynopsis.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -50,6 +49,8 @@ import androidx.compose.ui.window.PopupProperties import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.launch +import org.siloserver.silo.tv.ui.components.TvDialogDefaults +import org.siloserver.silo.tv.ui.components.tvDialogSurface import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved @@ -172,20 +173,14 @@ private fun TvSynopsisDialog( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.66f)), + .background(TvDialogDefaults.Scrim), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .fillMaxWidth(0.62f) .fillMaxHeight(0.72f) - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)) - .border( - 0.6.dp, - Color.White.copy(alpha = 0.14f), - RoundedCornerShape(18.dp), - ) + .tvDialogSurface() .onPreviewKeyEvent { event -> when { event.type == KeyEventType.KeyUp && diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index 263590a5b6..04714fd6bd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -39,6 +39,14 @@ import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.HeartBroken +import androidx.compose.material.icons.rounded.RemoveDone +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.Tv +import androidx.compose.material.icons.rounded.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -1836,6 +1844,7 @@ private fun HeroActionRow( TvDialogOption( key = "shuffle-series", title = "Shuffle Series", + icon = Icons.Rounded.Shuffle, onClick = { moreOpen = false onShuffleSeries() @@ -1848,6 +1857,7 @@ private fun HeroActionRow( TvDialogOption( key = "shuffle-season", title = "Shuffle ${tvSeasonPickerLabel(season)}", + icon = Icons.Rounded.Shuffle, onClick = { moreOpen = false onShuffleSeason(season) @@ -1859,7 +1869,8 @@ private fun HeroActionRow( TvDialogOption( key = "favorite", title = if (state.isFavorite) "Remove from Favorites" else "Add to Favorites", - selected = state.isFavorite, + // Actions take icons, not checks: a check means "selected". + icon = if (state.isFavorite) Icons.Rounded.HeartBroken else Icons.Rounded.FavoriteBorder, onClick = { moreOpen = false viewModel.onToggleFavorite() @@ -1870,7 +1881,7 @@ private fun HeroActionRow( TvDialogOption( key = "watched", title = if (state.isWatched) watchedUnmarkLabel(detail) else watchedMarkLabel(detail), - selected = state.isWatched, + icon = if (state.isWatched) Icons.Rounded.RemoveDone else Icons.Rounded.CheckCircleOutline, onClick = { moreOpen = false viewModel.onToggleWatched() @@ -1884,7 +1895,7 @@ private fun HeroActionRow( key = "season-watched", title = "Mark ${tvSeasonPickerLabel(season)} " + if (seasonWatched) "Unwatched" else "Watched", - selected = seasonWatched, + icon = if (seasonWatched) Icons.Rounded.RemoveDone else Icons.Rounded.CheckCircleOutline, onClick = { moreOpen = false viewModel.onSetSeasonWatched(season, !seasonWatched) @@ -1898,6 +1909,7 @@ private fun HeroActionRow( key = "watch-party", title = option.title, subtitle = option.subtitle, + icon = Icons.Rounded.Groups, onClick = { moreOpen = false option.onSelect() @@ -1916,6 +1928,7 @@ private fun HeroActionRow( key = "season-$season", title = "Go to Season $season", subtitle = detail.seriesTitle, + icon = Icons.Rounded.VideoLibrary, onClick = { moreOpen = false onSeasonClick(seriesId, season) @@ -1929,6 +1942,7 @@ private fun HeroActionRow( key = "series", title = "Go to Series", subtitle = detail.seriesTitle, + icon = Icons.Rounded.Tv, onClick = { moreOpen = false onSeriesClick(seriesId) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvMediaInfoDialog.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvMediaInfoDialog.kt index 7be02678a0..284a4adb09 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvMediaInfoDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvMediaInfoDialog.kt @@ -46,6 +46,7 @@ import org.siloserver.silo.playback.subtitleLabelIndicatesHearingImpaired import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus import java.util.Locale import kotlinx.coroutines.launch +import org.siloserver.silo.tv.ui.screens.player.TvPlayerChrome data class TvMediaInfoTrackSummary( val primary: String, @@ -111,8 +112,8 @@ fun TvMediaInfoDialog( .fillMaxWidth(0.36f) .fillMaxHeight(0.88f) .clip(RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.96f)) - .border(0.6.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)) + .background(TvPlayerChrome.Card) + .border(1.dp, TvPlayerChrome.PanelStroke, RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)) .onPreviewKeyEvent { ev -> when { ev.type == KeyEventType.KeyUp && (ev.key == Key.Back || ev.key == Key.Escape) -> { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/people/TvPersonDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/people/TvPersonDetailScreen.kt index 3943ec17ba..4cfb45ccdf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/people/TvPersonDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/people/TvPersonDetailScreen.kt @@ -73,8 +73,10 @@ import org.siloserver.silo.model.catalog.Person import org.siloserver.silo.model.catalog.personMetadataBadges import org.siloserver.silo.tv.ui.components.TvCatalogEmptyState import org.siloserver.silo.tv.ui.components.TvCatalogGrid +import org.siloserver.silo.tv.ui.components.TvDialogDefaults import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvLoadingScreen +import org.siloserver.silo.tv.ui.components.tvDialogSurface import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated import org.siloserver.silo.tv.ui.theme.Spacing import org.siloserver.silo.tv.ui.theme.tvPresetGridColumns @@ -468,16 +470,14 @@ private fun TvPersonBioDialog( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.62f)), + .background(TvDialogDefaults.Scrim), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .fillMaxWidth(0.56f) .fillMaxHeight(0.76f) - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)) - .border(0.6.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(18.dp)) + .tvDialogSurface() .onPreviewKeyEvent { ev -> when { ev.type == KeyEventType.KeyUp && diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerChrome.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerChrome.kt new file mode 100644 index 0000000000..db9ffc91ed --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerChrome.kt @@ -0,0 +1,86 @@ +package org.siloserver.silo.tv.ui.screens.player + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import org.siloserver.silo.tv.ui.theme.InterFamily + +/** + * The TV player's visual language, shared with the phone player: smoked + * panels over the picture, Paper for whatever has focus, Inter with tabular + * figures for anything that counts time. Focus is an inversion to Paper with + * Ink content, never a scale, as on tvOS. + */ +internal object TvPlayerChrome { + val Paper = Color(0xFFEDEDED) + val Ink = Color(0xFF0B0B0C) + val Graphite = Paper.copy(alpha = 0.60f) + val Faint = Paper.copy(alpha = 0.38f) + val InkMuted = Ink.copy(alpha = 0.58f) + + /** Panels and menus over the picture. Opaque enough that nothing reads through. */ + val Smoke = Color(0xFF131417).copy(alpha = 0.955f) + + /** Dialog and anchored-menu cards: fully opaque. */ + val Card = Color(0xFF17181B) + val PanelStroke = Color.White.copy(alpha = 0.10f) + + /** Transport buttons at rest: a dark disc and a ring, so they read on letterbox bars. */ + val ButtonRest = Color.Black.copy(alpha = 0.38f) + val ButtonRing = Color.White.copy(alpha = 0.24f) + + /** A selected row that does not have focus. */ + val Selected = Color.White.copy(alpha = 0.075f) + val Chip = Color.White.copy(alpha = 0.10f) + + /** Destructive: bright red text at rest, a red fill on focus. */ + val Stop = Color(0xFFFF453A) + val StopText = Color(0xFFFF6961) + val StopRest = Stop.copy(alpha = 0.12f) + + val SideInset = 48.dp + val BottomInset = 26.dp +} + +/** + * Text that is read meets the TV app's 14sp floor. The two exceptions are + * chrome at tvOS half scale, approved in TvTypographyReadabilityTest: the + * uppercase eyebrow above titles and sections, and the SDH/FORCED chips. + */ +internal object TvPlayerType { + private val base = TextStyle(fontFamily = InterFamily, color = TvPlayerChrome.Paper) + + val Eyebrow = base.copy( + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.14.em, + color = TvPlayerChrome.Graphite, + ) + val Title = base.copy(fontSize = 27.sp, lineHeight = 33.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.018).em) + val Time = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum") + val ButtonLabel = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold) + val ButtonValue = ButtonLabel.copy(fontWeight = FontWeight.Medium) + val Tab = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.SemiBold) + val Row = base.copy(fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium) + val RowDetail = base.copy(fontSize = 14.sp, lineHeight = 18.sp, fontWeight = FontWeight.Medium, color = TvPlayerChrome.Graphite) + /** A row's value. Not tabular: Inter's tnum also widens hyphens ("X - Large"). */ + val Value = RowDetail + + /** Columns of times and counts that must line up, such as chapter starts. */ + val Figures = RowDetail.copy(fontFeatureSettings = "tnum") + val Chip = base.copy( + fontSize = 9.sp, + lineHeight = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.07.em, + color = TvPlayerChrome.Paper.copy(alpha = 0.78f), + ) + val DialogTitle = base.copy(fontSize = 21.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.01).em) + val ConfirmTitle = base.copy(fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.015).em) + val Body = base.copy(fontSize = 15.sp, lineHeight = 22.sp, color = TvPlayerChrome.Graphite) + val Button = base.copy(fontSize = 15.sp, lineHeight = 19.sp, fontWeight = FontWeight.SemiBold) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt index 34c80362d9..c37dea9c49 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt @@ -1,6 +1,5 @@ package org.siloserver.silo.tv.ui.screens.player -import org.siloserver.silo.playback.SubtitleTimingActions import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.animation.animateContentSize @@ -11,6 +10,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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.fillMaxSize @@ -33,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState @@ -40,10 +42,14 @@ 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.Check -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue @@ -52,87 +58,103 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment -import androidx.compose.ui.res.stringResource import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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 androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.launch import org.siloserver.silo.common.player.PlayerStatsSnapshot -import org.siloserver.silo.domain.player.IntroSkipMode -import org.siloserver.silo.tv.R import org.siloserver.silo.common.player.SleepTimerState +import org.siloserver.silo.domain.player.IntroSkipMode import org.siloserver.silo.model.catalog.VersionChapter import org.siloserver.silo.model.playback.PlaybackExecutionPlan import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.model.playback.SubtitleIdentity import org.siloserver.silo.model.settings.SubtitleAppearance import org.siloserver.silo.model.settings.SubtitleBackgroundStylePreset +import org.siloserver.silo.playback.SubtitleTimingActions +import org.siloserver.silo.tv.R +import org.siloserver.silo.tv.ui.components.tvDialogSurface import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvFocusLog import org.siloserver.silo.tv.ui.focus.TvFrameRelocationMaxAttempts import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated - -// Geometry follows tvOS TVPlayerInfoHUD at the 0.5x point→dp map, adjusted for -// Android's larger body type: the card is WIDE and SHORT (tvOS 1100×380pt on a -// 1920×1080 canvas — 57% × 35%), with the tab rail floating above it on the -// video rather than inside it. The previous card was 51% × 67% as rendered — a -// portrait slab on a landscape screen, sitting on faces. -private val HudWidthFraction = 0.74f -private val HudMaxWidth = 720.dp + +// Geometry follows the options mockup on a 960×540dp screen: a capsule tab +// rail 24dp from the top, and a smoked panel 10dp under it, 780dp (81%) wide +// with a 22dp radius. The panel stays wide and short, as tvOS TVPlayerInfoHUD +// does, so it sits above faces rather than on them. +private val HudWidthFraction = 0.8125f +private val HudMaxWidth = 780.dp /** - * Card height wraps the pane between these bounds. A fixed 360dp height left - * Audio (two rows) and Stats (nine) as the same 60%-empty slab; wrapping lets - * a two-row pane be a two-row card. The max keeps long panes scrolling inside - * the card rather than growing it down over the transport. + * Card height wraps the pane between these bounds. A fixed height left Audio + * (two rows) and Stats (nine) as the same mostly-empty slab; wrapping lets a + * two-row pane be a two-row card. The max keeps long panes scrolling inside + * the card rather than growing it down over the picture. */ private val HudCardMinHeight = 156.dp -private val HudCardMaxHeight = 300.dp -private val HudPanelCorner = 16.dp -private val HudPanelPadding = 20.dp -private val HudTabCardGap = 12.dp -private val HudTabHeight = 38.dp -private val HudPaneBottomPadding = 4.dp -private val HudPaneColumnGap = 36.dp -private val HudTitleTextSize = 21.sp -private val HudTitleLineHeight = 25.sp -private val HudBodyTextSize = 16.sp -private val HudBodyLineHeight = 20.sp -private val HudMetaTextSize = 15.sp -private val HudMetaLineHeight = 19.sp -private val HudChipTextSize = 14.sp -private val HudChipLineHeight = 18.sp -private val HudTabTextSize = 16.sp -private val HudTabLineHeight = 20.sp +private val HudCardMaxHeight = 336.dp +private val HudPanelCorner = 22.dp +private val HudPanelPadding = PaddingValues(start = 22.dp, end = 22.dp, top = 20.dp, bottom = 18.dp) +private val HudRailTop = 24.dp +private val HudTabCardGap = 10.dp +private val HudTabHeight = 32.dp +private val HudRowHeight = 34.dp +private val HudRowCorner = 10.dp +private val HudRowPadding = 10.dp +private val HudCheckColumn = 20.dp +private val HudPaneColumnGap = 26.dp +private val HudSectionGap = 12.dp + +/** The left column of a two-column pane takes 330 of 736dp, as in the mockup. */ +private const val HudLeftColumnWeight = 33f +private const val HudRightColumnWeight = 38f /** * Lets a [HudFocusedSettingRow] register its own focus requester as the row that @@ -327,52 +349,57 @@ internal fun TvPlayerHud( } } - // Keep an open subtitle picker synchronized with reducer state while - // retaining the same stable focused row through Applying -> committed, - // and with each track's sync status as a sync runs. - val subtitleSyncStatuses = subtitlePresentation.rows.map { row -> subtitleSync.statusFor(row.identity) } - LaunchedEffect(subtitlePresentation, subtitleSyncStatuses, activePicker?.title) { - val current = activePicker - if (current?.title == "Subtitle Track") { - val checkedRow = subtitlePresentation.rows.firstOrNull { it.checked } - val focusedRow = subtitlePresentation.rows.firstOrNull { it.focused } - activePicker = current.copy( - options = subtitlePresentation.rows.mapIndexed { index, row -> - HudPickerOption( - id = row.stableId, - label = if (row.applying) "${row.label} · Applying…" else row.label, - detail = subtitleSyncStatuses.getOrNull(index), - ) - }, - selectedId = checkedRow?.stableId - ?: subtitlePresentation.rows.firstOrNull()?.stableId.orEmpty(), - focusedId = focusedRow?.stableId - ?: current.focusedId, - ) - } + // The Subtitles pane's right column drills into Appearance and Timing in + // place. Owned here so Back can step out of a page before it closes the + // HUD, and reset when the viewer moves to another tab. + var subtitlePage by remember { mutableStateOf(HudSubtitlePage.Root) } + LaunchedEffect(selectedTab) { + if (selectedTab != HudTab.Subtitles) subtitlePage = HudSubtitlePage.Root } - val presentPicker: (HudPickerPresentation) -> Unit = { activePicker = it } + // Every picker names the tab it came from above its title. + val presentPicker: (HudPickerPresentation) -> Unit = { picker -> + activePicker = if (picker.eyebrow == null) picker.copy(eyebrow = selectedTab.label) else picker + } val closePicker: () -> Unit = { activePicker = null } // Android 16 no longer dispatches KEYCODE_BACK to target-36 apps. Register - // the picker as the most specific callback; when it is closed, the player - // screen's callback remains responsible for dismissing the HUD itself. + // the picker and the subtitle page as the most specific callbacks; when + // both are closed, the player screen's callback dismisses the HUD itself. + BackHandler(enabled = activePicker == null && subtitlePage != HudSubtitlePage.Root) { + subtitlePage = HudSubtitlePage.Root + } BackHandler(enabled = activePicker != null) { closePicker() } - // Top-center: a floating tab rail over the video, and a card beneath it - // holding only the pane — the TVPlayerInfoHUD composition. No full-screen - // scrim; the picture stays visible. - // - // fillMaxWidth BEFORE widthIn. Chained the other way round, fillMaxWidth - // sees the already-capped max and takes its fraction of THAT: 0.72 × 680 = - // 490dp, which is what actually rendered — narrow enough to clip the tab - // rail ("Chap…") and cramp every two-column pane. + val pickerOpen = activePicker != null + val chromeAlpha by animateFloatAsState( + targetValue = if (pickerOpen) 0.3f else 1f, + animationSpec = tween(160), + label = "hudChromeAlpha", + ) + + // Full screen, so the scrim and a picker can use the whole picture. The + // scrim is light while the panel is up (the picture stays watchable and + // the rail reads over bright frames) and deepens behind a picker. Box( modifier = modifier + .fillMaxSize() + .drawBehind { + if (pickerOpen) { + drawRect(Color.Black.copy(alpha = 0.55f)) + } else { + drawRect(Color.Black.copy(alpha = 0.10f)) + drawRect( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.60f), + 1f to Color.Black.copy(alpha = 0.30f), + endY = 300.dp.toPx(), + tileMode = TileMode.Clamp, + ), + ) + } + } .onFocusChanged { hudHasFocus = it.hasFocus } - .fillMaxWidth(HudWidthFraction) - .widthIn(max = HudMaxWidth) .onPreviewKeyEvent { ev -> if (ev.key != Key.Back && ev.key != Key.Escape) return@onPreviewKeyEvent false TvFocusLog.d { "hud key BACK type=${ev.type} picker=${activePicker != null}" } @@ -390,11 +417,13 @@ internal fun TvPlayerHud( KeyEventType.KeyUp -> { // Pre-Android-16 remote and keyboard fallback. System // Back uses the callbacks above and on TvPlayerScreen. - if (activePicker != null) { - activePicker = null - } else { - TvFocusLog.d { "hud key BACK -> onDismiss" } - onDismiss() + when { + activePicker != null -> activePicker = null + subtitlePage != HudSubtitlePage.Root -> subtitlePage = HudSubtitlePage.Root + else -> { + TvFocusLog.d { "hud key BACK -> onDismiss" } + onDismiss() + } } true } @@ -403,19 +432,29 @@ internal fun TvPlayerHud( }, ) { CompositionLocalProvider(LocalHudPickerReturnFocus provides registerPickerReturnFocus) { + // fillMaxWidth BEFORE widthIn. Chained the other way round, fillMaxWidth + // sees the already-capped max and takes its fraction of THAT, which + // clipped the tab rail and cramped every two-column pane. Column( modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = if (activePicker != null) 0.28f else 1f }, + .align(Alignment.TopCenter) + .padding(top = HudRailTop) + .fillMaxWidth(HudWidthFraction) + .widthIn(max = HudMaxWidth) + .graphicsLayer { alpha = chromeAlpha }, verticalArrangement = Arrangement.spacedBy(HudTabCardGap), horizontalAlignment = Alignment.CenterHorizontally, ) { - // Floating tab rail. Centred like the tvOS HStack; the scroll is a - // safety net for very long localised labels — at this width the six - // English tabs fit with room. + // Floating capsule rail. The scroll is a safety net for very long + // localised labels; the six English tabs fit with room. Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clip(CircleShape) + .background(HudRailFill) + .border(1.dp, TvPlayerChrome.PanelStroke, CircleShape) + .padding(4.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(2.dp), ) { tabs.forEach { tab -> HudTabPill( @@ -435,6 +474,8 @@ internal fun TvPlayerHud( // The card: wraps its pane between the height bounds, so a // two-row Audio pane is a two-row card and a nine-row Stats pane // scrolls inside a full one. Shadow sits outside the clip. + val panelWindow = remember { HudPanelWindow() } + val panelShape = RoundedCornerShape(HudPanelCorner) Box( modifier = Modifier .fillMaxWidth() @@ -446,9 +487,8 @@ internal fun TvPlayerHud( // groups being entered/left, so both redirects apply to // every row regardless of which control was "nearest". .focusProperties { - // Down from a pill lands on the pane's FIRST row — the - // top-left control — not whichever swatch or row happens - // to sit under that pill. + // Down from a pill lands on the pane's entry row, not + // whichever swatch or row happens to sit under that pill. enter = { direction -> if (direction == FocusDirection.Down && paneEntryAvailable) { paneEntryFocus @@ -470,41 +510,51 @@ internal fun TvPlayerHud( } .focusGroup() .shadow( - elevation = 14.dp, - shape = RoundedCornerShape(HudPanelCorner), + elevation = 24.dp, + shape = panelShape, ambientColor = Color.Black.copy(alpha = 0.6f), spotColor = Color.Black.copy(alpha = 0.6f), ) - .clip(RoundedCornerShape(HudPanelCorner)) - // Near-opaque. The picture showing through the card read as - // "glass" but cost legibility over bright or busy frames — - // and this is a settings surface people squint at from the - // sofa. Keep the video visible AROUND the card, not through it. - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.96f)) - .border( - width = 0.5.dp, - color = Color.White.copy(alpha = 0.14f), - shape = RoundedCornerShape(HudPanelCorner), - ) + .clip(panelShape) + .onPlaced { panelWindow.panel = it } + // Smoked at 95.5%: the picture shows AROUND the card, not + // through it, because this is a settings surface people + // read from the sofa. The one exception is the subtitle + // sample, which cuts a window so it sits on the real frame. + .drawBehind { + val corner = CornerRadius(HudPanelCorner.toPx()) + val outline = Path().apply { addRoundRect(RoundRect(size.toRect(), corner)) } + val window = panelWindow.bounds + if (window == null) { + drawPath(outline, TvPlayerChrome.Smoke) + } else { + val hole = Path().apply { + addRoundRect(RoundRect(window, CornerRadius(HudPreviewCorner.toPx()))) + } + drawPath(Path.combine(PathOperation.Difference, outline, hole), TvPlayerChrome.Smoke) + } + } + .border(width = 1.dp, color = TvPlayerChrome.PanelStroke, shape = panelShape) .padding(HudPanelPadding), ) { + CompositionLocalProvider(LocalHudPanelWindow provides panelWindow) { when (selectedTab) { - HudTab.Info -> HudPaneViewport { - HudInfoPane( - title = title, - positionSec = positionSec, - durationSec = durationSec, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - stats = stats, - playbackPlan = playbackPlan, - subtitleLabel = subtitlePresentation.rows - .firstOrNull { row -> row.checked } - ?.label - ?: "Off", - chapters = chapters, - ) - } + // Info scrolls per column, so it must not sit inside the + // single-column viewport: nested vertical scrolls throw. + HudTab.Info -> HudInfoPane( + title = title, + positionSec = positionSec, + durationSec = durationSec, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + stats = stats, + playbackPlan = playbackPlan, + subtitleLabel = subtitlePresentation.rows + .firstOrNull { row -> row.checked } + ?.let { row -> tvSubtitleRowParts(row.label).title } + ?: "Off", + chapters = chapters, + ) HudTab.Stats -> HudPaneViewport { HudStatsPane(stats) } HudTab.Video -> HudVideoPane( videoQualities = videoQualities, @@ -558,6 +608,8 @@ internal fun TvPlayerHud( ) HudTab.Subtitles -> HudSubtitlesPane( presentation = subtitlePresentation, + page = subtitlePage, + onPageChange = { subtitlePage = it }, subtitleDelayMs = subtitleDelayMs, subtitleDelayEnabled = subtitleDelayEnabled, onSubtitleDelayChanged = onSubtitleDelayChanged, @@ -575,25 +627,26 @@ internal fun TvPlayerHud( enabled = activePicker == null, onPresentPicker = presentPicker, ) - HudTab.Chapters -> HudPaneViewport { - HudChaptersPane( - chapters = chapters, - onSelectChapter = onSelectChapter, - entryFocusRequester = paneEntryFocus, - ) - } + HudTab.Chapters -> HudChaptersPane( + chapters = chapters, + positionSec = positionSec, + durationSec = durationSec, + onSelectChapter = onSelectChapter, + entryFocusRequester = paneEntryFocus, + ) + } } } } } - // Centered modal picker dialog, drawn on top of the dimmed rail + card. - // matchParentSize, not fillMaxSize: the HUD box now wraps its content, - // so a fillMaxSize child would see an unbounded height and not stretch. + // Centered modal picker, drawn over the dimmed rail and card. val picker = activePicker if (picker != null) { Box( - modifier = Modifier.matchParentSize(), + modifier = Modifier + .fillMaxSize() + .padding(vertical = 24.dp), contentAlignment = Alignment.Center, ) { HudPickerDialog( @@ -605,6 +658,35 @@ internal fun TvPlayerHud( } } +/** Where the Subtitles pane's right column is: its root, or a drill-in page. */ +internal enum class HudSubtitlePage { Root, Appearance, Timing } + +/** The rail floats on the picture, so it carries its own smoked ground. */ +private val HudRailFill = TvPlayerChrome.Smoke.copy(alpha = 0.78f) +private val HudPreviewCorner = 12.dp + +/** + * The window the subtitle sample cuts in the panel, in panel coordinates. + * The panel draws its fill around it, so the sample sits on the live picture. + */ +private class HudPanelWindow { + var panel: LayoutCoordinates? = null + var bounds by mutableStateOf(null) + + fun report(source: LayoutCoordinates?) { + val panelCoordinates = panel + bounds = if (source == null || panelCoordinates == null || !source.isAttached || !panelCoordinates.isAttached) { + null + } else { + // Clipped to every scroll viewport in between, so a sample that + // scrolls partly away never opens a hole outside its column. + panelCoordinates.localBoundingBoxOf(source, clipBounds = true).takeIf { !it.isEmpty } + } + } +} + +private val LocalHudPanelWindow = staticCompositionLocalOf { null } + enum class HudTab(val label: String) { Info("Info"), Stats("Stats"), @@ -653,52 +735,33 @@ private fun HudTabPill( if (isFocused) onFocused() } - // The rail floats on the video, so an idle pill needs its own ground: - // tvOS HUDTabPillBody — black@0.45 fill with a white@0.18 hairline idle; - // solid white when selected; white@0.9 when merely focused. A white@0.06 - // fill (the old idle) vanishes over a bright frame. - // - // Selection follows focus, so a focused pill is always the selected one. - // A selected pill that is NOT focused means focus is down in the pane — - // it dims to a marker so the one solid-white element on screen is the - // control you're actually on. (Deliberate departure from tvOS, which - // keeps the selected pill white throughout.) + // The rail carries the ground, so an idle tab is just its label. Selection + // follows focus, so a focused tab is always the selected one; a selected + // tab that is NOT focused means focus is down in the pane, and it keeps a + // white-16% marker so the one Paper element on screen is the control + // you're on. Focus is an inversion, never a scale. val bg = when { - isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.22f) - // Firmer than tvOS's black@0.45: white type on 0.45 loses contrast - // over a bright frame, and the rail has no card behind it. - else -> Color.Black.copy(alpha = 0.62f) + isFocused -> TvPlayerChrome.Paper + isSelected -> Color.White.copy(alpha = 0.16f) + else -> Color.Transparent + } + val fg = when { + isFocused -> TvPlayerChrome.Ink + isSelected -> TvPlayerChrome.Paper + else -> TvPlayerChrome.Graphite } - val fg = if (isFocused) Color.Black else Color.White - val stroke = if (isFocused || isSelected) Color.Transparent else Color.White.copy(alpha = 0.18f) - val scale by animateFloatAsState( - targetValue = if (isFocused) 1.0f else 0.96f, - animationSpec = tween(120), - label = "hudTabScale", - ) Box( modifier = Modifier - .graphicsLayer { scaleX = scale; scaleY = scale } .height(HudTabHeight) - .clip(RoundedCornerShape(50)) + .clip(CircleShape) .background(bg) - .border(width = 0.5.dp, color = stroke, shape = RoundedCornerShape(50)) .focusRequester(focusRequester) .focusable(enabled = enabled, interactionSource = interactionSource) - .padding(horizontal = 18.dp), + .padding(horizontal = 15.dp), contentAlignment = Alignment.Center, ) { - Text( - text = label, - color = fg, - style = MaterialTheme.typography.titleSmall.copy( - fontSize = HudTabTextSize, - lineHeight = HudTabLineHeight, - fontWeight = FontWeight.SemiBold, - ), - ) + Text(text = label, style = TvPlayerType.Tab, color = fg, maxLines = 1) } } @@ -707,19 +770,26 @@ private fun HudPaneViewport( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { + val scroll = rememberScrollState() Column( modifier = modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(bottom = HudPaneBottomPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), + .hudFadingEdges(scroll) + .verticalScroll(scroll), content = content, ) } +/** + * The pane grid every two-column tab shares: 330 of 736dp on the left, a + * 26dp gutter, the rest on the right. Each column scrolls on its own and + * fades at the edge it scrolls past rather than cutting a row in half. + */ @Composable -private fun HudTwoColumnPane( +private fun HudColumns( modifier: Modifier = Modifier, + /** Pinned above the left column's scroll, so a long list keeps its label. */ + leftHeader: (@Composable () -> Unit)? = null, left: @Composable ColumnScope.() -> Unit, right: @Composable ColumnScope.() -> Unit, ) { @@ -727,8 +797,24 @@ private fun HudTwoColumnPane( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { - PaneColumn("Title", modifier = Modifier.weight(1f), content = left) - PaneColumn("Stream", modifier = Modifier.weight(1f), content = right) + val leftScroll = rememberScrollState() + val rightScroll = rememberScrollState() + Column(modifier = Modifier.weight(HudLeftColumnWeight)) { + leftHeader?.invoke() + Column( + modifier = Modifier + .hudFadingEdges(leftScroll) + .verticalScroll(leftScroll), + content = left, + ) + } + Column( + modifier = Modifier + .weight(HudRightColumnWeight) + .hudFadingEdges(rightScroll) + .verticalScroll(rightScroll), + content = right, + ) } } @@ -750,16 +836,12 @@ private fun HudInfoPane( playbackPlan: PlaybackExecutionPlan?, subtitleLabel: String, chapters: List, - modifier: Modifier = Modifier, ) { val episodeTag = if (seasonNumber != null && episodeNumber != null) { "S$seasonNumber · E$episodeNumber" } else { null } - val metaBits = buildList { - if (durationSec > 0) add(formatTime(durationSec)) - } val streamRows = buildList> { stats.backendRoute?.let { add("Route" to it) } playbackPlan?.takeIf { @@ -783,70 +865,39 @@ private fun HudInfoPane( stats.resolution?.let { add(it) } } - HudTwoColumnPane( - modifier = modifier, + HudColumns( left = { - Text( - text = title.ifBlank { "Now Playing" }, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall.copy( - fontSize = HudTitleTextSize, - lineHeight = HudTitleLineHeight, - fontWeight = FontWeight.SemiBold, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (episodeTag != null) { - Text( - text = episodeTag, - color = Color.White.copy(alpha = 0.75f), - style = MaterialTheme.typography.titleMedium.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), - ) - } - if (metaBits.isNotEmpty()) { + HudEyebrow("Title") + Column( + modifier = Modifier.padding(horizontal = HudRowPadding), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (episodeTag != null) { + Text(text = episodeTag.uppercase(), style = TvPlayerType.Eyebrow) + } Text( - text = metaBits.joinToString(" · "), - color = Color.White.copy(alpha = 0.65f), - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = HudMetaTextSize, - lineHeight = HudMetaLineHeight, - ), + text = title.ifBlank { "Now Playing" }, + style = TvPlayerType.DialogTitle, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - } - }, - right = { - if (badges.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - badges.forEach { badge -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .border( - width = 0.5.dp, - color = Color.White.copy(alpha = 0.35f), - shape = RoundedCornerShape(50), - ) - .padding(horizontal = 7.dp, vertical = 3.dp), - ) { - Text( - text = badge, - color = Color.White, - style = MaterialTheme.typography.labelMedium.copy( - fontSize = HudChipTextSize, - lineHeight = HudChipLineHeight, - fontWeight = FontWeight.SemiBold, - ), - ) - } + if (durationSec > 0) { + Text(text = formatTime(durationSec), style = TvPlayerType.Figures) + } + if (badges.isNotEmpty()) { + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + badges.forEach { badge -> HudChip(badge) } } } } + }, + right = { + HudEyebrow("Stream") streamRows.forEach { (label, value) -> - LabelValueRow(label = label, value = value) + HudReadOnlyRow(label = label, value = value) } }, ) @@ -886,58 +937,47 @@ private fun currentChapterTitle(chapters: List, positionSec: Dou return current.title.ifBlank { "Chapter ${current.index + 1}" } } +/** A section label inside a pane, inset to line up with row text. */ @Composable -private fun PaneColumn( - header: String, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), +private fun HudEyebrow(text: String, modifier: Modifier = Modifier, trailing: String? = null) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = HudRowPadding, end = HudRowPadding, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = header.uppercase(), - color = Color.White.copy(alpha = 0.5f), - style = MaterialTheme.typography.labelMedium.copy( - fontSize = HudChipTextSize, - lineHeight = HudChipLineHeight, - fontWeight = FontWeight.SemiBold, - ), + text = text.uppercase(), + style = TvPlayerType.Eyebrow, + maxLines = 1, + modifier = Modifier.weight(1f), ) - content() + if (trailing != null) { + Text(text = trailing, style = TvPlayerType.Figures, maxLines = 1) + } } } +/** Space between two sections in one column. */ @Composable -private fun LabelValueRow(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - // Fixed gap, weighted value — same reasoning as HudFocusedSettingRow: a - // lone weighted spacer collapses to 0dp once the two texts fill the row, - // which is how "SubtitlesArabic — SRT · Exter…" rendered. - Text( - text = label, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = Modifier.width(12.dp)) +private fun HudSectionSpacer() { + Spacer(modifier = Modifier.height(HudSectionGap)) +} + +/** A small uppercase tag: SDH, FORCED, a resolution, an HDR format. */ +@Composable +private fun HudChip(text: String, onPaper: Boolean = false) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(if (onPaper) TvPlayerChrome.Ink.copy(alpha = 0.10f) else TvPlayerChrome.Chip) + .padding(horizontal = 5.dp, vertical = 2.dp), + ) { Text( - text = value, - color = Color.White.copy(alpha = 0.7f), - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), + text = text.uppercase(), + style = TvPlayerType.Chip, + color = if (onPaper) TvPlayerChrome.InkMuted else TvPlayerType.Chip.color, maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.End, - modifier = Modifier.weight(1f), ) } } @@ -966,11 +1006,8 @@ private fun HudStatsPane(stats: PlayerStatsSnapshot, modifier: Modifier = Modifi horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { listOf(rows.take(split), rows.drop(split)).forEach { column -> - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - column.forEach { (label, value) -> LabelValueRow(label = label, value = value) } + Column(modifier = Modifier.weight(1f)) { + column.forEach { (label, value) -> HudReadOnlyRow(label = label, value = value) } } } } @@ -990,15 +1027,11 @@ private val PLAYBACK_SPEED_OPTIONS = listOf(0.75, 1.0, 1.25, 1.5, 2.0) private fun speedOptionId(speed: Double): String = String.format(java.util.Locale.ROOT, "%.2f", speed) -/** 1.0 -> "1.0×", 1.25 -> "1.25×" (matches tvOS speed labels). */ +/** 1.0 -> "1×", 1.25 -> "1.25×", 2.0 -> "2×": no trailing zero, as on the phone. */ private fun formatTvPlaybackSpeed(speed: Double): String { - val text = if (speed % 1.0 == 0.0) { - // Locale.ROOT so comma-decimal devices render "1.0×", not "1,0×", - // matching the dot-formatted speedOptionId used to commit the choice. - String.format(java.util.Locale.ROOT, "%.1f", speed) - } else { - speed.toString().trimEnd('0').trimEnd('.') - } + // Locale.ROOT so comma-decimal devices render "1.25×", not "1,25×", + // matching the dot-formatted speedOptionId used to commit the choice. + val text = String.format(java.util.Locale.ROOT, "%.2f", speed).trimEnd('0').trimEnd('.') return "$text×" } @@ -1074,18 +1107,12 @@ private fun HudVideoPane( speedRowVisible -> "speed" else -> "aspect" } - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), - ) { - // Playback column — Quality / Speed / Aspect / HDR + auto toggles. - PaneColumn( - "Playback", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HudColumns( + modifier = modifier, + left = { + // Playback column — Version / Quality / Speed / Aspect. + HudEyebrow("Playback") + Column { // Version — the server's file versions (4K / 1080p encodes). // Switching restarts the session on that file at the current // position (QA 2026-07-08 / tvOS parity). @@ -1156,9 +1183,13 @@ private fun HudVideoPane( onActivate = { onPresentPicker( HudPickerPresentation( - title = "Playback Speed", + title = "Playback speed", options = PLAYBACK_SPEED_OPTIONS.map { - HudPickerOption(speedOptionId(it), formatTvPlaybackSpeed(it)) + if (it == 1.0) { + HudPickerOption(speedOptionId(it), "Normal", trailing = formatTvPlaybackSpeed(it)) + } else { + HudPickerOption(speedOptionId(it), formatTvPlaybackSpeed(it)) + } }, selectedId = speedOptionId(playbackSpeed), onSelect = { id -> @@ -1193,114 +1224,106 @@ private fun HudVideoPane( }, ) } - } - + }, // Right column: what the device does with the picture, then what the // player does on its own. Previously the left column carried eight // rows against a lone Sleep timer here — the pane scrolled while // half the card sat empty. - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - PaneColumn("Output") { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - HudFocusedSettingRow( - label = "HDR passthrough", - value = onOffLabel(hdrEnabled), - enabled = enabled, - showsChevron = false, - onActivate = { onHdrEnabledChanged(!hdrEnabled) }, - ) + right = { + HudEyebrow("Output") + Column { + HudFocusedSettingRow( + label = "HDR passthrough", + value = onOffLabel(hdrEnabled), + enabled = enabled, + showsChevron = false, + onActivate = { onHdrEnabledChanged(!hdrEnabled) }, + ) - // Off plays DV sources as their base layer (HDR10) — some - // users prefer HDR10 even on DV-capable displays. Profile 5 - // always plays as DV (no watchable base layer); applies from - // the next playback start. Apple parity (silo-apple e9bd775). - HudFocusedSettingRow( - label = "Dolby Vision", - // A toggle on a DV file restarts the session so the - // server can re-plan the layer; say so on the row (the - // subtitle track row's idiom) and swallow presses until - // the replacement is playing, so a second press can't - // queue a second restart behind the first. Swallow, not - // disable: a disabled row is not focusable, and taking - // focus off the row the viewer just pressed left the - // next press landing on nothing. - value = if (dolbyVisionSwitchInFlight) { - "${onOffLabel(dolbyVisionEnabled)} · Applying…" - } else { - onOffLabel(dolbyVisionEnabled) - }, - enabled = enabled, - showsChevron = false, - onActivate = { - if (!dolbyVisionSwitchInFlight) { - onDolbyVisionEnabledChanged(!dolbyVisionEnabled) - } - }, - ) - } + // Off plays DV sources as their base layer (HDR10) — some + // users prefer HDR10 even on DV-capable displays. Profile 5 + // always plays as DV (no watchable base layer); applies from + // the next playback start. Apple parity (silo-apple e9bd775). + HudFocusedSettingRow( + label = "Dolby Vision", + // A toggle on a DV file restarts the session so the + // server can re-plan the layer; say so on the row (the + // subtitle track row's idiom) and swallow presses until + // the replacement is playing, so a second press can't + // queue a second restart behind the first. Swallow, not + // disable: a disabled row is not focusable, and taking + // focus off the row the viewer just pressed left the + // next press landing on nothing. + value = if (dolbyVisionSwitchInFlight) { + "${onOffLabel(dolbyVisionEnabled)} · Applying…" + } else { + onOffLabel(dolbyVisionEnabled) + }, + enabled = enabled, + showsChevron = false, + onActivate = { + if (!dolbyVisionSwitchInFlight) { + onDolbyVisionEnabledChanged(!dolbyVisionEnabled) + } + }, + ) } + HudSectionSpacer() + HudEyebrow("Automation") + Column { + // Three values, so Select cycles rather than toggles — + // the same one-press shape as the rows around it, without + // a picker sheet over the picture. Settings has the list. + HudFocusedSettingRow( + label = stringResource(R.string.settings_intro_skip_title), + value = stringResource(introSkipModeLabel(introSkipMode)), + enabled = enabled, + showsChevron = false, + onActivate = { onIntroSkipModeChanged(introSkipMode.next()) }, + ) - PaneColumn("Automation") { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - // Three values, so Select cycles rather than toggles — - // the same one-press shape as the rows around it, without - // a picker sheet over the picture. Settings has the list. - HudFocusedSettingRow( - label = stringResource(R.string.settings_intro_skip_title), - value = stringResource(introSkipModeLabel(introSkipMode)), - enabled = enabled, - showsChevron = false, - onActivate = { onIntroSkipModeChanged(introSkipMode.next()) }, - ) - - HudFocusedSettingRow( - label = "Auto-play next", - value = onOffLabel(autoPlayNext), - enabled = enabled, - showsChevron = false, - onActivate = { onAutoPlayNextChanged(!autoPlayNext) }, - ) + HudFocusedSettingRow( + label = "Auto-play next", + value = onOffLabel(autoPlayNext), + enabled = enabled, + showsChevron = false, + onActivate = { onAutoPlayNextChanged(!autoPlayNext) }, + ) - val activeSleep = sleepTimerState as? SleepTimerState.Active - HudFocusedSettingRow( - label = "Sleep timer", - value = activeSleep?.let { "Sleeping in ${formatSleepRemaining(it.remainingSeconds)}" } ?: "Off", - enabled = enabled, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Sleep Timer", - options = buildList { - if (activeSleep != null) { - add(HudPickerOption("cancel", "Cancel timer")) - } - add(HudPickerOption("off", "Off")) - addAll( - SLEEP_TIMER_PRESETS.map { minutes -> - HudPickerOption(minutes.toString(), sleepPresetLabel(minutes)) - }, - ) - }, - selectedId = if (activeSleep != null) "cancel" else "off", - onSelect = { id -> - when (id) { - "cancel", "off" -> onCancelSleepTimer() - else -> id.toIntOrNull()?.let(onStartSleepTimer) - } - }, - ), - ) - }, - ) - } + val activeSleep = sleepTimerState as? SleepTimerState.Active + HudFocusedSettingRow( + label = "Sleep timer", + value = activeSleep?.let { "Sleeping in ${formatSleepRemaining(it.remainingSeconds)}" } ?: "Off", + enabled = enabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Sleep timer", + options = buildList { + if (activeSleep != null) { + add(HudPickerOption("cancel", "Cancel timer")) + } + add(HudPickerOption("off", "Off")) + addAll( + SLEEP_TIMER_PRESETS.map { minutes -> + HudPickerOption(minutes.toString(), sleepPresetLabel(minutes)) + }, + ) + }, + selectedId = if (activeSleep != null) "cancel" else "off", + onSelect = { id -> + when (id) { + "cancel", "off" -> onCancelSleepTimer() + else -> id.toIntOrNull()?.let(onStartSleepTimer) + } + }, + ), + ) + }, + ) } - } - } + }, + ) } /** @@ -1327,17 +1350,22 @@ private fun HudAudioPane( // value 500dp from its label — "Audio track ……… English · DTS · 5.1" — // and left the card two-thirds empty. The right column is read-only // output facts the viewer would otherwise have to dig out of Stats. - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), - ) { - PaneColumn( - "Track", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Output — what the device is actually doing with the track. Mode is + // the fact behind the delay row's "Unavailable during passthrough": + // bitstream passthrough hands the codec to the receiver untouched, so + // there is no PCM to delay. + val outputRows = buildList> { + audioFormatShortName(stats.audioCodec)?.let { add("Codec" to it) } + add("Mode" to if (audioDelayEnabled) "Decoded to PCM" else "Passthrough") + stats.audioDecoderName + ?.takeIf { audioDelayEnabled } + ?.let { add("Decoder" to it.removePrefix("OMX.").removePrefix("c2.")) } + } + HudColumns( + modifier = modifier, + left = { + HudEyebrow("Track") + Column { val selectedTrack = audioTracks.firstOrNull { it.isSelected } val catalogAudio = activeVersion?.audioTracks.orEmpty() val formatting = org.siloserver.silo.tv.ui.screens.detail.TvPlaybackFormatting @@ -1381,7 +1409,7 @@ private fun HudAudioPane( onActivate = { onPresentPicker( HudPickerPresentation( - title = "Audio Track", + title = "Audio track", // Ids are catalog ordinals, the server's audio // contract, so an undelivered row stays // selectable and survives the round trip. @@ -1408,7 +1436,7 @@ private fun HudAudioPane( onActivate = { onPresentPicker( delayPicker( - title = "Audio Delay", + title = "Audio delay", current = audioDelayMs, from = -1_000, to = 1_000, @@ -1419,34 +1447,16 @@ private fun HudAudioPane( }, ) } - } - - // Output — what the device is actually doing with the track. Mode is - // the fact behind the delay row's "Unavailable during passthrough": - // bitstream passthrough hands the codec to the receiver untouched, so - // there is no PCM to delay. - val outputRows = buildList> { - audioFormatShortName(stats.audioCodec)?.let { add("Codec" to it) } - add("Mode" to if (audioDelayEnabled) "Decoded to PCM" else "Passthrough") - stats.audioDecoderName - ?.takeIf { audioDelayEnabled } - ?.let { add("Decoder" to it.removePrefix("OMX.").removePrefix("c2.")) } - } - PaneColumn( - "Output", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - outputRows.forEach { (label, value) -> - // Same row metrics as the setting rows on the left, so the - // two columns rule up; not focusable, nothing to open. - HudReadOnlyRow(label = label, value = value) - } + }, + right = { + HudEyebrow("Output") + outputRows.forEach { (label, value) -> + // Same row metrics as the setting rows on the left, so the + // two columns rule up; not focusable, nothing to open. + HudReadOnlyRow(label = label, value = value) } - } - } + }, + ) } /** @@ -1459,28 +1469,17 @@ private fun HudReadOnlyRow(label: String, value: String) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 8.dp), + .heightIn(min = HudRowHeight) + .padding(horizontal = HudRowPadding, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = label, - color = Color.White, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + // Fixed gap, weighted value: a lone weighted spacer collapses to 0dp + // once the two texts fill the row ("SubtitlesArabic — SRT · Exter…"). + Text(text = label, style = TvPlayerType.Row, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.width(12.dp)) Text( text = value, - color = Color.White.copy(alpha = 0.72f), - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), + style = TvPlayerType.Value, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End, @@ -1490,14 +1489,17 @@ private fun HudReadOnlyRow(label: String, value: String) { } /** - * Subtitles pane — track selection + delay + appearance rendered as tvOS - * row→dialog rows, plus the Android-only Search / AI-Translate rows preserved - * from the old drawer. Text-color / background-color stay as swatch chips since - * tvOS draws color swatches inline in its picker too. + * Subtitles pane. The left column lists every track inline, so switching + * subtitles is one press rather than row → picker → row. The right column + * shows a sample on the live picture, then Style (Size, Appearance, Timing) + * and the Android-only Find more rows. Appearance and Timing drill in place: + * [page] swaps the right column, and Back steps out (handled by the HUD). */ @Composable private fun HudSubtitlesPane( presentation: TvSubtitleHudPresentation, + page: HudSubtitlePage, + onPageChange: (HudSubtitlePage) -> Unit, subtitleDelayMs: Int, subtitleDelayEnabled: Boolean, onSubtitleDelayChanged: (Int) -> Unit, @@ -1526,388 +1528,493 @@ private fun HudSubtitlesPane( val geometryEnabled = enabled && applicability.geometryApplies val stylingEnabled = enabled && applicability.stylingApplies - val subtitleTrackFocus = remember { FocusRequester() } - val subtitleTextColorFocus = remember { FocusRequester() } - val subtitleBackgroundColorFocus = remember { FocusRequester() } - val subtitleOutlineColorFocus = remember { FocusRequester() } + // Moving between the root and a page removes the focused row, so focus is + // handed on explicitly: into a page's first row, or back to the row that + // opened it. Observed through the rows' own focus, not the pane's. + val appearanceRowFocus = remember { FocusRequester() } + val timingRowFocus = remember { FocusRequester() } + val pageEntryFocus = remember { FocusRequester() } + var focusedPageRow by remember { mutableStateOf(null) } + var lastPage by remember { mutableStateOf(page) } + LaunchedEffect(page) { + val previous = lastPage + lastPage = page + if (previous == page) return@LaunchedEffect + val (target, key) = when (page) { + HudSubtitlePage.Root -> when (previous) { + HudSubtitlePage.Timing -> timingRowFocus to "root:timing" + else -> appearanceRowFocus to "root:appearance" + } + else -> pageEntryFocus to "page:entry" + } + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = target::requestFocus, + isFocused = { focusedPageRow == key }, + ) + } + fun Modifier.trackPageFocus(key: String) = onFocusChanged { state -> + if (state.isFocused) { + focusedPageRow = key + } else if (focusedPageRow == key) { + focusedPageRow = null + } + } - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), - ) { - // Tracks + sync column. - PaneColumn( - "Tracks", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - val checkedRow = presentation.rows.firstOrNull { row -> row.checked } - val applyingRow = presentation.rows.firstOrNull { row -> row.applying } - val focusedRow = presentation.rows.firstOrNull { row -> row.focused } - HudFocusedSettingRow( - label = "Subtitles", - value = applyingRow?.let { "${it.label} · Applying…" } - ?: checkedRow?.label - ?: "Off", - enabled = enabled, - focusRequester = subtitleTrackFocus, - entryFocusRequester = entryFocusRequester, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Track", - options = presentation.rows.map { row -> - HudPickerOption( - id = row.stableId, - label = if (row.applying) { - "${row.label} · Applying…" - } else { - row.label - }, - detail = syncStatus(row.identity), - ) - }, - selectedId = checkedRow?.stableId - ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), - focusedId = focusedRow?.stableId - ?: checkedRow?.stableId - ?: presentation.rows.firstOrNull()?.stableId.orEmpty(), - closeOnSelect = false, - onFocused = presentation.onFocused, - onSelect = { stableId -> - presentation.rows - .firstOrNull { row -> row.stableId == stableId } - ?.let { row -> presentation.onSelect(row.identity) } - }, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Delay", - value = if (subtitleDelayEnabled) { - delayLabel(subtitleDelayMs) - } else { - "Unavailable for burned-in subtitles" - }, - enabled = enabled && subtitleDelayEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - delayPicker( - title = "Subtitle Delay", - current = subtitleDelayMs, - from = -2_000, - to = 2_000, - step = 100, - onSet = onSubtitleDelayChanged, - ), - ) - }, - ) - - if (timing != null) { - HudFocusedSettingRow( - label = "Timing", - value = subtitleTimingValue(timing), - // Swallow, not disable: the picker hands focus back to - // this row, and a disabled row is not focusable, so a - // running sync or a refusal would strand focus. + HudColumns( + modifier = modifier, + leftHeader = { + val trackCount = presentation.rows.count { row -> row.identity != SubtitleIdentity.Off } + HudEyebrow(if (trackCount > 0) "Tracks · $trackCount" else "Tracks") + }, + left = { + // The entry row is the checked track, so Down from the rail lands + // on the current choice; the first row when nothing is checked. + val entryRow = presentation.rows.firstOrNull { row -> row.checked } ?: presentation.rows.firstOrNull() + presentation.rows.forEach { row -> + key(row.stableId) { + HudSubtitleTrackRow( + row = row, + syncStatus = syncStatus(row.identity), enabled = enabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = activate@{ - if (timing.forbidden || !timing.actionsEnabled || !(timing.canSync || timing.canReset)) { - return@activate - } + focusRequester = entryFocusRequester.takeIf { row == entryRow }, + onFocused = { presentation.onFocused(row.stableId) }, + onSelect = { presentation.onSelect(row.identity) }, + ) + } + } + }, + right = { + HudSubtitlePreview(appearance = appearance) + when (page) { + HudSubtitlePage.Root -> { + HudEyebrow("Style") + HudFocusedSettingRow( + label = "Size", + value = TvSubtitleAppearanceOptions.fontSizeLabel(appearance.fontSize), + enabled = geometryEnabled, + onActivate = { onPresentPicker( HudPickerPresentation( - title = "Subtitle Timing", - options = listOfNotNull( - HudPickerOption(TIMING_SYNC, "Sync to audio").takeIf { timing.canSync }, - HudPickerOption(TIMING_RESET, "Reset timing").takeIf { timing.canReset }, - ), - selectedId = "", - focusedId = if (timing.canSync) TIMING_SYNC else TIMING_RESET, + title = "Subtitle size", + options = FONT_SIZES.map { HudPickerOption(it.first.name, it.second) }, + selectedId = appearance.fontSize.name, onSelect = { id -> - when (id) { - TIMING_SYNC -> onSyncSubtitle(timing.key) - TIMING_RESET -> onResetTiming(timing.key) + FONT_SIZES.firstOrNull { it.first.name == id }?.let { + onAppearanceChanged(appearance.copy(fontSize = it.first)) } }, ), ) }, ) - HudSubtitleTimingDetail(timing) + HudFocusedSettingRow( + label = "Appearance", + value = subtitleAppearanceSummary(appearance), + // Position is geometry, so the page is useful even + // when only geometry applies (image subtitles). + enabled = geometryEnabled, + focusRequester = appearanceRowFocus, + modifier = Modifier.trackPageFocus("root:appearance"), + onActivate = { onPageChange(HudSubtitlePage.Appearance) }, + ) + HudFocusedSettingRow( + label = "Timing", + value = subtitleTimingSummary(subtitleDelayMs, subtitleDelayEnabled, timing), + enabled = enabled && (subtitleDelayEnabled || timing != null), + focusRequester = timingRowFocus, + modifier = Modifier.trackPageFocus("root:timing"), + onActivate = { onPageChange(HudSubtitlePage.Timing) }, + ) + applicability.note?.let { note -> HudNote(note) } + if (onSearchSubtitles != null || onTranslateWithAi != null) { + HudSectionSpacer() + HudEyebrow("Find more") + if (onSearchSubtitles != null) { + HudActionRow( + label = "Search online", + icon = Icons.Rounded.Search, + enabled = enabled, + onClick = onSearchSubtitles, + ) + } + if (onTranslateWithAi != null) { + HudActionRow( + label = "Translate with AI", + icon = Icons.Rounded.AutoAwesome, + enabled = enabled, + onClick = onTranslateWithAi, + ) + } + } } - HudFocusedSettingRow( - label = "Size", - value = FONT_SIZES.firstOrNull { it.first == appearance.fontSize }?.second - ?: appearance.fontSize.name, - enabled = geometryEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Size", - options = FONT_SIZES.map { HudPickerOption(it.first.name, it.second) }, - selectedId = appearance.fontSize.name, - onSelect = { id -> - FONT_SIZES.firstOrNull { it.first.name == id }?.let { - onAppearanceChanged(appearance.copy(fontSize = it.first)) - } - }, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Font", - value = FONT_FAMILIES.firstOrNull { it.first == appearance.fontFamily }?.second - ?: appearance.fontFamily, - enabled = stylingEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Font", - options = FONT_FAMILIES.map { HudPickerOption(it.first, it.second) }, - selectedId = appearance.fontFamily, - onSelect = { id -> - FONT_FAMILIES.firstOrNull { it.first == id }?.let { - onAppearanceChanged(appearance.copy(fontFamily = it.first)) - } - }, - ), + HudSubtitlePage.Appearance -> { + HudEyebrow("Appearance") + HudFocusedSettingRow( + label = "Font", + value = TvSubtitleAppearanceOptions.fontFamilyLabel(appearance.fontFamily), + enabled = stylingEnabled, + // Entry is Position when styling is out (image tracks): + // a disabled row cannot take focus. + focusRequester = pageEntryFocus.takeIf { stylingEnabled }, + modifier = Modifier.trackPageFocus(if (stylingEnabled) "page:entry" else "page:font"), + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Subtitle font", + options = FONT_FAMILIES.map { HudPickerOption(it.first, it.second) }, + selectedId = appearance.fontFamily, + onSelect = { id -> + FONT_FAMILIES.firstOrNull { it.first == id }?.let { + onAppearanceChanged(appearance.copy(fontFamily = it.first)) + } + }, + ), + ) + }, + ) + HudSwatchRow( + label = "Text color", + swatches = TEXT_COLOR_SWATCHES, + selectedHex = appearance.fontColor, + swatchLabel = TvSubtitleAppearanceOptions::fontColorLabel, + enabled = stylingEnabled, + onSelect = { hex -> onAppearanceChanged(appearance.copy(fontColor = hex)) }, + ) + if (showTextOpacity) { + HudFocusedSettingRow( + label = "Text opacity", + value = "${appearance.textOpacity}%", + enabled = stylingEnabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Text opacity", + options = TvSubtitleAppearanceOptions.percentOptions( + TEXT_OPACITY_STEPS, + appearance.textOpacity, + ).map { HudPickerOption(it.toString(), "$it%") }, + selectedId = appearance.textOpacity.toString(), + onSelect = { id -> + id.toIntOrNull()?.let { + onAppearanceChanged(appearance.copy(textOpacity = it)) + } + }, + ), + ) + }, ) - }, - ) - - if (showTextOpacity) { + } + HudFocusedSettingRow( + label = "Background", + value = BACKGROUND_STYLES.firstOrNull { it.first == appearance.backgroundStyle }?.second + ?: appearance.backgroundStyle.name, + enabled = stylingEnabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Subtitle background", + options = BACKGROUND_STYLES.map { HudPickerOption(it.first.name, it.second) }, + selectedId = appearance.backgroundStyle.name, + onSelect = { id -> + BACKGROUND_STYLES.firstOrNull { it.first.name == id }?.let { + onAppearanceChanged(appearance.copy(backgroundStyle = it.first)) + } + }, + ), + ) + }, + ) + HudSwatchRow( + label = "Background color", + swatches = BACKGROUND_COLOR_SWATCHES, + selectedHex = appearance.backgroundColor, + swatchLabel = TvSubtitleAppearanceOptions::backgroundColorLabel, + enabled = stylingEnabled, + onSelect = { hex -> onAppearanceChanged(appearance.copy(backgroundColor = hex)) }, + ) HudFocusedSettingRow( - label = "Text Opacity", - value = "${appearance.textOpacity}%", + label = "Background opacity", + value = "${appearance.backgroundOpacity}%", enabled = stylingEnabled, - rightFocusRequester = subtitleTextColorFocus, onActivate = { onPresentPicker( HudPickerPresentation( - title = "Text Opacity", + title = "Background opacity", options = TvSubtitleAppearanceOptions.percentOptions( - TEXT_OPACITY_STEPS, - appearance.textOpacity, + OPACITY_STEPS, + appearance.backgroundOpacity, ).map { HudPickerOption(it.toString(), "$it%") }, - selectedId = appearance.textOpacity.toString(), + selectedId = appearance.backgroundOpacity.toString(), onSelect = { id -> id.toIntOrNull()?.let { - onAppearanceChanged(appearance.copy(textOpacity = it)) + onAppearanceChanged(appearance.copy(backgroundOpacity = it)) + } + }, + ), + ) + }, + ) + HudFocusedSettingRow( + label = "Outline", + value = onOffLabel(appearance.textOutline), + enabled = stylingEnabled, + showsChevron = false, + onActivate = { onAppearanceChanged(appearance.copy(textOutline = !appearance.textOutline)) }, + ) + if (appearance.textOutline) { + HudSwatchRow( + label = "Outline color", + swatches = OUTLINE_COLOR_SWATCHES, + selectedHex = appearance.textOutlineColor, + swatchLabel = TvSubtitleAppearanceOptions::outlineColorLabel, + enabled = stylingEnabled, + onSelect = { hex -> onAppearanceChanged(appearance.copy(textOutlineColor = hex)) }, + ) + } + HudFocusedSettingRow( + label = "Position", + value = POSITIONS.firstOrNull { it.first == appearance.position }?.second + ?: appearance.position.name, + enabled = geometryEnabled, + focusRequester = pageEntryFocus.takeIf { !stylingEnabled }, + modifier = Modifier.trackPageFocus(if (!stylingEnabled) "page:entry" else "page:position"), + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Subtitle position", + options = POSITIONS.map { HudPickerOption(it.first.name, it.second) }, + selectedId = appearance.position.name, + onSelect = { id -> + POSITIONS.firstOrNull { it.first.name == id }?.let { + onAppearanceChanged(appearance.copy(position = it.first)) } }, ), ) }, ) + applicability.note?.let { note -> HudNote(note) } } - HudFocusedSettingRow( - label = "Background", - value = BACKGROUND_STYLES.firstOrNull { it.first == appearance.backgroundStyle }?.second - ?: appearance.backgroundStyle.name, - enabled = stylingEnabled, - rightFocusRequester = subtitleBackgroundColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Background", - options = BACKGROUND_STYLES.map { HudPickerOption(it.first.name, it.second) }, - selectedId = appearance.backgroundStyle.name, - onSelect = { id -> - BACKGROUND_STYLES.firstOrNull { it.first.name == id }?.let { - onAppearanceChanged(appearance.copy(backgroundStyle = it.first)) - } - }, - ), + HudSubtitlePage.Timing -> { + HudEyebrow("Timing") + HudFocusedSettingRow( + label = "Delay", + value = if (subtitleDelayEnabled) { + delayLabel(subtitleDelayMs) + } else { + "Unavailable for burned-in subtitles" + }, + enabled = enabled && subtitleDelayEnabled, + focusRequester = pageEntryFocus.takeIf { subtitleDelayEnabled }, + modifier = Modifier.trackPageFocus(if (subtitleDelayEnabled) "page:entry" else "page:delay"), + onActivate = { + onPresentPicker( + delayPicker( + title = "Subtitle delay", + current = subtitleDelayMs, + from = -2_000, + to = 2_000, + step = 100, + onSet = onSubtitleDelayChanged, + ), + ) + }, + ) + if (timing != null) { + // Swallow, not disable: a running sync or a refusal + // would otherwise strand focus on a row that just + // stopped being focusable. + HudFocusedSettingRow( + label = "Sync to audio", + value = subtitleTimingValue(timing), + enabled = enabled, + showsChevron = false, + focusRequester = pageEntryFocus.takeIf { !subtitleDelayEnabled }, + modifier = Modifier.trackPageFocus(if (!subtitleDelayEnabled) "page:entry" else "page:sync"), + onActivate = { + if (!timing.forbidden && timing.actionsEnabled && timing.canSync) { + onSyncSubtitle(timing.key) + } + }, ) - }, - ) - - HudFocusedSettingRow( - label = "Opacity", - value = "${appearance.backgroundOpacity}%", - enabled = stylingEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Background Opacity", - options = TvSubtitleAppearanceOptions.percentOptions( - OPACITY_STEPS, - appearance.backgroundOpacity, - ).map { HudPickerOption(it.toString(), "$it%") }, - selectedId = appearance.backgroundOpacity.toString(), - onSelect = { id -> - id.toIntOrNull()?.let { - onAppearanceChanged(appearance.copy(backgroundOpacity = it)) - } + if (timing.canReset) { + HudFocusedSettingRow( + label = "Reset timing", + value = "", + enabled = enabled, + showsChevron = false, + onActivate = { + if (!timing.forbidden && timing.actionsEnabled) onResetTiming(timing.key) }, - ), - ) - }, - ) + ) + } + HudSubtitleTimingDetail(timing) + } + } + } + }, + ) +} - HudFocusedSettingRow( - label = "Outline", - value = onOffLabel(appearance.textOutline), - enabled = stylingEnabled, - // The outline-color swatch (subtitleOutlineColorFocus) is only - // composed when textOutline is on. Right-nav must not target a - // detached requester when it's off, so gate the target on it. - rightFocusRequester = subtitleOutlineColorFocus.takeIf { appearance.textOutline }, - showsChevron = false, - onActivate = { onAppearanceChanged(appearance.copy(textOutline = !appearance.textOutline)) }, - ) +/** "White · Box": the two appearance facts a viewer recognizes at a glance. */ +private fun subtitleAppearanceSummary(appearance: SubtitleAppearance): String { + val color = TvSubtitleAppearanceOptions.fontColorLabel(appearance.fontColor) + val background = when (appearance.backgroundStyle) { + SubtitleBackgroundStylePreset.None -> "No background" + SubtitleBackgroundStylePreset.Box -> "Box" + SubtitleBackgroundStylePreset.Shadow -> "Shadow" + SubtitleBackgroundStylePreset.Outline -> "Outline" + } + return "$color · $background" +} - HudFocusedSettingRow( - label = "Position", - value = POSITIONS.firstOrNull { it.first == appearance.position }?.second - ?: appearance.position.name, - enabled = geometryEnabled, - rightFocusRequester = subtitleTextColorFocus, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Subtitle Position", - options = POSITIONS.map { HudPickerOption(it.first.name, it.second) }, - selectedId = appearance.position.name, - onSelect = { id -> - POSITIONS.firstOrNull { it.first.name == id }?.let { - onAppearanceChanged(appearance.copy(position = it.first)) - } - }, - ), - ) - }, - ) +/** The Timing row's value: the delay in seconds, or what a sync is doing. */ +private fun subtitleTimingSummary( + delayMs: Int, + delayEnabled: Boolean, + timing: SubtitleTimingActions?, +): String = when { + timing != null && (timing.busy || timing.inProgress) -> "Working…" + delayEnabled -> delaySecondsLabel(delayMs) + timing != null -> subtitleTimingValue(timing) + else -> "Unavailable" +} - applicability.note?.let { note -> - Text( - text = note, - color = Color.White.copy(alpha = 0.62f), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - ) - } - } - } +/** 0 -> "0.0 s", 300 -> "+0.3 s", -1200 -> "−1.2 s" (true minus sign). */ +private fun delaySecondsLabel(valueMs: Int): String { + val seconds = String.format(java.util.Locale.ROOT, "%.1f", kotlin.math.abs(valueMs) / 1000.0) + return when { + valueMs > 0 -> "+$seconds s" + valueMs < 0 -> "−$seconds s" + else -> "$seconds s" + } +} - // Colors + Android-only acquisition column. - PaneColumn( - "Style & sources", - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - ) { - HudSubtitlePreview(appearance = appearance) +/** + * A subtitle track's label split for a row: the language as the title, the + * tags worth scanning for (Forced, SDH, AI) as chips, the rest as detail. + * Labels arrive as "English — SRT · Forced · External" from + * [subtitleChoiceLabel], or "English • SRT" from a player-only track. + */ +internal data class TvSubtitleRowParts(val title: String, val chips: List, val detail: String?) + +internal fun tvSubtitleRowParts(label: String): TvSubtitleRowParts { + val separator = listOf(" — ", " • ").firstOrNull { it in label } + val title = separator?.let { label.substringBefore(it) } ?: label + val facts = separator + ?.let { label.substringAfter(it) } + ?.split(" · ", " • ") + ?.map(String::trim) + ?.filter(String::isNotEmpty) + .orEmpty() + val chips = facts.mapNotNull { fact -> TvSubtitleChipNames[fact.lowercase(java.util.Locale.US)] } + val detail = facts + .filter { fact -> fact.lowercase(java.util.Locale.US) !in TvSubtitleChipNames } + .joinToString(" · ") + .ifBlank { null } + return TvSubtitleRowParts(title = title.trim(), chips = chips.distinct(), detail = detail) +} - // There is deliberately no "No background" toggle here. It was a - // second control over backgroundStyle, which the Background picker - // in the left column already exposes as "No background" — and - // toggling it off could not know what the style had been, so it - // hard-coded Box and silently destroyed the user's choice - // (Drop Shadow -> On -> Off left you on Box, persisted immediately). - // tvOS has no such toggle either: TVPlayerInfoHUD offers a single - // Style picker plus a Background color row. - - // Color swatches stay inline — tvOS draws color swatches directly, - // and a row→dialog of colors would lose the at-a-glance palette. - StyleSection("Text color", dimmed = !applicability.stylingApplies) { - TEXT_COLOR_SWATCHES.forEachIndexed { index, hex -> - StyleColorSwatch( - hex = hex, - label = TvSubtitleAppearanceOptions.fontColorLabel(hex), - selected = appearance.fontColor.equals(hex, ignoreCase = true), - enabled = stylingEnabled, - focusRequester = if (index == 0) subtitleTextColorFocus else null, - leftFocusRequester = subtitleTrackFocus, - ) { - onAppearanceChanged(appearance.copy(fontColor = hex)) - } - } - } - StyleSection("Background color", dimmed = !applicability.stylingApplies) { - BACKGROUND_COLOR_SWATCHES.forEachIndexed { index, hex -> - StyleColorSwatch( - hex = hex, - label = TvSubtitleAppearanceOptions.backgroundColorLabel(hex), - selected = appearance.backgroundColor.equals(hex, ignoreCase = true), - enabled = stylingEnabled, - focusRequester = if (index == 0) subtitleBackgroundColorFocus else null, - leftFocusRequester = subtitleTrackFocus, - ) { - onAppearanceChanged(appearance.copy(backgroundColor = hex)) - } - } - } - if (appearance.textOutline) { - StyleSection("Outline color", dimmed = !applicability.stylingApplies) { - OUTLINE_COLOR_SWATCHES.forEachIndexed { index, hex -> - StyleColorSwatch( - hex = hex, - label = TvSubtitleAppearanceOptions.outlineColorLabel(hex), - selected = appearance.textOutlineColor.equals(hex, ignoreCase = true), - enabled = stylingEnabled, - focusRequester = if (index == 0) subtitleOutlineColorFocus else null, - leftFocusRequester = subtitleTrackFocus, - ) { - onAppearanceChanged(appearance.copy(textOutlineColor = hex)) - } - } - } - } +private val TvSubtitleChipNames = mapOf( + "forced" to "Forced", + "sdh" to "SDH", + "cc" to "CC", + "ai translation" to "AI", +) - // Android-only sidecar acquisition rows — kept from the old drawer. - if (onSearchSubtitles != null || onTranslateWithAi != null) { - Column( - modifier = Modifier.padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (onSearchSubtitles != null) { - HudActionRow( - label = "Search subtitles", - enabled = enabled, - leftFocusRequester = subtitleTrackFocus, - onClick = onSearchSubtitles, - ) - } - if (onTranslateWithAi != null) { - HudActionRow( - label = "Translate with AI", - enabled = enabled, - leftFocusRequester = subtitleTrackFocus, - onClick = onTranslateWithAi, - ) - } +/** + * One inline subtitle track: a check column, the language with its chips, + * and the format and source on the right. Select switches to it at once. + */ +@Composable +private fun HudSubtitleTrackRow( + row: TvSubtitleHudRow, + syncStatus: String?, + enabled: Boolean, + focusRequester: FocusRequester?, + onFocused: () -> Unit, + onSelect: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val scope = rememberCoroutineScope() + val isFocused by interactionSource.collectIsFocusedAsState() + val colors = hudRowColors(focused = isFocused, current = row.checked) + val parts = remember(row.label) { tvSubtitleRowParts(row.label) } + val detail = listOfNotNull( + row.status ?: syncStatus, + parts.detail, + ).joinToString(" · ").ifBlank { null } + + // The checked row is revealed when the pane opens; focus reveals the rest. + LaunchedEffect(Unit) { if (row.checked) bringIntoViewRequester.bringIntoView() } + + Row( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { state -> + if (state.isFocused) { + onFocused() + scope.launch { bringIntoViewRequester.bringIntoView() } } } + .hudRow(colors.background) + .clickable(enabled = enabled, interactionSource = interactionSource, indication = null) { onSelect() } + .semantics { selected = row.checked } + .padding(horizontal = HudRowPadding, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HudCheckSlot(checked = row.checked, tint = colors.content) + // The title side takes what the detail leaves; the detail is short + // ("SRT · External") and capped, so a long name ellipsizes first. + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + text = parts.title, + style = TvPlayerType.Row, + color = colors.content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + parts.chips.forEach { chip -> + Spacer(modifier = Modifier.width(6.dp)) + HudChip(chip, onPaper = isFocused) + } + } + if (detail != null) { + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = detail, + style = TvPlayerType.RowDetail, + color = colors.detail, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = 168.dp), + ) } } } +/** + * The subtitle sample: a 58dp strip that cuts a window in the panel, so the + * line is drawn over the picture it will appear on. Updates live as the + * appearance changes. + */ @Composable private fun HudSubtitlePreview( appearance: SubtitleAppearance, modifier: Modifier = Modifier, ) { val safe = appearance.sanitized() - val shape = RoundedCornerShape(6.dp) val decoration = TvSubtitleAppearanceOptions.previewDecoration(safe) - val fontSize = TvSubtitleAppearanceOptions.previewFontSizeSp(safe.fontSize).sp + val fontSize = TvSubtitleAppearanceOptions.previewFontSizeSp(safe.fontSize).coerceAtMost(22f).sp val fontFamily = TvSubtitleAppearanceOptions.previewFontFamily(safe.fontFamily) val foreground = hexToColor(safe.fontColor).copy( alpha = TvSubtitleAppearanceOptions.previewOpacityAlpha(safe.textOpacity, floor = 1), @@ -1920,99 +2027,112 @@ private fun HudSubtitlePreview( 0f }, ) - val textStyle = MaterialTheme.typography.bodyLarge.copy( + val textStyle = TvPlayerType.Row.copy( fontSize = fontSize, lineHeight = fontSize * 1.18f, fontFamily = fontFamily, fontWeight = FontWeight.SemiBold, shadow = if (decoration.shadow) { - Shadow( - color = outline.copy(alpha = 0.9f), - offset = Offset(1f, 2f), - blurRadius = 5f, - ) + Shadow(color = outline.copy(alpha = 0.9f), offset = Offset(1f, 2f), blurRadius = 5f) } else { null }, ) + val window = LocalHudPanelWindow.current + DisposableEffect(window) { onDispose { window?.report(null) } } - Column( + Box( modifier = modifier + .padding(bottom = HudSectionGap) .fillMaxWidth() - .padding(top = 5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), + .height(58.dp) + .onGloballyPositioned { window?.report(it) } + .clip(RoundedCornerShape(HudPreviewCorner)) + // A light veil keeps the sample legible over a bright frame. + .background(Color.Black.copy(alpha = 0.14f)) + .padding(horizontal = 10.dp, vertical = 7.dp), + contentAlignment = TvSubtitleAppearanceOptions.previewAlignment(safe.position), ) { - Text( - text = "Example", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.82f), - ) + val boxed = safe.backgroundStyle == SubtitleBackgroundStylePreset.Box Box( modifier = Modifier - .fillMaxWidth() - .height(76.dp) - .clip(shape) - .background(DarkSurfaceElevated.copy(alpha = 0.72f)) - .padding(horizontal = 10.dp, vertical = 7.dp), - contentAlignment = TvSubtitleAppearanceOptions.previewAlignment(safe.position), + .clip(RoundedCornerShape(3.dp)) + .background(backgroundColor) + .padding(horizontal = if (boxed) 8.dp else 0.dp, vertical = if (boxed) 2.dp else 0.dp), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(3.dp)) - .background(backgroundColor) - .padding( - horizontal = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) 7.dp else 0.dp, - vertical = if (safe.backgroundStyle == SubtitleBackgroundStylePreset.Box) 2.dp else 0.dp, - ), - contentAlignment = Alignment.Center, - ) { - if (decoration.outline) { - listOf(-1 to -1, -1 to 1, 1 to -1, 1 to 1).forEach { (x, y) -> - Text( - text = "Subtitle example", - color = outline, - style = textStyle.copy(shadow = null), - maxLines = 1, - modifier = Modifier.offset(x.dp, y.dp), - ) - } + if (decoration.outline) { + listOf(-1 to -1, -1 to 1, 1 to -1, 1 to 1).forEach { (x, y) -> + Text( + text = HudPreviewLine, + color = outline, + style = textStyle.copy(shadow = null), + maxLines = 1, + modifier = Modifier.offset(x.dp, y.dp), + ) } - Text( - text = "Subtitle example", - color = foreground, - style = textStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } + Text( + text = HudPreviewLine, + color = foreground, + style = textStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } -@OptIn(ExperimentalLayoutApi::class) +private const val HudPreviewLine = "Subtitles will look like this." + +/** + * A row of colour swatches under its label. Swatches stay inline, as on + * tvOS: a picker of colours would lose the at-a-glance palette. + */ +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) @Composable -private fun StyleSection( - title: String, - dimmed: Boolean = false, - content: @Composable () -> Unit, +private fun HudSwatchRow( + label: String, + swatches: List, + selectedHex: String, + swatchLabel: (String) -> String, + enabled: Boolean, + onSelect: (String) -> Unit, ) { Column( - verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier - .padding(top = 5.dp) + .fillMaxWidth() + .padding(horizontal = HudRowPadding, vertical = 6.dp) // Same 0.35 alpha HudFocusedSettingRow uses for a disabled row. - .graphicsLayer { alpha = if (dimmed) 0.35f else 1f }, + .graphicsLayer { alpha = if (enabled) 1f else 0.35f }, + verticalArrangement = Arrangement.spacedBy(7.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.82f), - ) + Text(text = label, style = TvPlayerType.Row, maxLines = 1) + // Moving into the row lands on the current colour, not on whichever + // swatch happens to sit under the row the viewer came from. + val selectedFocus = remember { FocusRequester() } + val hasSelected = swatches.any { it.equals(selectedHex, ignoreCase = true) } FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { content() } + modifier = Modifier + .fillMaxWidth() + .focusProperties { + enter = { if (hasSelected) selectedFocus else FocusRequester.Default } + } + .focusGroup(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + swatches.forEach { hex -> + val selected = selectedHex.equals(hex, ignoreCase = true) + StyleColorSwatch( + hex = hex, + label = swatchLabel(hex), + selected = selected, + enabled = enabled, + focusRequester = selectedFocus.takeIf { selected }, + ) { onSelect(hex) } + } + } } } @@ -2023,7 +2143,6 @@ private fun StyleColorSwatch( selected: Boolean, enabled: Boolean, focusRequester: FocusRequester? = null, - leftFocusRequester: FocusRequester? = null, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -2031,23 +2150,24 @@ private fun StyleColorSwatch( val swatchColor = hexToColor(hex) val isLightSwatch = isLightHexColor(hex) val checkTint = if (isLightSwatch) Color.Black.copy(alpha = 0.78f) else Color.White - val ring = when { - isFocused && isLightSwatch -> Color.Black.copy(alpha = 0.78f) - isFocused -> Color.White - selected && isLightSwatch -> Color.Black.copy(alpha = 0.62f) - selected -> Color.White.copy(alpha = 0.85f) - else -> Color.White.copy(alpha = 0.25f) - } + // Focus is a Paper ring outside the swatch, so it reads on any colour. Box( modifier = Modifier - .size(24.dp) - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .focusProperties { - if (leftFocusRequester != null) left = leftFocusRequester - } + .size(32.dp) + .border( + width = 2.dp, + color = if (isFocused) TvPlayerChrome.Paper else Color.Transparent, + shape = CircleShape, + ) + .padding(4.dp) .clip(CircleShape) .background(swatchColor) - .border(width = if (isFocused || selected) 2.dp else 1.dp, color = ring, shape = CircleShape) + .border( + width = 1.dp, + color = if (selected && !isLightSwatch) Color.White.copy(alpha = 0.85f) else Color.White.copy(alpha = 0.22f), + shape = CircleShape, + ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .clickable(enabled = enabled, interactionSource = interactionSource, indication = null) { onClick() } .semantics { contentDescription = if (selected) "$label, selected" else label @@ -2057,7 +2177,7 @@ private fun StyleColorSwatch( ) { if (selected) { Icon( - imageVector = Icons.Filled.Check, + imageVector = Icons.Rounded.Check, contentDescription = null, tint = checkTint, modifier = Modifier.size(14.dp), @@ -2066,6 +2186,16 @@ private fun StyleColorSwatch( } } +/** A line of explanation under the rows it qualifies. Never takes focus. */ +@Composable +private fun HudNote(text: String) { + Text( + text = text, + style = TvPlayerType.RowDetail, + modifier = Modifier.padding(horizontal = HudRowPadding, vertical = 6.dp), + ) +} + private fun hexToColor(hex: String): Color = try { val cleaned = if (hex.startsWith("#")) hex.drop(1) else hex Color(0xFF000000.toInt() or (cleaned.toLong(16).toInt() and 0x00FFFFFF)) @@ -2104,46 +2234,140 @@ private fun delayLabel(valueMs: Int): String = } /** - * Full-width action row for HUD panes — a true click target: an explicit Select - * press is required (focus-driven commit would fire dialogs during plain - * traversal). + * Full-width action row for HUD panes: an icon, a label, a chevron. A true + * click target: an explicit Select press is required (focus-driven commit + * would fire dialogs during plain traversal). */ @Composable private fun HudActionRow( label: String, + icon: ImageVector, enabled: Boolean = true, - focusRequester: FocusRequester? = null, - leftFocusRequester: FocusRequester? = null, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - - val bg = if (isFocused) Color.White.copy(alpha = 0.94f) else Color.White.copy(alpha = 0.06f) - val fg = if (isFocused) Color.Black else Color.White.copy(alpha = 0.86f) + val colors = hudRowColors(focused = isFocused) Row( modifier = Modifier .fillMaxWidth() - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .focusProperties { - if (leftFocusRequester != null) left = leftFocusRequester - } - .clip(RoundedCornerShape(12.dp)) - .background(bg) + .hudRow(colors.background) .clickable(enabled = enabled, interactionSource = interactionSource, indication = null) { onClick() } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = HudRowPadding, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { + Icon(imageVector = icon, contentDescription = null, tint = colors.content, modifier = Modifier.size(19.dp)) + Spacer(modifier = Modifier.width(9.dp)) Text( text = label, - color = fg, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + style = TvPlayerType.Row, + color = colors.content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + HudChevron(colors.chevron) + } +} + +/** What a row looks like at rest, when it is the current choice, and focused. */ +private data class HudRowColors( + val background: Color, + val content: Color, + val detail: Color, + val chevron: Color, +) + +private fun hudRowColors(focused: Boolean, current: Boolean = false): HudRowColors = when { + focused -> HudRowColors( + background = TvPlayerChrome.Paper, + content = TvPlayerChrome.Ink, + detail = TvPlayerChrome.InkMuted, + chevron = TvPlayerChrome.Ink.copy(alpha = 0.45f), + ) + current -> HudRowColors( + background = TvPlayerChrome.Selected, + content = TvPlayerChrome.Paper, + detail = TvPlayerChrome.Graphite, + chevron = TvPlayerChrome.Faint, + ) + else -> HudRowColors( + background = Color.Transparent, + content = TvPlayerChrome.Paper, + detail = TvPlayerChrome.Graphite, + chevron = TvPlayerChrome.Faint, + ) +} + +/** The shared row ground: 34dp tall, a 10dp radius, filled by state. */ +private fun Modifier.hudRow(background: Color): Modifier = + heightIn(min = HudRowHeight) + .clip(RoundedCornerShape(HudRowCorner)) + .background(background) + +/** The leading check column, kept even when empty so labels line up. */ +@Composable +private fun HudCheckSlot(checked: Boolean, tint: Color) { + Box(modifier = Modifier.width(HudCheckColumn + 8.dp), contentAlignment = Alignment.CenterStart) { + if (checked) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = tint, + modifier = Modifier.size(19.dp), + ) + } } } +@Composable +private fun HudChevron(tint: Color) { + Icon( + imageVector = Icons.Rounded.ChevronRight, + contentDescription = null, + tint = tint, + modifier = Modifier + .padding(start = 4.dp) + .offset(x = 4.dp) + .size(18.dp), + ) +} + +/** + * Fades content at whichever edge it can scroll past, instead of cutting a + * row in half at the panel's edge. Nothing fades when nothing scrolls. + */ +private fun Modifier.hudFadingEdges( + state: ScrollableState, + top: Dp = 16.dp, + bottom: Dp = 36.dp, +): Modifier = graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + if (state.canScrollBackward) { + drawRect( + brush = Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black, endY = top.toPx()), + size = size.copy(height = top.toPx()), + blendMode = BlendMode.DstIn, + ) + } + if (state.canScrollForward) { + val height = bottom.toPx() + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Black, + 1f to Color.Transparent, + startY = size.height - height, + endY = size.height, + ), + topLeft = Offset(0f, size.height - height), + size = size.copy(height = height), + blendMode = BlendMode.DstIn, + ) + } + } + private fun PlayerStatsSnapshot.hasHudRows(): Boolean = hudRows().isNotEmpty() private fun PlayerStatsSnapshot.hudRows(): List> = buildList { @@ -2185,25 +2409,25 @@ private fun HudEmptyStatePane(message: String, modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxWidth() - .heightIn(min = HudCardMinHeight - HudPanelPadding * 2) + .heightIn(min = 110.dp) .padding(18.dp), contentAlignment = Alignment.Center, ) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(text = message, style = TvPlayerType.Body) } } /** - * Chapters pane — renders [VersionChapter]s from the active FileVersion as a - * focus-driven picker. Selecting a chapter seeks the player to its start time. + * Chapters pane — the active FileVersion's chapters as a table: number, + * title, start, length. The current chapter carries a play glyph, a tint and + * a progress line, and the list opens on it with focus there, so the chapters + * around "now" are one press away. Selecting a chapter seeks to its start. */ @Composable private fun HudChaptersPane( chapters: List, + positionSec: Double, + durationSec: Double, onSelectChapter: (Int) -> Unit, entryFocusRequester: FocusRequester, modifier: Modifier = Modifier, @@ -2212,68 +2436,155 @@ private fun HudChaptersPane( HudEmptyStatePane("No chapters in this title", modifier) return } - LazyColumn( - modifier = modifier - .fillMaxWidth() - .heightIn(max = 210.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - itemsIndexed( - chapters, - key = { _, chapter -> "${chapter.index}:${chapter.startSeconds}:${chapter.title}" }, - contentType = { _, _ -> "hud-chapter" }, - ) { idx, ch -> - HudChapterRow( - chapter = ch, - onSelect = { onSelectChapter(idx) }, - focusRequester = entryFocusRequester.takeIf { idx == 0 }, - ) + val currentIndex = chapters.indexOfLast { it.startSeconds <= positionSec }.coerceAtLeast(0) + val current = chapters[currentIndex] + val currentEnd = chapterEndSeconds(chapters, currentIndex, durationSec) + // Three chapters of context above the current one, as in the mockup. + val listState = rememberLazyListState(initialFirstVisibleItemIndex = (currentIndex - 3).coerceAtLeast(0)) + + Column(modifier = modifier.fillMaxWidth()) { + HudEyebrow( + text = if (chapters.size == 1) "1 chapter" else "${chapters.size} chapters", + trailing = if (currentEnd > positionSec) { + "${chapterTitle(current)} · ${formatTime(currentEnd - positionSec)} left in this chapter" + } else { + null + }, + ) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .hudFadingEdges(listState, top = 30.dp, bottom = 40.dp), + ) { + itemsIndexed( + chapters, + key = { _, chapter -> "${chapter.index}:${chapter.startSeconds}:${chapter.title}" }, + contentType = { _, _ -> "hud-chapter" }, + ) { idx, ch -> + val end = chapterEndSeconds(chapters, idx, durationSec) + HudChapterRow( + number = idx + 1, + chapter = ch, + lengthSec = (end - ch.startSeconds).coerceAtLeast(0.0), + progress = if (idx == currentIndex && end > ch.startSeconds) { + ((positionSec - ch.startSeconds) / (end - ch.startSeconds)).toFloat().coerceIn(0f, 1f) + } else { + null + }, + onSelect = { onSelectChapter(idx) }, + focusRequester = entryFocusRequester.takeIf { idx == currentIndex }, + ) + } } } } +/** A chapter's end: its own end time, else the next start, else the runtime. */ +private fun chapterEndSeconds(chapters: List, index: Int, durationSec: Double): Double { + val chapter = chapters[index] + return when { + chapter.endSeconds > chapter.startSeconds -> chapter.endSeconds + index + 1 < chapters.size -> chapters[index + 1].startSeconds + else -> durationSec + } +} + +private fun chapterTitle(chapter: VersionChapter): String = + chapter.title.ifBlank { "Chapter ${chapter.index + 1}" } + @Composable private fun HudChapterRow( + number: Int, chapter: VersionChapter, + lengthSec: Double, + /** Set on the current chapter only: how far into it playback is. */ + progress: Float?, onSelect: () -> Unit, focusRequester: FocusRequester? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val isCurrent = progress != null + val colors = hudRowColors(focused = isFocused, current = isCurrent) + val numberColor = if (isFocused) TvPlayerChrome.InkMuted else TvPlayerChrome.Graphite - val bg = if (isFocused) Color.White.copy(alpha = 0.12f) else Color.Transparent - val fg = if (isFocused) Color.White else Color.White.copy(alpha = 0.86f) - - Row( + Box( modifier = Modifier .fillMaxWidth() .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .clip(RoundedCornerShape(12.dp)) - .background(bg) + .heightIn(min = 35.dp) + .clip(RoundedCornerShape(HudRowCorner)) + .background(colors.background) .clickable(enabled = true, interactionSource = interactionSource, indication = null) { onSelect() } - .padding(horizontal = 14.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + .semantics { selected = isCurrent }, ) { - Text( - text = formatTime(chapter.startSeconds), - color = fg.copy(alpha = 0.72f), - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), - ) - Text( - text = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, - color = fg, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.SemiBold, - ), - modifier = Modifier.weight(1f), - ) + Row( + modifier = Modifier + .matchParentSize() + .padding(horizontal = HudRowPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.width(42.dp)) { + if (isCurrent && !isFocused) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Now playing", + tint = TvPlayerChrome.Paper, + modifier = Modifier.size(18.dp), + ) + } else { + Text( + text = number.toString().padStart(2, '0'), + style = TvPlayerType.Figures.copy(fontWeight = FontWeight.SemiBold), + color = numberColor, + ) + } + } + Text( + text = chapterTitle(chapter), + style = TvPlayerType.Row.copy(fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Medium), + color = colors.content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = formatTime(chapter.startSeconds), + style = TvPlayerType.Figures, + color = colors.detail, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(70.dp), + ) + Text( + text = formatTime(lengthSec), + style = TvPlayerType.Figures, + color = colors.detail, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(62.dp), + ) + } + if (progress != null) { + // Under the title column only, so it reads as the chapter's own bar. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = HudRowPadding + 42.dp, end = HudRowPadding + 140.dp, bottom = 3.dp) + .fillMaxWidth() + .height(2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(if (isFocused) TvPlayerChrome.Ink.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.14f)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress) + .height(2.dp) + .background(if (isFocused) TvPlayerChrome.Ink else TvPlayerChrome.Paper), + ) + } + } } } @@ -2288,7 +2599,7 @@ internal data class HudPickerOption( val colorHex: String? = null, /** A short second line under the label, such as a subtitle's sync status. */ val detail: String? = null, - /** Secondary text at the row's end, before the check, such as a bitrate. */ + /** A short value at the row's end, such as "1×" beside "Normal" or a bitrate. */ val trailing: String? = null, ) @@ -2298,6 +2609,8 @@ internal data class HudPickerOption( */ internal data class HudPickerPresentation( val title: String, + /** Names where the picker came from ("Video"); the HUD fills in its tab. */ + val eyebrow: String? = null, val options: List, val selectedId: String, val focusedId: String = selectedId, @@ -2306,9 +2619,6 @@ internal data class HudPickerPresentation( val onSelect: (String) -> Unit, ) -private const val TIMING_SYNC = "sync" -private const val TIMING_RESET = "reset" - /** * The Timing row's value: the refusal or failure when there is one, else the * sync state. The refusal is shortened to fit the row; the line under it says @@ -2327,7 +2637,7 @@ private fun subtitleTimingValue(timing: SubtitleTimingActions): String = when { */ @Composable private fun HudSubtitleTimingDetail(timing: SubtitleTimingActions) { - val modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp) + val modifier = Modifier.padding(horizontal = HudRowPadding, vertical = 4.dp) val percent = timing.percent if (timing.inProgress && percent != null) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -2348,8 +2658,8 @@ private fun HudSubtitleTimingDetail(timing: SubtitleTimingActions) { private fun HudTimingText(text: String, color: Color? = null, modifier: Modifier = Modifier) { Text( text = text, - color = color ?: Color.White.copy(alpha = 0.55f), - style = MaterialTheme.typography.bodyMedium.copy(fontSize = HudMetaTextSize), + color = color ?: TvPlayerChrome.Graphite, + style = TvPlayerType.RowDetail, modifier = modifier, ) } @@ -2373,10 +2683,9 @@ private fun delayPicker( } /** - * Drill-in setting row: label + current value + chevron, with an inverted - * capsule on focus (white fill / dark text). Activating (Select) runs - * [onActivate], which the caller wires to present a [HudPickerDialog]. Mirrors - * tvOS `HUDFocusedSettingRow`. + * Drill-in setting row: label, current value, chevron, inverting to Paper on + * focus. Activating (Select) runs [onActivate], which the caller wires to + * present a [HudPickerDialog] or a page. Mirrors tvOS `HUDFocusedSettingRow`. */ @Composable internal fun HudFocusedSettingRow( @@ -2391,13 +2700,12 @@ internal fun HudFocusedSettingRow( * [focusRequester] so a pane can keep its own handle on the row too. */ entryFocusRequester: FocusRequester? = null, - leftFocusRequester: FocusRequester? = null, - rightFocusRequester: FocusRequester? = null, /** * False for a toggle row: Select flips the value in place, so there is no * drill-in to advertise. Mirrors tvOS HUDToggleRow (showsChevron: false). */ showsChevron: Boolean = true, + modifier: Modifier = Modifier, onActivate: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -2408,25 +2716,17 @@ internal fun HudFocusedSettingRow( val selfFocusRequester = remember { FocusRequester() } val registerPickerReturnFocus = LocalHudPickerReturnFocus.current - val bg = if (isFocused) Color.White else Color.Transparent - val labelColor = if (isFocused) Color.Black else Color.White - val valueColor = if (isFocused) Color.Black.copy(alpha = 0.78f) else Color.White.copy(alpha = 0.72f) - val chevronColor = if (isFocused) Color.Black.copy(alpha = 0.55f) else Color.White.copy(alpha = 0.45f) + val colors = hudRowColors(focused = isFocused) val rowAlpha = if (enabled) 1f else 0.35f Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .focusRequester(selfFocusRequester) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then(if (entryFocusRequester != null) Modifier.focusRequester(entryFocusRequester) else Modifier) - .focusProperties { - if (leftFocusRequester != null) left = leftFocusRequester - if (rightFocusRequester != null) right = rightFocusRequester - } .graphicsLayer { alpha = rowAlpha } - .clip(RoundedCornerShape(8.dp)) - .background(bg) + .hudRow(colors.background) .clickable( enabled = enabled, interactionSource = interactionSource, @@ -2435,7 +2735,7 @@ internal fun HudFocusedSettingRow( registerPickerReturnFocus?.invoke(selfFocusRequester) onActivate() } - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = HudRowPadding, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { // A single weighted spacer used to be the only thing between label and @@ -2448,12 +2748,8 @@ internal fun HudFocusedSettingRow( // and ellipsizes when the row is cramped. Text( text = label, - color = labelColor, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), + style = TvPlayerType.Row, + color = colors.content, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -2461,7 +2757,7 @@ internal fun HudFocusedSettingRow( Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), ) { if (colorHex != null) { Box( @@ -2478,35 +2774,26 @@ internal fun HudFocusedSettingRow( // fill = false keeps short values grouped against the right edge. Text( text = value, - color = valueColor, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.SemiBold, - ), + style = TvPlayerType.Value, + color = colors.detail, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (showsChevron) { - Icon( - imageVector = Icons.Filled.ChevronRight, - contentDescription = null, - tint = chevronColor, - modifier = Modifier.size(11.dp), - ) - } + if (showsChevron) HudChevron(colors.chevron) } } } /** - * Centered modal option list mirroring tvOS `HUDPickerDialog`: a dark card with - * a title and a scrollable list of options; the selected option shows a - * checkmark, focus auto-lands on the selected option and scrolls it into view, - * Select commits the option + closes, and Back closes (handled by the HUD's - * key handler). Each option commits on explicit Select (click), not focus, so - * D-pad traversal doesn't change the value. + * Centered modal option list mirroring tvOS `HUDPickerDialog`: an opaque card + * with an eyebrow naming where it came from, a title, and the options, each + * with a leading check column so the check stays visible on a focused row. + * The card sizes to its options up to the screen, and scrolls past that. + * Focus auto-lands on the selected option and scrolls it into view, Select + * commits the option and closes, and Back closes (handled by the HUD's key + * handler). Options commit on explicit Select, not focus, so D-pad traversal + * doesn't change the value. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -2522,6 +2809,7 @@ internal fun HudPickerDialog( .takeIf { it >= 0 } ?: selectedIndex.coerceAtLeast(0) val focusRequester = remember { FocusRequester() } + val listScroll = rememberScrollState() // Auto-focus the selected option on appear. Because every option is in the // focus graph, Compose's scroll container brings that focused row onscreen. @@ -2534,32 +2822,31 @@ internal fun HudPickerDialog( modifier = modifier .then(optionFocusModifier) .width(360.dp) - .heightIn(max = 220.dp) - .clip(RoundedCornerShape(14.dp)) - .background(DarkSurfaceElevated.copy(alpha = 0.98f)) - .border(0.5.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(14.dp)) + .heightIn(max = 470.dp) + .tvDialogSurface(RoundedCornerShape(HudPanelCorner)) .focusGroup() .focusProperties { exit = { FocusRequester.Cancel } } - .padding(horizontal = 18.dp, vertical = 14.dp), + .padding(start = 14.dp, end = 14.dp, top = 20.dp, bottom = 14.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = presentation.title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall.copy( - fontSize = HudTitleTextSize, - lineHeight = HudTitleLineHeight, - fontWeight = FontWeight.SemiBold, - ), - ) + Column { + Column(modifier = Modifier.padding(start = HudRowPadding, end = HudRowPadding, bottom = 12.dp)) { + presentation.eyebrow?.let { eyebrow -> + Text( + text = eyebrow.uppercase(), + style = TvPlayerType.Eyebrow, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + Text(text = presentation.title, style = TvPlayerType.DialogTitle) + } // Fully compose this small modal list so every D-pad destination is // present in the focus graph. A lazy list made below-fold rows look // like the end of the modal and either trapped or leaked focus. Column( modifier = Modifier - .heightIn(max = 160.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(4.dp), + .weight(1f, fill = false) + .hudFadingEdges(listScroll) + .verticalScroll(listScroll), ) { options.forEachIndexed { index, option -> key(option.id) { @@ -2592,23 +2879,12 @@ private fun HudPickerOptionRow( val bringIntoViewRequester = remember { BringIntoViewRequester() } val scope = rememberCoroutineScope() val isFocused by interactionSource.collectIsFocusedAsState() - - val bg = when { - isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.14f) - else -> Color.Transparent - } - val fg = when { - isFocused -> Color.Black - isSelected -> Color.White - else -> Color.White - } + val colors = hudRowColors(focused = isFocused, current = isSelected) Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(bg) + .hudRow(colors.background) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .bringIntoViewRequester(bringIntoViewRequester) .onFocusChanged { state -> @@ -2619,14 +2895,15 @@ private fun HudPickerOptionRow( } .clickable(interactionSource = interactionSource, indication = null) { onSelect() } .semantics { this.selected = isSelected } - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = HudRowPadding, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), ) { + HudCheckSlot(checked = isSelected, tint = colors.content) if (option.colorHex != null) { Box( modifier = Modifier - .size(11.dp) + .padding(end = 8.dp) + .size(12.dp) .clip(CircleShape) .background(hexToColor(option.colorHex)) .border(0.5.dp, Color.White.copy(alpha = 0.45f), CircleShape), @@ -2635,44 +2912,31 @@ private fun HudPickerOptionRow( Column(modifier = Modifier.weight(1f)) { Text( text = option.label, - color = fg, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - fontWeight = FontWeight.Medium, - ), + style = TvPlayerType.Row, + color = colors.content, maxLines = 1, overflow = TextOverflow.Ellipsis, ) option.detail?.let { detail -> Text( text = detail, - color = fg.copy(alpha = 0.62f), - style = MaterialTheme.typography.bodyMedium.copy(fontSize = HudMetaTextSize), - maxLines = 1, + style = TvPlayerType.RowDetail, + color = colors.detail, + maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), ) } } option.trailing?.let { trailing -> Text( text = trailing, - color = fg.copy(alpha = 0.62f), - style = MaterialTheme.typography.bodyMedium.copy(fontSize = HudMetaTextSize), + style = TvPlayerType.Value, + color = colors.detail, maxLines = 1, + modifier = Modifier.padding(start = 12.dp), ) } - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = fg, - modifier = Modifier.size(10.dp), - ) - } else if (option.trailing != null) { - // Keeps trailing text aligned with the selected row's. - Spacer(modifier = Modifier.size(10.dp)) - } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index d4f145c919..e4d1b1f13e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -1859,6 +1859,29 @@ fun TvPlayerScreen( subtitleManager.applyAppearance(pv, subtitleAppearance) } + // While the controls are up the cue rises above them (they reach ~207dp + // up the screen) instead of landing on the title and scrubber; while the + // options panel is open the cue is hidden, so nothing reads through it. + val subtitleDensity = androidx.compose.ui.platform.LocalDensity.current + val controlsCoverCues = state.showControls && !state.hudOpen && !state.showNextUp + val cueReachPx = if (controlsCoverCues) with(subtitleDensity) { 216.dp.roundToPx() } else 0 + val latestCueReachPx by rememberUpdatedState(cueReachPx) + LaunchedEffect(playerViewRef, cueReachPx, state.hudOpen) { + val subtitleView = playerViewRef?.subtitleView ?: return@LaunchedEffect + subtitleView.visibility = if (state.hudOpen) android.view.View.INVISIBLE else android.view.View.VISIBLE + liftSubtitlesClearOfControls(subtitleView, cueReachPx) + } + // The subtitle manager resizes and moves the canvas on its own (video + // size, aspect, letterbox), so re-lift after each layout too. + DisposableEffect(playerViewRef) { + val subtitleView = playerViewRef?.subtitleView ?: return@DisposableEffect onDispose { } + val listener = android.view.View.OnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + view.post { liftSubtitlesClearOfControls(view, latestCueReachPx) } + } + subtitleView.addOnLayoutChangeListener(listener) + onDispose { subtitleView.removeOnLayoutChangeListener(listener) } + } + // The video branch of the player's `when` below — the only state in which // the PlayerView is mounted and video-scoped overlays should draw. val videoActive = state.streamUrl != null && state.error == null @@ -2057,9 +2080,11 @@ fun TvPlayerScreen( TvPlayerClockScope(viewModel) { clock -> TvPlayerIdleOverlay( title = state.title, - episodeTag = state.seasonNumber?.let { season -> - state.episodeNumber?.let { ep -> "S$season·E$ep" } - }, + eyebrow = tvPlayerEyebrow(state), + subtitlesValue = subtitlePresentation.rows + .firstOrNull { row -> row.checked } + ?.let { row -> tvSubtitleRowParts(row.label).title } + ?: "Off", positionSec = clock.position, durationSec = clock.duration, isPaused = state.isPaused, @@ -2082,6 +2107,7 @@ fun TvPlayerScreen( skipBackSeconds = seekIntervals.backSeconds, skipForwardSeconds = seekIntervals.forwardSeconds, canToggleAfterCommit = watchParty == null, + playbackSpeed = if (watchParty != null) 1.0 else playbackSpeed, onSkipBack = { if (canSeekInRoom) { performRelativeSeek( @@ -2154,14 +2180,10 @@ fun TvPlayerScreen( } if (!isInPictureInPictureMode && state.hudOpen) { - // Floating top-center card — no full-screen scrim so video - // stays visible behind it. Mirrors tvOS TVPlayerInfoHUD. - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 56.dp), - contentAlignment = androidx.compose.ui.Alignment.TopCenter, - ) { + // Floating top-center rail and card over a light scrim, so + // the picture stays watchable behind it. The HUD fills the + // screen and places itself; mirrors tvOS TVPlayerInfoHUD. + Box(modifier = Modifier.fillMaxSize()) { TvPlayerClockScope(viewModel) { clock -> TvPlayerHud( title = state.title, @@ -2292,26 +2314,24 @@ fun TvPlayerScreen( !state.hudOpen && !state.showNextUp ) { // Controls hidden: align the transient line with the REAL - // scrubber track's position inside the idle overlay, which - // stacks (bottom-up): 40dp overlay padding + 33dp transport - // cluster + 16dp gap + 8dp spacer + 16dp gap = 113dp to the - // scrubber COLUMN's bottom — plus ~6dp because the 3.5dp - // track is centered in the column's lower box (41dp minus - // label row), not flush with its bottom. + // scrubber track inside the idle overlay, which stacks + // (bottom-up): 26dp inset + 44dp transport + 16dp gap + + // 18dp clock row + 8dp gap = 112dp to the 28dp bar box, whose + // track is centred in it — ~126dp — less half the chip's own + // 4dp track. // // Controls visible: the live scrubber already reports - // position, so the chip drops its own track and rises into - // the 42dp gap between that column's top (113 + 41) and the - // title block at 196dp. Centred, so it clears the + // position, so the chip drops its own track and rises above + // the title block (~207dp). Centred, so it clears the // left-aligned title at any title width. - // Horizontal 80dp matches the track width in both cases. + // Horizontal 48dp matches the track width in both cases. Box( modifier = Modifier .fillMaxSize() .padding( - start = 80.dp, - end = 80.dp, - bottom = if (state.showControls) 154.dp else 119.dp, + start = TvPlayerChrome.SideInset, + end = TvPlayerChrome.SideInset, + bottom = if (state.showControls) 226.dp else 124.dp, ), contentAlignment = Alignment.BottomCenter, ) { @@ -2687,7 +2707,10 @@ private fun TvSiloCastPlayerRegistration( @Composable private fun TvPlayerIdleOverlay( title: String, - episodeTag: String?, + // "MOVIE · 2026" or "SEVERANCE · S2:E4" above the title. + eyebrow: String?, + // What the Subtitles button says when focused ("English", "Off"). + subtitlesValue: String?, positionSec: Double, durationSec: Double, isPaused: Boolean, @@ -2730,6 +2753,7 @@ private fun TvPlayerIdleOverlay( * playback applies both locally and in order, so it keeps the behaviour. */ canToggleAfterCommit: Boolean = true, + playbackSpeed: Double = 1.0, ) { val scrubberFocus = remember { FocusRequester() } val playPauseFocus = remember { FocusRequester() } @@ -2807,29 +2831,52 @@ private fun TvPlayerIdleOverlay( } }, ) { - // Bottom gradient scrim — 240dp tall, ~0.55 black at the bottom edge, - // fading to transparent so video content above stays visible. + // Bottom gradient scrim — 270dp tall, darkest at the bottom edge where + // the controls sit, clear above so the paused picture stays visible. Box( modifier = Modifier .fillMaxWidth() - .height(240.dp) + .height(270.dp) .align(Alignment.BottomCenter) .background( Brush.verticalGradient( 0.00f to Color.Transparent, - 0.40f to Color.Black.copy(alpha = 0.30f), - 1.00f to Color.Black.copy(alpha = 0.55f), + 0.56f to Color.Black.copy(alpha = 0.55f), + 1.00f to Color.Black.copy(alpha = 0.86f), ), ), ) + // Bottom-up: 26dp inset, 44dp transport, 16dp, scrubber (28dp bar + // + 8dp + clock row), 16dp, then the eyebrow and title. The skip + // indicator and the subtitle lift below are placed from these numbers. Column( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) - .padding(horizontal = 80.dp, vertical = 40.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding( + start = TvPlayerChrome.SideInset, + end = TvPlayerChrome.SideInset, + bottom = TvPlayerChrome.BottomInset, + ), ) { + if (title.isNotBlank()) { + if (!eyebrow.isNullOrBlank()) { + androidx.tv.material3.Text( + text = eyebrow.uppercase(), + style = TvPlayerType.Eyebrow, + maxLines = 1, + ) + } + androidx.tv.material3.Text( + text = title, + style = TvPlayerType.Title, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), + ) + } + // Interactive scrubber — capsule track with chapter ticks, interval // skip, hold-to-auto-seek, and Select to commit. tvOS spec §4.1. TvPlayerScrubber( @@ -2869,6 +2916,7 @@ private fun TvPlayerIdleOverlay( onRequestFocus = scrubberFocus, onPlayPause = onPlayPause, canToggleAfterCommit = canToggleAfterCommit, + playbackSpeed = playbackSpeed, onMoveDownToTransport = { playPauseFocus.claimFocusOrReport( target = "player_transport", @@ -2878,10 +2926,11 @@ private fun TvPlayerIdleOverlay( onExitWhenIdle = onClose, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) TvPlayerTransportCluster( modifier = Modifier.onFocusChanged { transportHasFocus = it.hasFocus }, + subtitlesValue = subtitlesValue, isPlaying = !isPaused, skipBackSeconds = skipBackSeconds, skipForwardSeconds = skipForwardSeconds, @@ -2902,45 +2951,23 @@ private fun TvPlayerIdleOverlay( ) } - // Quiet bottom-left title footer above the scrubber column (tvOS - // titleFooter idiom) — series / title / episode tag, shadowed, no box, - // no "Playing" literal. Sits above the transport stack's top padding. - if (title.isNotBlank()) { - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 80.dp, bottom = 196.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - androidx.tv.material3.Text( - text = title, - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.titleMedium.copy( - shadow = androidx.compose.ui.graphics.Shadow( - color = Color.Black.copy(alpha = 0.55f), - offset = androidx.compose.ui.geometry.Offset(0f, 1f), - blurRadius = 6f, - ), - ), - ) - if (episodeTag != null) { - androidx.tv.material3.Text( - text = episodeTag, - color = Color.White.copy(alpha = 0.62f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - ) - } - } - } - } - } } +/** + * The small line above the player title: "SEVERANCE · S2:E4" for an episode + * (whose title is the episode's own), "MOVIE · 2026" for a film. + */ +internal fun tvPlayerEyebrow(state: TvPlayerViewModel.UiState): String? { + val series = state.seriesTitle?.takeIf { it.isNotBlank() } + if (series != null) { + val code = state.seasonNumber?.let { season -> state.episodeNumber?.let { "S$season:E$it" } } + return listOfNotNull(series, code).joinToString(" · ") + } + val kind = "Movie".takeIf { state.contentType == "movie" } + return listOfNotNull(kind, state.year?.toString()).joinToString(" · ").ifBlank { null } +} + private fun formatSleepCountdown(seconds: Int): String { val s = seconds.coerceAtLeast(0) val m = s / 60 @@ -3936,3 +3963,21 @@ private fun TvPlayerOverlays( } } } + +/** + * Pads the cue canvas's bottom so cues sit at least [reachPx] above the + * bottom of the screen, clear of the controls; 0 clears the lift. + */ +private fun liftSubtitlesClearOfControls(subtitleView: android.view.View, reachPx: Int) { + val liftPx = if (reachPx > 0) { + val location = IntArray(2) + subtitleView.getLocationInWindow(location) + val belowView = subtitleView.rootView.height - (location[1] + subtitleView.height) + (reachPx - belowView).coerceIn(0, subtitleView.height / 2) + } else { + 0 + } + if (subtitleView.paddingBottom != liftPx) { + subtitleView.setPadding(subtitleView.paddingLeft, subtitleView.paddingTop, subtitleView.paddingRight, liftPx) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScrubber.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScrubber.kt index bf8456fb0a..bc658258d5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScrubber.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScrubber.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.tv.ui.screens.player +import androidx.compose.foundation.Canvas +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.LocalContext import android.os.SystemClock import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -133,6 +136,8 @@ fun TvPlayerScrubber( onPlayPause: () -> Unit, /** See TvPlayerIdleOverlay.canToggleAfterCommit. */ canToggleAfterCommit: Boolean = true, + /** The current rate, so the wall-clock finish time matches what the viewer will see. */ + playbackSpeed: Double = 1.0, onMoveDownToTransport: () -> Unit, onExitWhenIdle: () -> Unit, modifier: Modifier = Modifier, @@ -240,7 +245,11 @@ fun TvPlayerScrubber( } else 0f val trackHeight by animateDpAsState( - targetValue = if (isTimelineScrubbing) 6.dp else 3.5.dp, + targetValue = when { + isTimelineScrubbing -> 7.dp + isFocused -> 5.dp + else -> 4.dp + }, animationSpec = tween(120), label = "scrubberTrackHeight", ) @@ -254,37 +263,17 @@ fun TvPlayerScrubber( label = "scrubberPuckSize", ) - Column( - modifier = modifier.height(41.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatScrubberTime(labelPositionSec), - color = Color.White.copy(alpha = 0.82f), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), - ) - Text( - text = formatRemainingTime(durationSec - labelPositionSec), - color = Color.White.copy(alpha = 0.70f), - style = MaterialTheme.typography.labelLarge, - ) - } - - Box(modifier = Modifier.weight(1f)) { + // The bar sits above its clock row, as on tvOS: elapsed on the left, time + // left and the wall-clock finish on the right. + val context = LocalContext.current + val timeFormat = remember(context) { android.text.format.DateFormat.getTimeFormat(context) } + Column(modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth().height(28.dp)) { BoxWithConstraints( modifier = Modifier .fillMaxWidth() .align(Alignment.Center) - // Required, not preferred: the column above hands this box - // ~14dp once the clock row has taken its share, and a plain - // height() is coerced to that, squashing the puck and - // clipping the auto-seek chip. .requiredHeight(28.dp) .focusRequester(onRequestFocus) .onFocusChanged { /* state collected via interactionSource */ } @@ -383,99 +372,52 @@ fun TvPlayerScrubber( val barWidthPx = with(density) { barWidthDp.toPx() } val puckSizePx = with(density) { puckSize.toPx() } - // Track (unfocused 0.24, focused 0.35, scrubbing 0.48 — spec). - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background( - Color.White.copy( - alpha = when { - isTimelineScrubbing -> 0.48f - isFocused -> 0.35f - else -> 0.24f - }, - ), - ), - ) - - // Marker bands — intro/recap/credits/preview, each a tinted band on - // the track. Drawn above the bare track but below the played fill / - // ticks so the playhead still reads clearly over it. - if (durationSec > 0) { - val bandAlpha = if (isTimelineScrubbing || isFocused) 0.45f else 0.34f - val markers = listOfNotNull( - introRangeSec?.let { it to Color.Cyan }, + // Track, marker bands, buffered-ahead and played fill, drawn as one + // segmented bar: chapter starts become 2dp gaps rather than ticks. + val trackAlpha = when { + isTimelineScrubbing -> 0.45f + isFocused -> 0.32f + else -> 0.22f + } + val bandAlpha = if (isTimelineScrubbing || isFocused) 0.45f else 0.34f + val markerBands = if (durationSec > 0) { + listOfNotNull( + introRangeSec?.let { it to Color(0xFF22D3EE) }, recapRangeSec?.let { it to Color(0xFF8BC34A) }, creditsRangeSec?.let { it to Color(0xFFFFB74D) }, previewRangeSec?.let { it to Color(0xFFBA68C8) }, - ) - for ((range, color) in markers) { - val start = (range.start / durationSec).toFloat().coerceIn(0f, 1f) - val end = (range.endInclusive / durationSec).toFloat().coerceIn(0f, 1f) - if (end > start) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = barWidthDp * start) - .fillMaxWidth(end - start) - .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background(color.copy(alpha = bandAlpha)), - ) - } + ).map { (range, color) -> + Triple( + (range.start / durationSec).toFloat().coerceIn(0f, 1f), + (range.endInclusive / durationSec).toFloat().coerceIn(0f, 1f), + color.copy(alpha = bandAlpha), + ) } + } else { + emptyList() } - - // Played fill — pure white, follows the preview while scrubbing. - Box( + val chapterFractions = if (durationSec > 0) { + chapters.map { (it.timeSec / durationSec).toFloat() } + } else { + emptyList() + } + Canvas( modifier = Modifier - .fillMaxWidth(totalProgress) - .align(Alignment.CenterStart) + .fillMaxWidth() .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White), - ) - - // Buffered-ahead sliver: only the region between playhead and - // end-of-buffer. Width can be 0 (live HLS, transcode start). - val bufferedAheadFrac = (bufferedFrac - totalProgress).coerceAtLeast(0f) - if (bufferedAheadFrac > 0f) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = barWidthDp * totalProgress) - .fillMaxWidth(bufferedAheadFrac) - .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.28f)), + .align(Alignment.Center), + ) { + drawTvSegmentedTrack( + chapterFractions = chapterFractions, + trackColor = Color.White.copy(alpha = trackAlpha), + bufferedColor = Color.White.copy(alpha = 0.20f), + playedColor = TvPlayerChrome.Paper, + playedFraction = totalProgress, + bufferedFraction = bufferedFrac.coerceAtLeast(totalProgress), + bands = markerBands, ) } - // Chapter marker ticks — skip the chapter-0 tick at x≈0 so it doesn't - // sit under the capsule endcap. Iterate with `for` (rather than - // `forEach`) so the lambda body keeps composable scope. - if (durationSec > 0) { - for (ch in chapters) { - val frac = (ch.timeSec / durationSec).toFloat().coerceIn(0f, 1f) - if (frac > 0.001f) { - Box( - modifier = Modifier - // CenterStart, not Center: `offset` is anchor-relative, - // so Center adds half the bar width to every tick. - .align(Alignment.CenterStart) - .offset(x = barWidthDp * frac - 1.5.dp) - .width(if (isTimelineScrubbing) 3.dp else 2.dp) - .height(trackHeight + 8.dp) - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.45f)), - ) - } - } - } - // Puck — only while focused so the bar stays passive when // attention is elsewhere on the overlay. if (isFocused || isTimelineScrubbing) { @@ -520,9 +462,76 @@ fun TvPlayerScrubber( ) } } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatScrubberTime(labelPositionSec), + style = TvPlayerType.Time, + modifier = Modifier.weight(1f), + ) + val remainingSec = (durationSec - labelPositionSec).coerceAtLeast(0.0) + val wallClockRemainingSec = if (playbackSpeed > 0.0) remainingSec / playbackSpeed else remainingSec + Text( + text = listOfNotNull( + formatRemainingTime(remainingSec), + finishTimeLabel(timeFormat, wallClockRemainingSec)?.let { "Ends $it" }, + ).joinToString(" · "), + style = TvPlayerType.Time.copy(color = TvPlayerChrome.Graphite), + ) + } + } +} + +/** + * The bar as segments split at chapter starts. Gaps are dropped when a title + * has so many short chapters that the segments would stop reading as bars. + */ +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawTvSegmentedTrack( + chapterFractions: List, + trackColor: Color, + bufferedColor: Color, + playedColor: Color, + playedFraction: Float, + bufferedFraction: Float, + bands: List>, +) { + val width = size.width + val height = size.height + val radius = androidx.compose.ui.geometry.CornerRadius(height / 2f) + val gap = 2.dp.toPx() + val boundaries = chapterFractions.filter { it > 0.001f && it < 0.999f }.sorted() + val edges = (listOf(0f) + boundaries + listOf(1f)).map { it * width } + val useGaps = boundaries.isNotEmpty() && edges.zipWithNext().all { (a, b) -> b - a >= height * 2f + gap } + val segments = if (useGaps) edges.zipWithNext() else listOf(0f to width) + segments.forEachIndexed { index, (rawStart, rawEnd) -> + val start = if (index == 0) rawStart else rawStart + gap / 2f + val end = if (index == segments.lastIndex) rawEnd else rawEnd - gap / 2f + val topLeft = androidx.compose.ui.geometry.Offset(start, 0f) + val segmentSize = androidx.compose.ui.geometry.Size(end - start, height) + fun fill(color: Color, untilX: Float, fromX: Float = start) { + if (untilX <= fromX) return + clipRect(left = fromX, top = 0f, right = untilX.coerceAtMost(end), bottom = height) { + drawRoundRect(color = color, topLeft = topLeft, size = segmentSize, cornerRadius = radius) + } + } + drawRoundRect(color = trackColor, topLeft = topLeft, size = segmentSize, cornerRadius = radius) + fill(bufferedColor, bufferedFraction * width) + bands.forEach { (from, to, color) -> fill(color, untilX = to * width, fromX = maxOf(start, from * width)) } + fill(playedColor, playedFraction * width) } } +/** "11:42 PM" in the device's 12/24-hour [timeFormat], [remainingSec] of wall-clock time from now. */ +private fun finishTimeLabel(timeFormat: java.text.DateFormat, remainingSec: Double): String? { + if (remainingSec <= 0.0 || !remainingSec.isFinite()) return null + val finish = java.util.Date(System.currentTimeMillis() + (remainingSec * 1000).toLong()) + return timeFormat.format(finish) } @Composable @@ -555,7 +564,7 @@ private fun TvScrubberAutoSeekChip(rate: Int, modifier: Modifier = Modifier) { private fun formatScrubberTime(seconds: Double): String = formatTimelineClock(seconds) private fun formatRemainingTime(secondsRemaining: Double): String = - "-${formatTimelineClock(secondsRemaining.coerceAtLeast(0.0))}" + "−${formatTimelineClock(secondsRemaining.coerceAtLeast(0.0))}" private fun formatTimelineClock(seconds: Double): String { if (seconds <= 0 || seconds.isNaN()) return "0:00" diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt index 9850796f37..cfabb85cca 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt @@ -1,25 +1,32 @@ package org.siloserver.silo.tv.ui.screens.player import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.Subtitles +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.ui.draw.shadow +import androidx.tv.material3.Text import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ClosedCaption -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.Tune import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -27,8 +34,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -47,13 +54,13 @@ import org.siloserver.silo.tv.ui.components.TvSeekIntervalIcon * Bottom transport row mirroring `iosApp/.../tvOS/TVPlayerTransportCluster.swift`. * * Primary group (skipBack / playPause / skipForward) pinned left; secondary - * group (options / close) pushed right. Uniform circular buttons that flip - * white-on-black ↔ black-on-white when focused (white-fill inversion only — no - * focus scale). The skip buttons use the profile-wide video intervals - * (10s back / 30s forward on older servers). There is no Back button - * and no separate subtitles button — `options` (⋯) opens the floating HUD, - * whose Subtitles tab now owns the track/style/delay controls; `close` (xmark) - * exits the player. Up returns focus to the scrubber. + * group (up next / subtitles / options / close) pushed right. At rest every + * button is a dark disc with a faint ring, so it reads over a letterbox bar as + * well as over the picture. Focus inverts to Paper with an Ink glyph — no + * scale — and a secondary button widens into a labeled pill that also says + * what it is set to ("Subtitles · English"), the tvOS detail-page pattern. + * The skip buttons use the profile-wide video intervals (10s back / 30s + * forward on older servers). Up returns focus to the scrubber. */ @Composable fun TvPlayerTransportCluster( @@ -76,7 +83,14 @@ fun TvPlayerTransportCluster( // Resolved video intervals behind [onSkipBack]/[onSkipForward]. skipBackSeconds: Int = 10, skipForwardSeconds: Int = 30, + // What the Subtitles button reveals when focused ("English", "Off"). + subtitlesValue: String? = null, ) { + // The groups sit at opposite ends of the row. Left/Right cross the gap + // between them explicitly; a plain search from skip-forward can land on + // the scrubber above instead. + val skipForwardFocus = remember { FocusRequester() } + val firstSecondaryFocus = remember { FocusRequester() } Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -93,7 +107,7 @@ fun TvPlayerTransportCluster( ) DockGap() TransportIconButton( - icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + icon = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, description = if (isPlaying) "Pause" else "Play", onClick = onPlayPause, focusRequester = playPauseFocus, @@ -106,6 +120,8 @@ fun TvPlayerTransportCluster( seekGlyph = SeekGlyph(forward = true, seconds = skipForwardSeconds), description = "Skip forward $skipForwardSeconds seconds", onClick = onSkipForward, + focusRequester = skipForwardFocus, + rightNeighbor = firstSecondaryFocus, onMoveUp = onMoveUpToScrubber, ) } @@ -114,30 +130,39 @@ fun TvPlayerTransportCluster( Row(verticalAlignment = Alignment.CenterVertically) { onUpNext?.let { showUpNext -> TransportIconButton( - icon = Icons.Filled.SkipNext, + icon = Icons.Rounded.SkipNext, description = "Up Next", + label = "Up next", onClick = showUpNext, + focusRequester = firstSecondaryFocus, + leftNeighbor = skipForwardFocus, onMoveUp = onMoveUpToScrubber, ) DockGap() } TransportIconButton( - icon = Icons.Filled.ClosedCaption, + icon = Icons.Rounded.Subtitles, description = "Subtitles", + label = "Subtitles", + value = subtitlesValue, onClick = onOpenQuickSubtitles, + focusRequester = firstSecondaryFocus.takeIf { onUpNext == null }, + leftNeighbor = skipForwardFocus.takeIf { onUpNext == null }, onMoveUp = onMoveUpToScrubber, ) DockGap() TransportIconButton( - icon = Icons.Filled.Tune, + icon = Icons.Rounded.Tune, description = "Info and options", + label = "Options", onClick = onOpenHUD, onMoveUp = onMoveUpToScrubber, ) DockGap() TransportIconButton( - icon = Icons.Filled.Close, + icon = Icons.Rounded.Close, description = "Close player", + label = "Close", onClick = onClose, onMoveUp = onMoveUpToScrubber, ) @@ -147,7 +172,7 @@ fun TvPlayerTransportCluster( @Composable private fun DockGap() { - Spacer(modifier = Modifier.size(width = 5.dp, height = 1.dp)) + Spacer(modifier = Modifier.size(width = 10.dp, height = 1.dp)) } @Composable @@ -156,9 +181,15 @@ private fun TransportIconButton( description: String, onClick: () -> Unit, focusRequester: FocusRequester? = null, + // Explicit Left/Right targets where the default search would go astray. + leftNeighbor: FocusRequester? = null, + rightNeighbor: FocusRequester? = null, isPrimary: Boolean = false, onMoveUp: () -> Unit = {}, seekGlyph: SeekGlyph? = null, + // Shown beside the glyph while focused; null keeps the button a disc. + label: String? = null, + value: String? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -167,22 +198,32 @@ private fun TransportIconButton( val metrics = tvTransportControlMetrics(isPrimary) val buttonSize = metrics.buttonSizeDp.dp val symbolSize = metrics.symbolSizeDp.dp + val shape = RoundedCornerShape(percent = 50) - // Focus is signaled by filling the circle white — no scale transform so the - // buttons never cross the bounds of their circular hit target. - val focusBg by animateColorAsState( - targetValue = if (isFocused) Color.White else Color.Black.copy(alpha = 0.35f), + // Focus inverts the disc to Paper — no scale, so a button never crosses + // its own hit target or nudges its neighbours. + val fill by animateColorAsState( + targetValue = if (isFocused) TvPlayerChrome.Paper else TvPlayerChrome.ButtonRest, animationSpec = tween(120), label = "transportBg", ) - val iconTint = if (isFocused) Color.Black else Color.White + val tint = if (isFocused) TvPlayerChrome.Ink else TvPlayerChrome.Paper + val expanded = isFocused && label != null - Box( + Row( modifier = Modifier - .size(buttonSize) - .clip(CircleShape) - .background(focusBg) + .height(buttonSize) + .widthIn(min = buttonSize) + .then(if (isFocused) Modifier.shadow(10.dp, shape, clip = false) else Modifier) + .clip(shape) + .background(fill) + .then(if (isFocused) Modifier else Modifier.border(1.dp, TvPlayerChrome.ButtonRing, shape)) + .animateContentSize(animationSpec = tween(160)) .let { mod -> if (focusRequester != null) mod.focusRequester(focusRequester) else mod } + .focusProperties { + leftNeighbor?.let { left = it } + rightNeighbor?.let { right = it } + } .focusable(interactionSource = interactionSource) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false @@ -199,27 +240,42 @@ private fun TransportIconButton( } } .semantics { - contentDescription = description + contentDescription = listOfNotNull(description, value).joinToString(", ") role = Role.Button - }, - contentAlignment = Alignment.Center, + } + .padding(horizontal = if (expanded) 13.dp else 0.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { if (seekGlyph != null) { TvSeekIntervalIcon( forward = seekGlyph.forward, seconds = seekGlyph.seconds, contentDescription = null, - tint = iconTint, + tint = tint, modifier = Modifier.size(symbolSize), ) } else if (icon != null) { Icon( imageVector = icon, contentDescription = null, - tint = iconTint, + tint = tint, modifier = Modifier.size(symbolSize), ) } + if (expanded) { + Spacer(Modifier.width(8.dp)) + Text(text = label.orEmpty(), style = TvPlayerType.ButtonLabel.copy(color = TvPlayerChrome.Ink), maxLines = 1) + if (!value.isNullOrBlank()) { + Spacer(Modifier.width(6.dp)) + Text( + text = value, + style = TvPlayerType.ButtonValue.copy(color = TvPlayerChrome.InkMuted), + maxLines = 1, + ) + } + Spacer(Modifier.width(5.dp)) + } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 1e0ec9df4f..0defc219d4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -997,6 +997,8 @@ class TvPlayerViewModel( val serverUnreachable: Boolean = false, val title: String = "", val seriesTitle: String? = null, + val year: Int? = null, + val contentType: String? = null, /** * Artwork URL for Now Playing lock-screen / Bluetooth / Wear surfaces. * Sourced from `WatchDetail.posterUrl` with `backdropUrl` fallback. @@ -2302,6 +2304,8 @@ class TvPlayerViewModel( contentId = contentId, title = result.title, seriesTitle = result.seriesTitle, + year = result.year, + contentType = result.contentType, artworkUrl = result.artworkUrl, sessionId = result.sessionId, playMethod = result.playMethod, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSkipSeekIndicator.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSkipSeekIndicator.kt index c2baa22bf5..fc2eb2eddb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSkipSeekIndicator.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvSkipSeekIndicator.kt @@ -95,22 +95,22 @@ fun TvSkipSeekIndicator( if (showTrack && snapshot.durationSec > 0.0) { val progress = (snapshot.targetSec / snapshot.durationSec).toFloat().coerceIn(0f, 1f) - // Mirrors TvPlayerScrubber's resting track exactly: 3.5dp - // capsule, White@0.24 rail, solid White fill — so the line is + // Mirrors TvPlayerScrubber's resting track exactly: 4dp + // capsule, White@0.22 rail, solid Paper fill — so the line is // visually the same bar the transport shows in this spot. Box( modifier = Modifier .fillMaxWidth() - .height(3.5.dp) + .height(4.dp) .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.24f)), + .background(Color.White.copy(alpha = 0.22f)), ) { Box( modifier = Modifier .fillMaxWidth(progress) - .height(3.5.dp) + .height(4.dp) .clip(RoundedCornerShape(percent = 50)) - .background(Color.White), + .background(TvPlayerChrome.Paper), ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt index 1b80ab0e01..4cea4bc5a4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -419,6 +419,8 @@ class TvVideoPlaybackStarter( chapters = effectiveVersion?.chapters.orEmpty(), seriesId = watchDetail.seriesId, seriesTitle = watchDetail.seriesTitle, + year = watchDetail.year, + contentType = watchDetail.type, seasonNumber = watchDetail.seasonNumber, episodeNumber = watchDetail.episodeNumber, resolvedEpisodeSelection = resolvedEpisodeSelection, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt index 9d60fa8eef..85877bde09 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsPage.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Inbox -import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -55,7 +54,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.Button import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -79,6 +77,9 @@ import org.siloserver.silo.model.request.requestBackdropUrl import org.siloserver.silo.model.request.requestPosterUrl import org.siloserver.silo.model.section.SectionItem import org.siloserver.silo.repository.RequestsRepository +import org.siloserver.silo.tv.ui.components.TvDialog +import org.siloserver.silo.tv.ui.components.TvDialogAction +import org.siloserver.silo.tv.ui.components.TvDialogActionStyle import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvFocusMarquee import org.siloserver.silo.tv.ui.components.TvHeroFactToken @@ -780,53 +781,41 @@ private fun TvRequestActionDialog( } var confirmingDecline by remember(item) { mutableStateOf(false) } if (confirmingDecline) { - AlertDialog( + // Keep comes first and takes focus, so a stray press doesn't decide on + // someone's request. + TvDialog( + title = "Decline this request?", + message = "${record.title} will show as declined to the person who asked for it.", onDismissRequest = onDismiss, - containerColor = DarkSurfaceElevated, - titleContentColor = SiloOnSurface, - textContentColor = SiloOnSurface.copy(alpha = 0.76f), - title = { Text("Decline this request?", color = SiloOnSurface) }, - text = { - Text( - "${record.title} will show as declined to the person who asked for it.", - color = SiloOnSurface.copy(alpha = 0.76f), - ) - }, - confirmButton = { - // Keep first, so a stray press doesn't decide on someone's request. - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = onDismiss) { Text("Keep") } - Button(onClick = { onAction(TvRequestAction.Decline, record) }) { Text("Decline") } - } - }, + actions = listOf( + TvDialogAction(label = "Keep", onClick = onDismiss), + TvDialogAction( + label = "Decline", + onClick = { onAction(TvRequestAction.Decline, record) }, + style = TvDialogActionStyle.Destructive, + ), + ), ) return } - AlertDialog( + // Close comes first and takes focus, so a stray press doesn't send a + // non-retryable action. + TvDialog( + title = record.title, + message = if (item is TvRequestItem.Approval) "Requested by someone on this server." else RequestProgress.of(record).longLabel, onDismissRequest = onDismiss, - containerColor = DarkSurfaceElevated, - titleContentColor = SiloOnSurface, - textContentColor = SiloOnSurface.copy(alpha = 0.76f), - title = { Text(record.title, color = SiloOnSurface) }, - text = { - Text( - if (item is TvRequestItem.Approval) "Requested by someone on this server." else RequestProgress.of(record).longLabel, - color = SiloOnSurface.copy(alpha = 0.76f), + actions = listOf(TvDialogAction(label = "Close", onClick = onDismiss)) + actions.map { action -> + TvDialogAction( + label = action.label, + // Decline asks first. + onClick = { + if (action == TvRequestAction.Decline) confirmingDecline = true else onAction(action, record) + }, + style = when (action) { + TvRequestAction.Cancel, TvRequestAction.Decline -> TvDialogActionStyle.Destructive + TvRequestAction.Approve, TvRequestAction.Retry -> TvDialogActionStyle.Primary + }, ) }, - confirmButton = { - // Close first, so a stray press doesn't send a non-retryable action. - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = onDismiss) { Text("Close") } - actions.forEach { action -> - Button( - onClick = { - // Decline asks first. - if (action == TvRequestAction.Decline) confirmingDecline = true else onAction(action, record) - }, - ) { Text(action.label) } - } - } - }, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 1886996974..111d48de9a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -63,6 +63,10 @@ import org.siloserver.silo.model.settings.SeekDirection import org.siloserver.silo.model.settings.SeekIntervalSupport import org.siloserver.silo.model.settings.SeekIntervals import org.siloserver.silo.model.settings.SeekMedia +import org.siloserver.silo.tv.ui.components.TvDialog +import org.siloserver.silo.tv.ui.components.TvDialogAction +import org.siloserver.silo.tv.ui.components.TvDialogActionStyle +import org.siloserver.silo.tv.ui.components.tvDialogSurface import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult @@ -315,9 +319,9 @@ fun TvSettingsScreen( if (showSignOutConfirm) { TvSettingsConfirmDialog( - title = "Sign Out", - message = "You will be returned to the login screen.", - confirmLabel = "Sign Out", + title = "Sign out?", + message = "This TV goes back to the sign-in screen. Your profiles and watch history stay on the server.", + confirmLabel = "Sign out", onConfirm = { showSignOutConfirm = false viewModel.onSignOut(context) @@ -1864,7 +1868,7 @@ private fun TvSubtitleSettingsPane( if (showResetConfirmation) { TvSettingsConfirmDialog( - title = "Reset Custom Appearance?", + title = "Reset custom appearance?", message = "This restores all custom subtitle appearance options to their defaults.", confirmLabel = "Reset", onConfirm = { @@ -2113,9 +2117,7 @@ fun TvSettingsPickerSheet( modifier = Modifier .width(380.dp) .heightIn(max = 460.dp) - .clip(PickerCardShape) - .background(PickerCardFill) - .border(1.dp, Color.White.copy(alpha = 0.09f), PickerCardShape), + .tvDialogSurface(), ) { Row( modifier = Modifier @@ -2251,7 +2253,6 @@ private fun TvSettingsPickerOptionRow( // Confirm dialog // --------------------------------------------------------------------------- -@OptIn(ExperimentalTvMaterial3Api::class) @Composable internal fun TvSettingsConfirmDialog( title: String, @@ -2259,105 +2260,23 @@ internal fun TvSettingsConfirmDialog( confirmLabel: String, onConfirm: () -> Unit, onDismiss: () -> Unit, + destructive: Boolean = true, ) { - BackHandler(onBack = onDismiss) - // Default focus lands on Cancel so a stray OK press never triggers the - // destructive action. - val cancelFocus = remember { FocusRequester() } - var confirmHasFocus by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - requestFocusUntilObserved( - maxAttempts = TvContentInitialFocusMaxAttempts, - awaitAttempt = { withFrameNanos { } }, - requestFocus = cancelFocus::requestFocus, - isFocused = { confirmHasFocus }, - ) - } - - Dialog( + // Cancel comes first and takes focus, so a stray OK press never runs the + // action. A destructive action reads red at rest and fills red on focus. + TvDialog( + title = title, + message = message, onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - // tvOS confirmation overlay: a #15171C card over a 62% black scrim. - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.62f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .width(320.dp) - .clip(PickerCardShape) - .background(PickerCardFill) - .border(1.dp, Color.White.copy(alpha = 0.09f), PickerCardShape) - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy(fontSize = 19.sp, lineHeight = 22.sp), - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DialogButton( - label = "Cancel", - onClick = onDismiss, - focusRequester = cancelFocus, - ) - DialogButton( - label = confirmLabel, - onClick = onConfirm, - destructive = true, - ) - } - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun DialogButton( - label: String, - onClick: () -> Unit, - destructive: Boolean = false, - focusRequester: FocusRequester? = null, -) { - val shape = RoundedCornerShape(6.dp) - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = shape), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.08f), - contentColor = if (destructive) MaterialTheme.colorScheme.error else Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, + actions = listOf( + TvDialogAction(label = "Cancel", onClick = onDismiss), + TvDialogAction( + label = confirmLabel, + onClick = onConfirm, + style = if (destructive) TvDialogActionStyle.Destructive else TvDialogActionStyle.Primary, + ), ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - modifier = (focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), - color = if (isFocused) FocusedContent else if (destructive) MaterialTheme.colorScheme.error else Color.White, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } + ) } /** The Experimental group: features that are not finished yet. */ @@ -2468,8 +2387,6 @@ internal val SettingsBackground = Color(0xFF000000) private val PaneHeaderTile = Color(0xFF3A3A3C) /** Picker and confirmation cards (Apple `siloSurfaceElevated`), 30pt corners. */ -private val PickerCardFill = Color(0xFF15171C) -private val PickerCardShape = RoundedCornerShape(15.dp) // tvOS destructive row colors: bright red at rest on black, deeper red on the // focused white platter (TVSettingsRailRowStyle). diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt index cef6f1704e..8da516c944 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -25,11 +24,9 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign @@ -44,11 +41,14 @@ import org.siloserver.silo.common.diagnostics.DiagnosticsConsentMode import org.siloserver.silo.common.diagnostics.DiagnosticsDestinationKind import org.siloserver.silo.common.diagnostics.DiagnosticsUiState import org.siloserver.silo.common.diagnostics.TimedCaptureStatus +import org.siloserver.silo.tv.ui.components.TvDialogDefaults +import org.siloserver.silo.tv.ui.components.tvDialogSurface import org.siloserver.silo.tv.ui.focus.TvControlState import org.siloserver.silo.tv.ui.focus.TvFrameRelocationMaxAttempts import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.tvControlSemantics import org.siloserver.silo.tv.ui.screens.auth.QrCodePanel +import org.siloserver.silo.tv.ui.screens.player.TvPlayerType import org.siloserver.silo.tv.ui.screens.settings.PickerOption import org.siloserver.silo.tv.ui.screens.settings.SettingsActionRow import org.siloserver.silo.tv.ui.screens.settings.SettingsFooterText @@ -386,7 +386,8 @@ internal fun TvDiagnosticsSettingsPane( TvSettingsConfirmDialog( title = "Always send crash reports?", message = "Future eligible reports may upload automatically until you change this setting.", - confirmLabel = "Always Send", + confirmLabel = "Always send", + destructive = false, onConfirm = { confirmAlways = false onSetConsent(DiagnosticsConsentMode.ALWAYS) @@ -424,23 +425,18 @@ internal fun TvPrivacyPolicyDialog(onDismiss: () -> Unit) { Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box( - modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.86f)), + modifier = Modifier.fillMaxSize().background(TvDialogDefaults.Scrim), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier - .width(360.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(20.dp), + .width(380.dp) + .tvDialogSurface() + .padding(horizontal = 28.dp, vertical = 26.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - text = "Privacy Policy", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + Text(text = "Privacy Policy", style = TvPlayerType.DialogTitle) Text( text = "Scan this code with your phone, or type the address below, to read " + "the full policy.", diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt index e80dc40bff..a99a3ab0a0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyComponents.kt @@ -5,10 +5,8 @@ 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.Row 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.LaunchedEffect @@ -16,15 +14,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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 -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color 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.sp import androidx.compose.ui.window.Popup @@ -33,12 +26,10 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.delay import org.siloserver.silo.repository.WatchTogetherConnectionState -import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus -import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved -import org.siloserver.silo.tv.ui.focus.tvModalFocusBoundary -import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated -import org.siloserver.silo.tv.ui.theme.SiloOnSurface -import org.siloserver.silo.tv.ui.theme.SiloSecondaryText +import org.siloserver.silo.tv.ui.components.TvDialogAction +import org.siloserver.silo.tv.ui.components.TvDialogActionStyle +import org.siloserver.silo.tv.ui.components.TvDialogCard +import org.siloserver.silo.tv.ui.components.TvDialogDefaults /** A non-focusable one- or two-line notice (reconnecting, host away, host stopped). */ @Composable @@ -65,9 +56,6 @@ internal fun TvPartyNotice(title: String, modifier: Modifier = Modifier, detail: } } -/** Frames to keep asking for the confirmation's initial focus. */ -private const val CONFIRM_FOCUS_ATTEMPTS = 6 - /** * Two-button confirmation on the party's card. Initial focus goes to Cancel * unless [focusConfirm], so a stray Select never runs a destructive action. @@ -83,21 +71,6 @@ internal fun TvWatchPartyConfirmDialog( destructive: Boolean = false, focusConfirm: Boolean = false, ) { - val confirmFocus = remember { FocusRequester() } - val cancelFocus = remember { FocusRequester() } - var confirmFocused by remember { mutableStateOf(false) } - var cancelFocused by remember { mutableStateOf(false) } - // The focusable popup hands focus to its first control on its own, and - // the shared initial-focus helper then stands down, so move focus to the - // intended button once the buttons exist. - LaunchedEffect(focusConfirm) { - requestFocusUntilObserved( - maxAttempts = CONFIRM_FOCUS_ATTEMPTS, - awaitAttempt = { withFrameNanos { } }, - requestFocus = (if (focusConfirm) confirmFocus else cancelFocus)::requestFocus, - isFocused = { if (focusConfirm) confirmFocused else cancelFocused }, - ) - } Popup( alignment = Alignment.Center, onDismissRequest = onDismiss, @@ -111,58 +84,22 @@ internal fun TvWatchPartyConfirmDialog( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.72f)), + .background(TvDialogDefaults.Scrim), contentAlignment = Alignment.Center, ) { - val shape = RoundedCornerShape(15.dp) - Column( - modifier = Modifier - .width(440.dp) - .background(DarkSurfaceElevated, shape) - .border(1.dp, PartyChromeBorder, shape) - .padding(28.dp) - .tvModalFocusBoundary() - .then(rememberTvDialogInitialFocus(if (focusConfirm) confirmFocus else cancelFocus)), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = title, - fontSize = 22.sp, - fontWeight = FontWeight.SemiBold, - color = SiloOnSurface, - textAlign = TextAlign.Center, - ) - if (message != null) { - Text( - text = message, - fontSize = TvPartyMetrics.body, - color = SiloSecondaryText, - textAlign = TextAlign.Center, - ) - } - Row( - modifier = Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - TvPartyButton( - label = cancelLabel, - kind = TvPartyButtonKind.Secondary, - onClick = onDismiss, - modifier = Modifier - .focusRequester(cancelFocus) - .onFocusChanged { cancelFocused = it.isFocused }, - ) - TvPartyButton( + TvDialogCard( + title = title, + message = message, + actions = listOf( + TvDialogAction(label = cancelLabel, onClick = onDismiss), + TvDialogAction( label = confirmLabel, - kind = if (destructive) TvPartyButtonKind.Secondary else TvPartyButtonKind.Primary, onClick = onConfirm, - modifier = Modifier - .focusRequester(confirmFocus) - .onFocusChanged { confirmFocused = it.isFocused }, - ) - } - } + style = if (destructive) TvDialogActionStyle.Destructive else TvDialogActionStyle.Primary, + ), + ), + initialFocusIndex = if (focusConfirm) 1 else 0, + ) } } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt index 91f1d25f95..7b8bc34427 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt @@ -35,7 +35,7 @@ class TvHudPickerFocusWiringSourceTest { @Test fun pickerKeepsTheEagerFocusGraph() { assertContains(pickerDialog, "Column(") - assertContains(pickerDialog, ".verticalScroll(rememberScrollState())") + assertContains(pickerDialog, ".verticalScroll(") assertFalse(Regex("\\bLazyColumn\\s*\\(").containsMatchIn(pickerDialog.withoutComments())) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt index 42ab90a343..0682f425f0 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt @@ -25,6 +25,10 @@ class TvTypographyReadabilityTest { // server card, and the label inside the "Tap Set up" step picture. "org/siloserver/silo/tv/ui/components/marquee/TvMarquee.kt|fontSize = 9.sp,", "org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt|Text(\"Set up\", color = Color.Black, fontSize = 7.5.sp, fontWeight = FontWeight.Bold)", + // Player chrome at tvOS half scale: the uppercase tracked eyebrow over + // the title and panel sections, and the SDH/FORCED track chips. + "org/siloserver/silo/tv/ui/screens/player/TvPlayerChrome.kt|fontSize = 11.sp,", + "org/siloserver/silo/tv/ui/screens/player/TvPlayerChrome.kt|fontSize = 9.sp,", ) @Test diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/player/SubtitleTrackDisplayLabel.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/player/SubtitleTrackDisplayLabel.kt index 626dc0e8bc..e9b24bdfb0 100644 --- a/shared/src/androidMain/kotlin/org/siloserver/silo/player/SubtitleTrackDisplayLabel.kt +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/player/SubtitleTrackDisplayLabel.kt @@ -44,36 +44,66 @@ fun formatSubtitleTrackDisplayLabel( isForced: Boolean, index: Int, ): String { + val label = subtitleTrackLabelParts(rawLabel, language, codecOrMime, isForced) + val parts = buildList { + label.languageName?.let { add(it) } + if (label.aiGenerated) add("AI Translation") + if (label.forced) add("Forced") + if (label.sdh) add("SDH") + label.descriptor?.let { add(it) } + label.format?.let { add(it) } + label.provider?.let { add(it) } + }.distinctBy { it.lowercase(Locale.US) } + + return parts.takeIf { it.isNotEmpty() }?.joinToString(" • ") + ?: "Subtitle ${index + 1}" +} + +/** + * The facts [formatSubtitleTrackDisplayLabel] joins into one string, kept + * apart so a picker can lay them out: the language as the row title, Forced + * and SDH as chips, the format and provider as a detail line. The provider + * and descriptor are null for an AI translation, which is labelled as one. + */ +data class SubtitleTrackLabelParts( + val languageName: String?, + val forced: Boolean, + val sdh: Boolean, + val aiGenerated: Boolean, + val descriptor: String?, + val format: String?, + val provider: String?, +) + +fun subtitleTrackLabelParts( + rawLabel: String?, + language: String?, + codecOrMime: String?, + isForced: Boolean, +): SubtitleTrackLabelParts { val cleanRaw = rawLabel?.trim().takeUnless { it.isNullOrBlank() } val providerId = providerIdentifier(cleanRaw) - val provider = providerDisplayName(cleanRaw) val labelWithoutProvider = cleanRaw?.replace(providerSuffix, "")?.trim() val inferredLanguage = languageCodeFor(language) ?: inferLanguageCode(labelWithoutProvider) val languageName = languageDisplayName(inferredLanguage) - val format = subtitleFormatLabel(codecOrMime ?: labelWithoutProvider) val forced = isForced || labelWithoutProvider?.let { forcedToken.containsMatchIn(it) } == true val sdh = labelWithoutProvider?.let { sdhToken.containsMatchIn(it) } == true val aiGenerated = providerId == "translated" || labelWithoutProvider?.contains(Regex("""(?i)\bAI\b""")) == true - val descriptor = meaningfulDescriptor( - rawLabel = labelWithoutProvider, + return SubtitleTrackLabelParts( languageName = languageName, forced = forced, sdh = sdh, - ).takeUnless { aiGenerated } - - val parts = buildList { - languageName?.let { add(it) } - if (aiGenerated) add("AI Translation") - if (forced) add("Forced") - if (sdh) add("SDH") - descriptor?.let { add(it) } - format?.let { add(it) } - if (!aiGenerated) provider?.let { add(it) } - }.distinctBy { it.lowercase(Locale.US) } - - return parts.takeIf { it.isNotEmpty() }?.joinToString(" • ") - ?: "Subtitle ${index + 1}" + aiGenerated = aiGenerated, + descriptor = meaningfulDescriptor( + rawLabel = labelWithoutProvider, + languageName = languageName, + forced = forced, + sdh = sdh, + ).takeUnless { aiGenerated }, + format = subtitleFormatLabel(codecOrMime ?: labelWithoutProvider), + provider = providerDisplayName(cleanRaw).takeUnless { aiGenerated }, + ) } private fun providerIdentifier(rawLabel: String?): String? = From 1574897b088983c86cc3f2281bc6b43d1ca44696 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:53:23 -0400 Subject: [PATCH 43/87] fix(tv): close the TV's own playback before a phone takes over (#503) * fix(tv): close the TV's own playback before a phone takes over When a phone started a title through SiloRemote while the Android TV was playing something, the TV installed the phone's temporary profile without stopping that player first. The old player's stop was then held back ("Playback authority changed; stop remains pending"), and the server kept its session alive next to the phone's until the idle timeout. A handoff that installs a new identity now stops the registered player and waits for its queued session stop to finish before preparing the phone's profile. A phone that already holds the TV's profile still goes straight through without interrupting playback. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): stop the TV's playback only after the phone's handoff is approved The first version stopped the TV's title as soon as a handoff offer arrived, before the offer was validated or the server approved it, so a bogus, failed, or denied offer still interrupted playback. prepare() also ended an earlier phone's identity before approval, and the stopped player's own two-second identity cleanup could revoke it while the stop was still in flight. prepare() now takes a beforeActivation step that runs after approval and before anything changes; the receiver stops the player there, then prepare() ends the previous identity and installs the new one. The step runs under the manager's lock, which holds off the stopped player's identity cleanup until the swap is done. A pending cleanup is cancelled only for a reused identity, so a failed handoff still cleans up an idle one. The player registration now signals unregistering after the slot is cleared. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): fail the handoff when the old session's stop does not finish The handoff checked only that the old player had unregistered. If its queued session stop was still running when the teardown limit ran out, the identity swapped underneath it and the stop went out under the phone's profile. The handoff now fails unless the whole teardown wait finished. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../tv/cast/RemotePlaybackIdentityManager.kt | 13 ++- .../silo/tv/cast/TvSiloCastReceiver.kt | 40 ++++++- .../silo/tv/cast/FakeRemotePlaybackApi.kt | 66 ++++++++++++ .../tv/cast/RemotePlaybackHandoffOrderTest.kt | 101 ++++++++++++++++++ .../cast/RemotePlaybackIdentityClaimTest.kt | 62 ----------- 5 files changed, 216 insertions(+), 66 deletions(-) create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/FakeRemotePlaybackApi.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt index de8ffe7610..5122d104ff 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt @@ -54,11 +54,20 @@ class RemotePlaybackIdentityManager( active.controllerDeviceId == controllerDeviceId } + /** + * Installs the phone's temporary identity once the server approves it. + * [beforeActivation] runs after approval and before anything changes, while + * the outgoing identity (the TV's own or an earlier phone's) still owns the + * credentials, so whatever is playing can close its server session under the + * identity it started with. An offer that fails or is denied leaves the + * current identity and playback alone. + */ suspend fun prepare( offer: SiloCastHandoffOffer, controllerDeviceId: String, controllerDeviceName: String?, receiverRun: Long, + beforeActivation: suspend () -> Unit = {}, onChallenge: suspend (SiloCastHandoffChallenge) -> Unit, ): SiloCastHandoffReady = mutex.withLock { validateOffer(offer) @@ -69,8 +78,6 @@ class RemotePlaybackIdentityManager( return@withLock reclaimed.toReady(offer.requestId, reused = true) } - endLocked() - val capability = deviceLoginApi.remotePlaybackCapabilityAt(offer.serverURL).successOrThrow() require(capability.remotePlaybackHandoff && SiloCastProtocol.version in capability.protocolVersions) { "The phone's Silo server does not support remote playback handoff." @@ -111,6 +118,8 @@ class RemotePlaybackIdentityManager( } val expiresAtMs = poll.sessionExpiresAt?.let(::parseInstantMillis) ?: (System.currentTimeMillis() + DEFAULT_SESSION_MS) + beforeActivation() + endLocked() val generationId = UUID.randomUUID().toString() tokenManager.beginTemporaryScope( TemporaryAuthScope( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index 6c12bc940e..3dc0eeace0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -5,6 +5,7 @@ import java.io.Closeable import java.net.ServerSocket import java.net.Socket import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -232,6 +233,8 @@ class TvSiloCastReceiver( refreshStandbyState() } } + // After the slot is cleared: a handoff waiting on this checks it next. + player.unregistered.complete(Unit) } } @@ -455,8 +458,14 @@ class TvSiloCastReceiver( return true } session.handoffJob?.cancel() - identityEndJob?.cancel() - identityEndJob = null + // A reused identity must survive a pending cleanup. One this + // offer would replace stays until approval, so its cleanup still + // runs if the handoff fails; after a replacement it finds a newer + // generation and does nothing. + if (identityManager.matches(offer, controllerId)) { + identityEndJob?.cancel() + identityEndJob = null + } val run = receiverRun session.handoffJob = scope?.launch { try { @@ -465,6 +474,7 @@ class TvSiloCastReceiver( controllerDeviceId = controllerId, controllerDeviceName = session.controllerDeviceName, receiverRun = run, + beforeActivation = ::stopPlayerBeforeIdentitySwap, ) { challenge -> if (activeSession === session) { session.send(SiloCastMessage.HandoffChallenge(challenge)) @@ -645,6 +655,31 @@ class TvSiloCastReceiver( return true } + /** + * Stops the registered player and waits for its server session to close. + * An approved handoff runs this just before it installs the phone's + * identity, including for a title the TV started under its own account: + * the player's stop rides on the identity it started under, so after the + * swap it is refused and the session lingers beside the phone's. Runs under + * the identity manager's lock, which also holds off the stopped player's + * own identity cleanup until the swap is done. + */ + private suspend fun stopPlayerBeforeIdentitySwap() { + val player = activePlayer ?: return + withContext(Dispatchers.Main.immediate) { + player.adapter.handle(SiloCastControlCommand(name = SiloCastControlCommand.Stop)) + } + val tornDown = withTimeoutOrNull(PLAYBACK_TEARDOWN_TIMEOUT_MS) { + // The exit queues the session stop before the route unregisters. + player.unregistered.await() + awaitPlaybackTeardown() + } != null + // The player or its session stop outlived the teardown limit, or + // something started playing meanwhile; swapping the identity now would + // strand that session. + check(tornDown && activePlayer == null) { "The TV is still playing. Try again." } + } + private suspend fun requireAuthorized(session: ControllerSession): Boolean { if (session.isAuthorized) return true session.send( @@ -849,6 +884,7 @@ class TvSiloCastReceiver( val adapter: TvSiloCastPlayerAdapter, val stateProvider: () -> SiloCastPlaybackState, val identityGeneration: String?, + val unregistered: CompletableDeferred = CompletableDeferred(), ) private companion object { diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/FakeRemotePlaybackApi.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/FakeRemotePlaybackApi.kt new file mode 100644 index 0000000000..ba8ff3e834 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/FakeRemotePlaybackApi.kt @@ -0,0 +1,66 @@ +package org.siloserver.silo.tv.cast + +import org.siloserver.silo.cast.SiloCastProtocol +import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse +import org.siloserver.silo.model.auth.DeviceLoginPollResponse +import org.siloserver.silo.model.auth.DeviceLoginStartResponse +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.api.DeviceLoginApi + +/** Remote-playback device-login API that approves every handoff unless told otherwise. */ +internal class FakeRemotePlaybackApi : DeviceLoginApi { + var endCalls = 0 + var approvedProfileId = "profile-1" + var pollStatus = "approved" + + override suspend fun remotePlaybackCapabilityAt(serverUrl: String) = ApiResult.Success( + DeviceLoginCapabilityResponse( + remotePlaybackHandoff = true, + protocolVersions = listOf(SiloCastProtocol.version), + ), + ) + + override suspend fun startRemotePlaybackAt( + serverUrl: String, + deviceName: String?, + devicePlatform: String?, + ) = ApiResult.Success( + DeviceLoginStartResponse( + deviceCode = "device-code", + userCode = "USER", + matchCode = "12", + verificationUri = "$serverUrl/pair", + verificationUriComplete = "$serverUrl/pair?code=USER", + expiresAt = "2099-01-01T00:00:00Z", + expiresIn = 60, + interval = 1, + deviceName = deviceName.orEmpty(), + devicePlatform = devicePlatform.orEmpty(), + clientPurpose = "remote_playback", + temporary = true, + ), + ) + + override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String) = ApiResult.Success( + DeviceLoginPollResponse( + status = pollStatus, + accessToken = "access", + refreshToken = "refresh", + profileId = approvedProfileId, + profileToken = "profile-token", + temporary = true, + ), + ) + + override suspend fun endRemotePlayback(scope: AuthScopeSnapshot): ApiResult { + endCalls += 1 + return ApiResult.Success(Unit) + } + + override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unused") + override suspend fun pollDeviceLogin(deviceCode: String) = error("unused") + override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unused") + override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unused") +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt new file mode 100644 index 0000000000..1eae8fd054 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt @@ -0,0 +1,101 @@ +package org.siloserver.silo.tv.cast + +import android.app.Application +import kotlin.test.* +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.siloserver.silo.cast.SiloCastHandoffOffer +import org.siloserver.silo.network.AndroidServerRegistry +import org.siloserver.silo.network.TokenManagerImpl + +/** + * Whatever the TV is playing has to close its server session under the + * identity it started with. The handoff therefore stops it only once the + * phone's profile is approved, and before that profile replaces the TV's + * credentials. An offer that is denied or fails leaves playback alone. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RemotePlaybackHandoffOrderTest { + + private val api = FakeRemotePlaybackApi() + private val tokens = TokenManagerImpl() + private val manager = RemotePlaybackIdentityManager(api, tokens, deviceNameProvider = { "TV" }) + + @Test fun theTvsOwnPlaybackStopsBeforeTheFirstPhoneProfileIsInstalled() = runTest { + var hadTemporaryScopeAtStop: Boolean? = null + + prepare(offer()) { hadTemporaryScopeAtStop = tokens.hasTemporaryScope() } + + assertEquals(false, hadTemporaryScopeAtStop) + assertTrue(tokens.hasTemporaryScope()) + } + + @Test fun anEarlierPhoneProfileOutlivesItsPlayersStop() = runTest { + prepare(offer()) + val earlier = assertNotNull(manager.activeIdentity) + var identityAtStop: String? = null + var endCallsAtStop = -1 + + api.approvedProfileId = "profile-2" + prepare(offer(profileId = "profile-2")) { + identityAtStop = manager.activeIdentity?.generationId + endCallsAtStop = api.endCalls + } + + assertEquals(earlier.generationId, identityAtStop) + assertEquals(0, endCallsAtStop) + assertEquals(1, api.endCalls) + assertNotEquals(earlier.generationId, manager.activeIdentity?.generationId) + } + + @Test fun aDeniedHandoffNeitherStopsPlaybackNorEndsTheCurrentProfile() = runTest { + prepare(offer()) + val current = assertNotNull(manager.activeIdentity) + var stopped = false + + api.pollStatus = "denied" + assertFails { prepare(offer(profileId = "profile-2")) { stopped = true } } + + assertFalse(stopped) + assertEquals(current, manager.activeIdentity) + assertTrue(tokens.hasTemporaryScope()) + assertEquals(0, api.endCalls) + } + + @Test fun aStopThatFailsKeepsTheCurrentProfile() = runTest { + prepare(offer()) + val current = assertNotNull(manager.activeIdentity) + + api.approvedProfileId = "profile-2" + assertFails { prepare(offer(profileId = "profile-2")) { error("still playing") } } + + assertEquals(current, manager.activeIdentity) + assertEquals(0, api.endCalls) + } + + private suspend fun prepare( + offer: SiloCastHandoffOffer, + beforeActivation: suspend () -> Unit = {}, + ) = manager.prepare( + offer, + CONTROLLER, + controllerDeviceName = null, + receiverRun = 1L, + beforeActivation = beforeActivation, + ) {} + + private fun offer(profileId: String = "profile-1") = SiloCastHandoffOffer( + requestId = "request", + serverId = AndroidServerRegistry.idFor(SERVER_URL), + serverURL = SERVER_URL, + profileId = profileId, + ) + + private companion object { + const val SERVER_URL = "https://media.example.test" + const val CONTROLLER = "phone-1" + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt index 997a053b2f..b3cc188e6f 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt @@ -7,15 +7,8 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.siloserver.silo.cast.SiloCastHandoffOffer -import org.siloserver.silo.cast.SiloCastProtocol -import org.siloserver.silo.model.auth.DeviceLoginCapabilityResponse -import org.siloserver.silo.model.auth.DeviceLoginPollResponse -import org.siloserver.silo.model.auth.DeviceLoginStartResponse import org.siloserver.silo.network.AndroidServerRegistry -import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.TokenManagerImpl -import org.siloserver.silo.network.api.DeviceLoginApi /** * The receiver's stop() ends the temporary identity only after the player's @@ -95,61 +88,6 @@ class RemotePlaybackIdentityClaimTest { profileId = profileId, ) - private class FakeRemotePlaybackApi : DeviceLoginApi { - var endCalls = 0 - var approvedProfileId = "profile-1" - - override suspend fun remotePlaybackCapabilityAt(serverUrl: String) = ApiResult.Success( - DeviceLoginCapabilityResponse( - remotePlaybackHandoff = true, - protocolVersions = listOf(SiloCastProtocol.version), - ), - ) - - override suspend fun startRemotePlaybackAt( - serverUrl: String, - deviceName: String?, - devicePlatform: String?, - ) = ApiResult.Success( - DeviceLoginStartResponse( - deviceCode = "device-code", - userCode = "USER", - matchCode = "12", - verificationUri = "$serverUrl/pair", - verificationUriComplete = "$serverUrl/pair?code=USER", - expiresAt = "2099-01-01T00:00:00Z", - expiresIn = 60, - interval = 1, - deviceName = deviceName.orEmpty(), - devicePlatform = devicePlatform.orEmpty(), - clientPurpose = "remote_playback", - temporary = true, - ), - ) - - override suspend fun pollDeviceLoginAt(serverUrl: String, deviceCode: String) = ApiResult.Success( - DeviceLoginPollResponse( - status = "approved", - accessToken = "access", - refreshToken = "refresh", - profileId = approvedProfileId, - profileToken = "profile-token", - temporary = true, - ), - ) - - override suspend fun endRemotePlayback(scope: AuthScopeSnapshot): ApiResult { - endCalls += 1 - return ApiResult.Success(Unit) - } - - override suspend fun startDeviceLogin(deviceName: String?, devicePlatform: String?) = error("unused") - override suspend fun pollDeviceLogin(deviceCode: String) = error("unused") - override suspend fun lookupDeviceLogin(token: String?, code: String?) = error("unused") - override suspend fun approveDeviceLogin(token: String?, code: String?) = error("unused") - override suspend fun denyDeviceLogin(token: String?, code: String?) = error("unused") - } - private companion object { const val SERVER_URL = "https://media.example.test" const val CONTROLLER = "phone-1" From 8b0f374fd51fa3d97559b9fe5538d1fad95be8e6 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:38:26 -0400 Subject: [PATCH 44/87] test: keep shuffle countdown on one scheduler --- ...erViewModelLoadOwnershipIntegrationTest.kt | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index a7a55f0ca0..164495e842 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -26,6 +26,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -794,7 +795,7 @@ class PlayerViewModelLoadOwnershipIntegrationTest { } /** A shuffle server whose answers each test scripts; it records every request. */ - private class FakeShuffleServer { + private class FakeShuffleServer(testDispatcher: CoroutineDispatcher? = null) { val requests: MutableList = java.util.Collections.synchronizedList(mutableListOf()) var read: String = shuffle(current = EPISODE_A, next = MOVIE_B) var advance: String = shuffle(current = MOVIE_B, next = EPISODE_C) @@ -804,24 +805,27 @@ class PlayerViewModelLoadOwnershipIntegrationTest { var skipGate: kotlinx.coroutines.CompletableDeferred? = null val api = org.siloserver.silo.network.apiv2.ShufflesV2Api( - HttpClient(MockEngine { request -> - val body = (request.body as? io.ktor.http.content.TextContent)?.text.orEmpty() - requests += "${request.method.value} ${request.url.encodedPath} $body".trim() - val path = request.url.encodedPath - val (status, json) = when { - request.method.value == "DELETE" -> HttpStatusCode.NoContent to "" - path == "/api/v2/shuffles" -> HttpStatusCode.Created to - shuffle(current = EPISODE_A, next = MOVIE_B) - path.endsWith("/advance") -> HttpStatusCode.OK to advance - path.endsWith("/skip") -> { - skipGate?.await() - HttpStatusCode.OK to skip + HttpClient(MockEngine.create { + dispatcher = testDispatcher + addHandler { request -> + val body = (request.body as? io.ktor.http.content.TextContent)?.text.orEmpty() + requests += "${request.method.value} ${request.url.encodedPath} $body".trim() + val path = request.url.encodedPath + val (status, json) = when { + request.method.value == "DELETE" -> HttpStatusCode.NoContent to "" + path == "/api/v2/shuffles" -> HttpStatusCode.Created to + shuffle(current = EPISODE_A, next = MOVIE_B) + path.endsWith("/advance") -> HttpStatusCode.OK to advance + path.endsWith("/skip") -> { + skipGate?.await() + HttpStatusCode.OK to skip + } + readStatus.value >= 400 -> readStatus to + """{"type":"https://siloserver.org/docs/api/v2/problems/conflict","title":"Conflict","status":${readStatus.value}}""" + else -> HttpStatusCode.OK to read } - readStatus.value >= 400 -> readStatus to - """{"type":"https://siloserver.org/docs/api/v2/problems/conflict","title":"Conflict","status":${readStatus.value}}""" - else -> HttpStatusCode.OK to read + respond(json, status, headersOf(HttpHeaders.ContentType, "application/json")) } - respond(json, status, headersOf(HttpHeaders.ContentType, "application/json")) }) { install(ContentNegotiation) { json(SiloJson) } }, FakeTokenManager(), ApiV2Gate.Unrestricted, @@ -1010,7 +1014,7 @@ class PlayerViewModelLoadOwnershipIntegrationTest { @Test fun pickAnotherReplacesThePickWithAFullCountdownAndStopEndsTheShuffle() = runTest(dispatcher) { - val server = FakeShuffleServer() + val server = FakeShuffleServer(dispatcher) val starter = DeferredNonCooperativeStarter() val fixture = playerViewModel(starter, backgroundScope, server.api) val store = ViewModelStore().also { it.put("player", fixture.viewModel) } @@ -1024,7 +1028,10 @@ class PlayerViewModelLoadOwnershipIntegrationTest { assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS - 3, viewModel.uiState.value.upNextCountdownSeconds) viewModel.pickAnotherShuffle() - viewModel.awaitState { it.nextEpisode?.contentId == "episode-c" } + // Keep the response and this waiter on the countdown's scheduler. + // Returning through Dispatchers.Default lets virtual time tick + // between publication of the full countdown and this assertion. + withTimeout(30_000) { viewModel.uiState.first { it.nextEpisode?.contentId == "episode-c" } } assertTrue(server.requests.contains("""POST /api/v2/shuffles/sh1/skip {"next_content_id":"movie-b"}""")) assertEquals(PlayerViewModel.UP_NEXT_COUNTDOWN_SECONDS, viewModel.uiState.value.upNextCountdownSeconds) From 6808ea83f9f22508421dd9ef68a30c21f2116198 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:08:29 -0400 Subject: [PATCH 45/87] test: serialize library shrink fixtures on the test scheduler --- .../libraries/LibrariesViewModelTest.kt | 96 +++++++++++-------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt index 2675f23242..a6c95cd839 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -6,6 +6,7 @@ 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.MockEngineConfig import io.ktor.client.engine.mock.MockRequestHandleScope import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -14,6 +15,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -21,6 +23,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -400,7 +403,10 @@ class LibrariesViewModelTest { @Test fun libraryListRecheckConfirmsAShrinkBeforePublishingIt() = runTest { - val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + val fixture = DeferredLibrariesFixture( + deferredKeys = emptySet(), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) val cache = InMemoryLibraryCache() val viewModel = fixture.viewModel(catalogCache = cache) @@ -452,7 +458,10 @@ class LibrariesViewModelTest { @Test fun libraryListRecheckConfirmsAShrinkOfHiddenAudiobookLibraries() = runTest { - val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + val fixture = DeferredLibrariesFixture( + deferredKeys = emptySet(), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) fixture.librariesBody = """ {"items":[ {"id":"1","name":"First","type":"movies","sort_order":0}, @@ -492,6 +501,7 @@ class LibrariesViewModelTest { """.trimIndent() viewModel.refresh() fixture.awaitRequest("libraries") + fixture.awaitRequest("libraries") viewModel.uiState.first { !it.isLoadingLibraries && it.libraries.isEmpty() } fixture.librariesStatusQueue += HttpStatusCode.ServiceUnavailable viewModel.refreshLibraryList() @@ -555,6 +565,7 @@ class LibrariesViewModelTest { private class DeferredLibrariesFixture( private val deferredKeys: Set, + engineDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) /** Overrides the immediate two-library list; [librariesStatus] fails it instead. */ @@ -569,49 +580,52 @@ class LibrariesViewModelTest { private val pendingRequests = mutableListOf() private val responses = deferredKeys.associateWith { CompletableDeferred() } private val client = HttpClient( - MockEngine { request -> - val key = when (request.url.encodedPath) { - "/api/v2/user/libraries" -> "libraries" - "/api/v2/catalog/filters" -> "filters" - // Unfiltered browses are GET /catalog with `sort=-field`; facet - // filters switch to POST /catalog/query with the query in the body. - "/api/v2/catalog", "/api/v2/catalog/query" -> { - val body = (request.body as? io.ktor.http.content.TextContent) - ?.let { SiloJson.parseToJsonElement(it.text).jsonObject } - fun field(name: String): String? = body?.get(name)?.let { (it as? JsonPrimitive)?.contentOrNull } - ?: request.url.parameters[name] - val rawSort = field("sort") - val sort = rawSort?.removePrefix("-") - val order = field("order") ?: if (rawSort?.startsWith("-") == true) "desc" else "asc" - val baseKey = "catalog:${field("library_id")}:$sort:$order" - val filterSuffix = if ((body?.get("groups") as? JsonArray)?.isNotEmpty() == true) { - ":filtered" - } else { - "" + MockEngine(MockEngineConfig().apply { + dispatcher = engineDispatcher + addHandler { request -> + val key = when (request.url.encodedPath) { + "/api/v2/user/libraries" -> "libraries" + "/api/v2/catalog/filters" -> "filters" + // Unfiltered browses are GET /catalog with `sort=-field`; facet + // filters switch to POST /catalog/query with the query in the body. + "/api/v2/catalog", "/api/v2/catalog/query" -> { + val body = (request.body as? io.ktor.http.content.TextContent) + ?.let { SiloJson.parseToJsonElement(it.text).jsonObject } + fun field(name: String): String? = body?.get(name)?.let { (it as? JsonPrimitive)?.contentOrNull } + ?: request.url.parameters[name] + val rawSort = field("sort") + val sort = rawSort?.removePrefix("-") + val order = field("order") ?: if (rawSort?.startsWith("-") == true) "desc" else "asc" + val baseKey = "catalog:${field("library_id")}:$sort:$order" + val filterSuffix = if ((body?.get("groups") as? JsonArray)?.isNotEmpty() == true) { + ":filtered" + } else { + "" + } + val offsetSuffix = ":${field("cursor") ?: "0"}" + 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]}" } - val offsetSuffix = ":${field("cursor") ?: "0"}" - 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) + val status = if (key == "libraries") librariesStatusQueue.poll() ?: librariesStatus else HttpStatusCode.OK + if (status != HttpStatusCode.OK) { + respond(content = "", status = status) + } else { + respondJson(body) } } - requests.send(key) - val body = responses[key]?.await() ?: immediateBody(key) - val status = if (key == "libraries") librariesStatusQueue.poll() ?: librariesStatus else HttpStatusCode.OK - if (status != HttpStatusCode.OK) { - respond(content = "", status = status) - } else { - respondJson(body) - } - }, + }), ) { install(ContentNegotiation) { json(SiloJson) } } From bbbb4300dcea8691be865b041085fd3cf7476b53 Mon Sep 17 00:00:00 2001 From: Suspense Date: Wed, 7 Oct 2026 21:47:50 +0200 Subject: [PATCH 46/87] fix(settings): flush settings before a phone profile switch and match the server's limits (#501) * fix(settings): flush settings before a phone profile switch and match the server's limits A settings change waits about 750 ms before it is sent, and the flusher drops a write once its profile is no longer active. The TV pushed pending writes before a profile switch; the phone did not, so a change made just before "Switch Profile" could be lost. Both phone entry points (the profile menu and Settings) now go through ProfileSwitchTeardown, which pushes pending writes (bounded at 5 s so an unreachable server cannot hang the switch), then leaves the shell and clears per-profile caches. Playback speed was clamped to 0.25-4.0, but the contract allows 0.25-3.0 in 0.05 steps and the server rejects anything else, so the write was dropped. Speeds are now clamped and snapped to the step. The Profile 7 HDR10 fallback read true before the first refresh, while the contract default is false. It now defaults to false, matching the server and Apple. * fix(settings): guard the phone profile switch while settings are pushed The switch now waits up to 5 s for pending settings to reach the server. During that wait a second tap started a second switch, so Back landed on a second profile picker, and the profile-menu path ran in the tab screen's scope, so opening a title during the wait cancelled the switch. ProfileSwitchTeardown ignores a request while one is in flight and exposes a switching state. Both entry points now run in the navigation host's scope, and the shell shows a progress overlay that takes input until the switch has left it. * fix(settings): block system Back while a profile switch pushes settings The switching overlay consumed touches but not system Back, so Back during the push could pop Settings before the profile picker opened, or close the app from a root screen and cancel the switch. * fix(settings): normalize a stored playback speed on read Older builds clamped speed to 4.0, so a stored value could sit outside the 0.25-3.0 contract range until the next server refresh, or indefinitely offline. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/AndroidPlayerSettingsStore.kt | 16 ++- .../AndroidPlayerSettingsStoreTest.kt | 32 ++++- .../android/auth/ProfileSwitchTeardown.kt | 71 ++++++++++ .../silo/android/di/AndroidModule.kt | 3 + .../android/ui/navigation/AppNavigation.kt | 53 ++++++-- .../ui/navigation/LocalProfileSwitch.kt | 10 ++ .../silo/android/ui/screens/MainScreen.kt | 28 +--- .../ui/screens/settings/SettingsViewModel.kt | 2 +- .../android/auth/ProfileSwitchTeardownTest.kt | 124 ++++++++++++++++++ .../screens/settings/TvSettingsViewModel.kt | 2 +- .../settings/PlayerSettingsContractTest.kt | 56 ++++++++ .../silo/model/settings/PlaybackSpeedRange.kt | 27 ++++ 12 files changed, 385 insertions(+), 39 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/LocalProfileSwitch.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt create mode 100644 shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlayerSettingsContractTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSpeedRange.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt index 594ea7ef3b..518008a0e9 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt @@ -14,6 +14,7 @@ import org.siloserver.silo.model.download.DownloadQuality import org.siloserver.silo.model.settings.EffectiveSettingValue import org.siloserver.silo.model.settings.LanguageOptions import org.siloserver.silo.model.settings.PlaybackSettingsKeys +import org.siloserver.silo.model.settings.PlaybackSpeedRange import org.siloserver.silo.model.settings.QualityPresets import org.siloserver.silo.model.settings.SettingKeys import org.siloserver.silo.model.settings.SettingScope @@ -263,8 +264,12 @@ class AndroidPlayerSettingsStore( override val hdrEnabledFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.HdrEnabled, true) } + // Off until the server says otherwise: the contract default is false, so + // defaulting on here sent Profile 7 sources down the HDR10 path before the + // first refresh (or offline) when the server would have played them as + // Dolby Vision. override val dvProfile7HDR10FallbackFlow: Flow = - profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DvProfile7HDR10Fallback, true) } + profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DvProfile7HDR10Fallback, false) } override val dolbyVisionEnabledFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DolbyVisionEnabled, true) } @@ -312,7 +317,10 @@ class AndroidPlayerSettingsStore( // ---- Doubles ------------------------------------------------------- override val playbackSpeedFlow: Flow = profileScopedFlow(1.0) { p, s -> - p.stringFor(s, PlaybackSettingsKeys.PlaybackSpeed, "1.0").toDoubleOrNull() ?: 1.0 + // Normalized on read too, so a value an older build stored outside + // the contract range (up to 4.0) is not played back as is. + p.stringFor(s, PlaybackSettingsKeys.PlaybackSpeed, "1.0").toDoubleOrNull() + ?.let(PlaybackSpeedRange::normalize) ?: 1.0 } // ---- Ints ---------------------------------------------------------- @@ -478,7 +486,9 @@ class AndroidPlayerSettingsStore( writeStringLocal(PlaybackSettingsKeys.DefaultDownloadQuality, DownloadQuality.fromWire(value).wire) override suspend fun setPlaybackSpeed(value: Double) { - val clamped = value.coerceIn(0.25, 4.0) + // The server refuses a speed outside the contract range or off its + // step, and the flusher then drops the write for good. + val clamped = PlaybackSpeedRange.normalize(value) withScope { scope, store -> store.edit { it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PlaybackSpeed)] = clamped.toString() } serverSettingsFlusher.enqueue( diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt index 89079ad8a6..9c3cf2f6ec 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt @@ -465,12 +465,40 @@ class AndroidPlayerSettingsStoreTest { ) @Test - fun `setPlaybackSpeed clamps out-of-range values`() = runTest { + fun `setPlaybackSpeed clamps to the contract range and step`() = runTest { val store = newStore() store.setPlaybackSpeed(10.0) - assertEquals(4.0, store.playbackSpeedFlow.first(), 0.0) + assertEquals(3.0, store.playbackSpeedFlow.first(), 0.0) + store.setPlaybackSpeed(3.5) + assertEquals(3.0, store.playbackSpeedFlow.first(), 0.0) store.setPlaybackSpeed(0.01) assertEquals(0.25, store.playbackSpeedFlow.first(), 0.0) + // Off the 0.05 grid: the server would refuse it and the write would + // be dropped, so it is snapped before it is stored or sent. + store.setPlaybackSpeed(1.33) + assertEquals(1.35, store.playbackSpeedFlow.first(), 0.0) + assertEquals( + listOf("3.0", "3.0", "0.25", "1.35"), + fakeFlusher.calls.filter { it.key == PlaybackSettingsKeys.PlaybackSpeed }.map { it.value }, + ) + } + + @Test + fun `a playback speed stored by an older build is normalized on read`() = runTest { + // Older builds clamped to 4.0, so a stored speed can sit outside the + // contract range until the next server refresh, or forever offline. + val store = newStoreSeededWith { prefs -> + prefs[stringPreferencesKey(PlaybackSettingsKeys.PlaybackSpeed)] = "3.5" + } + assertEquals(3.0, store.playbackSpeedFlow.first(), 0.0) + } + + @Test + fun `Profile 7 HDR10 fallback defaults off before the first refresh`() = runTest { + // The contract default is false; reading true before hydration (or + // offline) sent Profile 7 sources down the HDR10 path. + assertFalse(newStore().dvProfile7HDR10FallbackFlow.first()) + assertFalse(newStore(profileId = null).dvProfile7HDR10FallbackFlow.first()) } @Test diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt new file mode 100644 index 0000000000..ad9adc59c8 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt @@ -0,0 +1,71 @@ +package org.siloserver.silo.android.auth + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withTimeoutOrNull +import org.siloserver.silo.common.settings.CardPresentationStore +import org.siloserver.silo.common.settings.OverlayPrefsStore +import org.siloserver.silo.common.settings.PlayerSettingsStore +import org.siloserver.silo.common.settings.SeekIntervalStore +import org.siloserver.silo.common.settings.TitleArtStore +import org.siloserver.silo.model.profile.ActiveProfileStore + +/** + * The phone's "Switch Profile", shared by the profile menu and Settings. + * + * Settings writes wait in the flusher's debounce for a moment, and the flusher + * drops a write once its profile is no longer active. So the pending writes are + * pushed first, while the profile they were made on is still the active one + * (the TV switch does the same). Then the shell is left and the per-profile + * caches are dropped, or the next profile keeps rendering, and writing back, + * the previous one's values. + */ +class ProfileSwitchTeardown( + private val playerSettingsStore: PlayerSettingsStore, + private val overlayPrefsStore: OverlayPrefsStore, + private val activeProfileStore: ActiveProfileStore, + private val cardPresentationStore: CardPresentationStore, + private val seekIntervalStore: SeekIntervalStore, + private val titleArtStore: TitleArtStore, + private val flushTimeoutMs: Long = FLUSH_TIMEOUT_MS, +) { + private val _switching = MutableStateFlow(false) + + /** True while a switch is pushing settings; the shell shows progress and blocks input. */ + val switching: StateFlow = _switching.asStateFlow() + + /** + * Push pending settings, then call [leaveShell] and drop per-profile state. + * + * The push is bounded: against an unreachable server each write can wait + * out a connect timeout, and the switch must not hang on that. A write + * still unsent when the bound passes stays queued under the old profile, + * as it would have without this flush. + * + * A request made while a switch is already running is ignored, so a + * second tap during the push cannot open a second profile picker. + * Returns whether this call performed the switch. + */ + suspend fun switchProfile(leaveShell: () -> Unit): Boolean { + if (!_switching.compareAndSet(expect = false, update = true)) return false + try { + withTimeoutOrNull(flushTimeoutMs) { playerSettingsStore.flushPendingDeviceSettings() } + // Navigate before clearing: clearing while the shell is still composed + // repaints it with default cards behind the picker. + leaveShell() + overlayPrefsStore.clear() + activeProfileStore.reset() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + } finally { + _switching.value = false + } + return true + } + + private companion object { + const val FLUSH_TIMEOUT_MS = 5_000L + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index bb6f36065d..374480462c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -529,6 +529,9 @@ val androidModule = module { single { org.siloserver.silo.android.auth.SignOutTeardown(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + single { + org.siloserver.silo.android.auth.ProfileSwitchTeardown(get(), get(), get(), get(), get(), get()) + } viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 625b2296fa..ab651d9a68 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -22,6 +22,11 @@ import org.siloserver.silo.android.cast.GOOGLE_CAST_LEGACY_SEEK_INTERVALS import org.siloserver.silo.android.ui.screens.auth.DevicePairingWrongServerScreen import org.siloserver.silo.android.ui.screens.auth.DevicePairingUnknownServerScreen import androidx.compose.runtime.collectAsState +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import org.siloserver.silo.android.ui.components.LoadingIndicator import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -181,11 +186,25 @@ fun AppNavigation( val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore = koinInject() val titleArtStore: TitleArtStore = koinInject() val signOutTeardown: org.siloserver.silo.android.auth.SignOutTeardown = koinInject() + val profileSwitchTeardown: org.siloserver.silo.android.auth.ProfileSwitchTeardown = koinInject() val siloCastController: SiloCastController = koinInject() // Lives as long as the nav host, so work started from a destination that is // popped in the same gesture (re-hydrating after a profile switch) is not // cancelled with that destination's own scope. val navScope = rememberCoroutineScope() + val profileSwitching by profileSwitchTeardown.switching.collectAsState() + // Both "Switch Profile" entry points (profile menu, Settings) run here, so + // leaving a tab while settings are pushed cannot cancel the switch; the + // teardown ignores a repeat request while one is in flight. + val switchProfile: () -> Unit = remember(navController, profileSwitchTeardown) { + { + navScope.launch { + profileSwitchTeardown.switchProfile { + navController.navigate(Route.ProfileSelection.route) + } + } + } + } val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() var activePlayerTargetProvider by remember { @@ -390,6 +409,7 @@ fun AppNavigation( CompositionLocalProvider( LocalSharedTransitionScope provides this, LocalHeroSourceHandoff provides heroSourceHandoff, + LocalProfileSwitch provides switchProfile, ) { Box(modifier = Modifier.fillMaxSize()) { // The first-run screens draw over one shared brand-light backdrop that @@ -951,16 +971,9 @@ fun AppNavigation( onPairDevice = { navController.navigate(Route.PairDevice().route) }, - onSwitchProfile = { - // Leave the shell first, then drop the per-profile caches - // (see the profile-menu path in MainScreen). - navController.navigate(Route.ProfileSelection.route) - overlayPrefsStore.clear() - activeProfileStore.reset() - cardPresentationStore.clear() - seekIntervalStore.clear() - titleArtStore.clear() - }, + // Same switch as the profile menu: push pending settings, + // leave the shell, then drop the per-profile caches. + onSwitchProfile = switchProfile, onNavigateToWatchlist = { navController.navigate(Route.Watchlist.route) }, onNavigateToFavorites = { navController.navigate(Route.Favorites.route) }, onNavigateToHistory = { navController.navigate(Route.History.route) }, @@ -1689,6 +1702,26 @@ fun AppNavigation( enabled = currentRoute != null && currentRoute !in companionHiddenRoutes, ) org.siloserver.silo.android.ui.screens.profiles.HouseholdReverifyHost() + + // While a profile switch pushes pending settings (bounded at a few + // seconds), show that it is under way and take input, so nothing else + // can be opened over a switch that is about to leave the shell. + if (profileSwitching) { + // System Back too: composed after the NavHost, this handler wins, so + // Back can neither pop the screen under the switch nor close the + // app and cancel it. + BackHandler {} + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .pointerInput(Unit) { + awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } + }, + ) { + LoadingIndicator() + } + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/LocalProfileSwitch.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/LocalProfileSwitch.kt new file mode 100644 index 0000000000..fd39d0845a --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/LocalProfileSwitch.kt @@ -0,0 +1,10 @@ +package org.siloserver.silo.android.ui.navigation + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Starts "Switch Profile" in the navigation host's scope, which outlives the + * tab screen the request came from. Run from a tab's own scope, opening a + * title while settings were still being pushed cancelled the switch. + */ +val LocalProfileSwitch = staticCompositionLocalOf<() -> Unit> { {} } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt index 00b37680cf..f5579ed143 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import org.siloserver.silo.model.profile.ActiveProfileStore import org.siloserver.silo.android.ui.components.MainAppHeaderBodyHeight import org.siloserver.silo.android.ui.components.MainAppTopBar import org.siloserver.silo.android.ui.components.TabTopBarActions @@ -76,8 +75,6 @@ import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.feature.RequestsFeatureStore import org.siloserver.silo.common.network.ServerReachabilityMonitor import org.siloserver.silo.common.network.ServerReachabilityStatus -import org.siloserver.silo.common.settings.CardPresentationStore -import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.android.ui.theme.siloPageBackdrop import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.ServerRegistry @@ -164,12 +161,8 @@ fun MainScreen( val requestsFeatureStore: RequestsFeatureStore = koinInject() val metadataAiFeatureStore: MetadataAiFeatureStore = koinInject() val shuffleFeatureStore: org.siloserver.silo.model.feature.ShuffleFeatureStore = koinInject() - val overlayPrefsStore: OverlayPrefsStore = koinInject() - val activeProfileStore: ActiveProfileStore = koinInject() - val cardPresentationStore: CardPresentationStore = koinInject() - val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore = koinInject() - val titleArtStore: org.siloserver.silo.common.settings.TitleArtStore = koinInject() val signOutTeardown: org.siloserver.silo.android.auth.SignOutTeardown = koinInject() + val switchProfile = org.siloserver.silo.android.ui.navigation.LocalProfileSwitch.current val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() val reachabilityScope = rememberCoroutineScope() @@ -303,21 +296,12 @@ fun MainScreen( } /** - * Profile-menu "Switch Profile". Overlays and card presentation are cached - * per profile and the app never backgrounds during an in-app switch, so - * drop them here or the next profile keeps rendering — and writing back — - * the previous one's values. Navigate first: clearing while the shell is - * still composed repaints it with default cards behind the picker (the - * TV shell's switch-profile path takes the same order). + * Profile-menu "Switch Profile": pushes pending settings while this + * profile is still active, then leaves the shell and drops the per-profile + * caches (see [org.siloserver.silo.android.auth.ProfileSwitchTeardown]). + * Runs in the navigation host's scope ([org.siloserver.silo.android.ui.navigation.LocalProfileSwitch]). */ - fun switchProfileFromMenu() { - navController.navigate(Route.ProfileSelection.route) - overlayPrefsStore.clear() - activeProfileStore.reset() - cardPresentationStore.clear() - seekIntervalStore.clear() - titleArtStore.clear() - } + fun switchProfileFromMenu() = switchProfile() val requestsMenuAction: (() -> Unit)? = if (requestsEnabled) { { navController.navigate(Route.Requests.route) } } else { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 0300c41aa1..9f90fd5a51 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -84,7 +84,7 @@ data class SettingsUiState( val autoSkipCredits: Boolean = false, val pictureInPictureEnabled: Boolean = true, val dolbyVisionEnabled: Boolean = true, - val dvProfile7HDR10Fallback: Boolean = true, + val dvProfile7HDR10Fallback: Boolean = false, val subtitleMatchesDevice: Boolean = false, val showAudiobooks: Boolean = false, val subtitleAppearance: org.siloserver.silo.model.settings.SubtitleAppearance = diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt new file mode 100644 index 0000000000..e49f071df9 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt @@ -0,0 +1,124 @@ +package org.siloserver.silo.android.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.common.settings.PlayerSettingsStore +import org.siloserver.silo.common.settings.SeekIntervalStore +import org.siloserver.silo.common.settings.TitleArtStore +import org.siloserver.silo.model.profile.ActiveProfileStore +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.ProfileApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.ProfileRepository +import java.lang.reflect.Proxy +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A settings write waits out the flusher's debounce, and the flusher drops it + * once its profile is no longer active. The phone switch has to push pending + * writes before it leaves the shell, as the TV switch does. + */ +class ProfileSwitchTeardownTest { + private val http = HttpClient(MockEngine { respond("", HttpStatusCode.NoContent) }) + + @AfterTest fun tearDown() = http.close() + + @Test + fun pendingSettingsAreFlushedBeforeLeavingTheShell() = runTest { + val events = mutableListOf() + val settings = object : PlayerSettingsStore by idle() { + override suspend fun flushPendingDeviceSettings() { + events += "flush" + } + } + val teardown = teardown(settings, onClear = { events += "clear:$it" }) + + teardown.switchProfile { events += "leave" } + + assertEquals("flush", events.first()) + assertEquals("leave", events[1], "the shell is left only after the flush") + assertEquals( + setOf("clear:overlays", "clear:cards", "clear:seek", "clear:titleArt"), + events.drop(2).toSet(), + ) + } + + @Test + fun aFlushThatCannotFinishDoesNotBlockTheSwitch() = runTest { + var left = false + val settings = object : PlayerSettingsStore by idle() { + override suspend fun flushPendingDeviceSettings() = awaitCancellation() + } + + teardown(settings, flushTimeoutMs = 5_000).switchProfile { left = true } + + assertEquals(true, left) + assertEquals(5_000, currentTime) + } + + @Test + fun aSecondRequestWhileSwitchingIsIgnored() = runTest { + val flushGate = CompletableDeferred() + val settings = object : PlayerSettingsStore by idle() { + override suspend fun flushPendingDeviceSettings() = flushGate.await() + } + val teardown = teardown(settings) + var left = 0 + + val first = async { teardown.switchProfile { left++ } } + runCurrent() + assertTrue(teardown.switching.value, "the first switch is still pushing settings") + // A second tap during the push must not open a second profile picker. + assertFalse(teardown.switchProfile { left++ }) + + flushGate.complete(Unit) + assertTrue(first.await()) + assertEquals(1, left) + assertFalse(teardown.switching.value) + + // Once finished, the next switch runs normally. + assertTrue(teardown.switchProfile { left++ }) + assertEquals(2, left) + } + + private fun teardown( + settings: PlayerSettingsStore, + onClear: (String) -> Unit = {}, + flushTimeoutMs: Long = 5_000, + ) = ProfileSwitchTeardown( + playerSettingsStore = settings, + overlayPrefsStore = recordingClear("overlays", onClear), + activeProfileStore = ActiveProfileStore(ProfileRepository(ProfileApi(http, ApiV2Gate.Unrestricted), TokenManagerImpl())), + cardPresentationStore = recordingClear("cards", onClear), + seekIntervalStore = recordingClear("seek", onClear), + titleArtStore = recordingClear("titleArt", onClear), + flushTimeoutMs = flushTimeoutMs, + ) + + /** A store that reports its `clear()` and otherwise answers nothing. */ + private inline fun recordingClear(name: String, crossinline onClear: (String) -> Unit): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + if (method.name == "clear") onClear(name) + if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T + + /** Collaborators the switch doesn't depend on: they answer nothing and emit nothing. */ + private inline fun idle(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 3fd1420870..98122ae775 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -124,7 +124,7 @@ class TvSettingsViewModel( val dolbyVisionEnabled: Boolean = true, val showAudiobooksTab: Boolean = false, val subtitleMatchesDevice: Boolean = false, - val dvProfile7HDR10Fallback: Boolean = true, + val dvProfile7HDR10Fallback: Boolean = false, val forceHdrPassthrough: Boolean = false, val trueBlackBars: Boolean = false, val autoSkipCredits: Boolean = false, diff --git a/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlayerSettingsContractTest.kt b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlayerSettingsContractTest.kt new file mode 100644 index 0000000000..dfe4c545c9 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlayerSettingsContractTest.kt @@ -0,0 +1,56 @@ +package org.siloserver.silo.model.settings + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Pins the client-side restatements of manifest facts to the vendored + * manifest, so a re-vendor that changes them fails here. + */ +class PlayerSettingsContractTest { + + private val definitions by lazy { + val raw = checkNotNull(javaClass.classLoader?.getResource("settings/v1/manifest.json")) { + "Missing test resource settings/v1/manifest.json" + }.readText() + Json.parseToJsonElement(raw).jsonObject.getValue("definitions").jsonArray + .map { it.jsonObject } + .associateBy { it.getValue("key").jsonPrimitive.content } + } + + @Test + fun playbackSpeedBoundsMatchTheManifest() { + val schema = definitions.getValue(SettingKeys.PLAYER_PLAYBACK_SPEED) + .getValue("value_schema").jsonObject + assertEquals(PlaybackSpeedRange.MIN, schema.getValue("minimum").jsonPrimitive.double) + assertEquals(PlaybackSpeedRange.MAX, schema.getValue("maximum").jsonPrimitive.double) + assertEquals(PlaybackSpeedRange.STEP, schema.getValue("step").jsonPrimitive.double) + } + + @Test + fun profile7FallbackDefaultsOff() { + // AndroidPlayerSettingsStore falls back to this before the first + // refresh; it has to agree with what the server would resolve. + assertFalse( + definitions.getValue(SettingKeys.PLAYER_DV_PROFILE7_HDR10_FALLBACK) + .getValue("default_value").jsonPrimitive.boolean, + ) + } + + @Test + fun normalizeClampsAndSnapsToTheStep() { + assertEquals(3.0, PlaybackSpeedRange.normalize(4.0)) + assertEquals(0.25, PlaybackSpeedRange.normalize(0.01)) + assertEquals(1.35, PlaybackSpeedRange.normalize(1.33)) + assertEquals(1.25, PlaybackSpeedRange.normalize(1.25)) + assertEquals(2.5, PlaybackSpeedRange.normalize(2.5)) + assertEquals(1.0, PlaybackSpeedRange.normalize(Double.NaN)) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSpeedRange.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSpeedRange.kt new file mode 100644 index 0000000000..5d5542ed44 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSpeedRange.kt @@ -0,0 +1,27 @@ +package org.siloserver.silo.model.settings + +import kotlin.math.roundToLong + +/** + * The bounds of `player.playback_speed`: 0.25..3.0 in 0.05 steps. + * + * The generated [SettingKeys] carry no value bounds yet, so they are restated + * here and pinned to the vendored manifest by `PlayerSettingsContractTest`. + * The server enforces the step as well as the range, so a value off the grid is + * refused like one out of range, and the write is dropped. + */ +object PlaybackSpeedRange { + const val MIN = 0.25 + const val MAX = 3.0 + const val STEP = 0.05 + + /** [value] clamped to the range and rounded to the nearest step. */ + fun normalize(value: Double): Double { + if (value.isNaN()) return 1.0 + val clamped = value.coerceIn(MIN, MAX) + // Steps per unit (20), so the result is a whole number of steps + // divided once, which lands on the nearest double to the step value. + val stepsPerUnit = (1 / STEP).roundToLong() + return (clamped * stepsPerUnit).roundToLong().toDouble() / stepsPerUnit + } +} From 7f8c7e1ade7f4fc67b31e04aee1b1afdb4513655 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:13:31 -0400 Subject: [PATCH 47/87] test: use test scheduler for library recovery fixture --- .../android/ui/screens/libraries/LibrariesViewModelTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt index a6c95cd839..6727bf5523 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -314,7 +314,10 @@ class LibrariesViewModelTest { @Test fun libraryListRecheckRecoversShortOrClearedListsAndKeepsThemOnlyOnTransientFailure() = runTest { - val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + val fixture = DeferredLibrariesFixture( + deferredKeys = emptySet(), + engineDispatcher = StandardTestDispatcher(testScheduler), + ) fixture.librariesBody = """ {"items":[{"id":"1","name":"First","type":"movies","sort_order":0}],"page":{"has_more":false}} """.trimIndent() From b6f081b2d53737d93e77191a059d1c9417aed171 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:24:51 -0400 Subject: [PATCH 48/87] test: scope TV startup ownership barrier --- .../tv/ui/screens/player/TvStartupMetadataOwnerTest.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt index cdeb5e8a66..8037a6eb33 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvStartupMetadataOwnerTest.kt @@ -99,9 +99,13 @@ class TvStartupMetadataOwnerTest { fun field(name: String): Any? = PlaybackSessionLifecycle::class.java.getDeclaredField(name).let { it.isAccessible = true; it.get(lifecycle) } + val startupCoroutine = CoroutineName("tv-startup-metadata-owner-test") var held = false tokens.beforeSnapshot = { - if (stage in listOf("final", "cancel", "newer", "predecessor") && !held && field("lastAdoptedSessionId") == "allocated") { + // Route telemetry also snapshots tokens; pause only the startup transaction. + if (currentCoroutineContext()[CoroutineName] === startupCoroutine && + stage in listOf("final", "cancel", "newer", "predecessor") && + !held && field("lastAdoptedSessionId") == "allocated") { held = true; entered.complete(Unit); release.await() } } @@ -130,7 +134,7 @@ class TvStartupMetadataOwnerTest { FakePlayerSettingsStore(), lifecycle, ServerReachabilityMonitor(ApiV2Probe(client)::probeFresh, backgroundScope, { null }), localState, ) - val start = async { starter.start(VideoPlaybackStartRequest(contentId = "item", preferredFileId = 41, roomId = null, resumePositionOverride = null)) } + val start = async(startupCoroutine) { starter.start(VideoPlaybackStartRequest(contentId = "item", preferredFileId = 41, roomId = null, resumePositionOverride = null)) } if (stage in listOf("final", "cancel", "newer", "predecessor")) { entered.await() val active = lifecycle.state.value as SessionState.Active From 7ccc5db6d8d114598de1c95404e8f4c28531629b Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:19:56 -0400 Subject: [PATCH 49/87] ci: cache Android build outputs while rerunning source checks --- .github/workflows/android-build.yml | 22 ++++++++++++- .github/workflows/trusted-linux-ci.yml | 44 ++++++++++++++++++++++++++ build.gradle.kts | 8 +++++ gradle.properties | 1 + scripts/ci-build-metadata.py | 28 ++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100755 scripts/ci-build-metadata.py diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 788acbfb13..6b6a30a2be 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -16,6 +16,21 @@ on: required: false default: false type: boolean + build_cache: + description: Reuse Gradle task outputs (disable for a baseline measurement) + required: false + default: true + type: boolean + parallel: + description: Benchmark parallel project execution at the existing worker limit + required: false + default: false + type: boolean + configuration_cache: + description: Check configuration-cache compatibility without publishing artifacts + required: false + default: false + type: boolean permissions: contents: read @@ -26,7 +41,12 @@ concurrency: jobs: ci: - uses: Silo-Server/silo-android/.github/workflows/trusted-linux-ci.yml@main + # Hosted PR validation must exercise the proposed workflow revision. The + # runner group's main-only restriction still applies to self-hosted calls. + uses: ./.github/workflows/trusted-linux-ci.yml with: self_hosted: false build_ffmpeg_aar: ${{ inputs.build_ffmpeg_aar || false }} + build_cache: ${{ github.event_name != 'workflow_dispatch' || inputs.build_cache }} + parallel: ${{ inputs.parallel || false }} + configuration_cache: ${{ inputs.configuration_cache || false }} diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml index 0c1bae0065..0bdba7a416 100644 --- a/.github/workflows/trusted-linux-ci.yml +++ b/.github/workflows/trusted-linux-ci.yml @@ -13,10 +13,27 @@ on: type: boolean required: false default: false + build_cache: + type: boolean + required: false + default: true + parallel: + type: boolean + required: false + default: false + configuration_cache: + type: boolean + required: false + default: false permissions: contents: read +env: + SILO_CI_CACHE_FLAG: ${{ inputs.build_cache && '--build-cache' || '--no-build-cache' }} + SILO_CI_PARALLEL_FLAG: ${{ inputs.parallel && '--parallel' || '--no-parallel' }} + SILO_CI_CONFIGURATION_FLAG: ${{ inputs.configuration_cache && '--configuration-cache' || '--no-configuration-cache' }} + jobs: ffmpeg-aar: name: Media3 FFmpeg AAR @@ -121,6 +138,14 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + # Writes are scoped to the default branch, manual runs requiring write + # access, or internal PRs from an explicit trusted association. + # Forks retain read-only access and cannot seed another ref's cache. + cache-read-only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + + - name: Record build inputs + run: ./scripts/ci-build-metadata.py # Exercise both Play artifacts, including R8 and bundle packaging. # Signing and publishing remain in release.yml. @@ -129,6 +154,7 @@ jobs: ./gradlew \ -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ :androidApp:bundleRelease :androidTvApp:bundleRelease \ + "${SILO_CI_CACHE_FLAG}" "${SILO_CI_PARALLEL_FLAG}" "${SILO_CI_CONFIGURATION_FLAG}" \ --max-workers=2 test -s androidApp/build/outputs/bundle/release/androidApp-release.aab test -s androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab @@ -157,6 +183,14 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + # Writes are scoped to the default branch, manual runs requiring write + # access, or internal PRs from an explicit trusted association. + # Forks retain read-only access and cannot seed another ref's cache. + cache-read-only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + + - name: Record build inputs + run: ./scripts/ci-build-metadata.py # Robolectric does NOT resolve its Android runtimes through Gradle. At test # execution time it fetches android-all-instrumented straight from Maven @@ -195,6 +229,7 @@ jobs: ./gradlew \ -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ testDebugUnitTest \ + "${SILO_CI_CACHE_FLAG}" "${SILO_CI_PARALLEL_FLAG}" "${SILO_CI_CONFIGURATION_FLAG}" \ --max-workers=2 - name: Upload test reports @@ -225,6 +260,14 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + # Writes are scoped to the default branch, manual runs requiring write + # access, or internal PRs from an explicit trusted association. + # Forks retain read-only access and cannot seed another ref's cache. + cache-read-only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + + - name: Record build inputs + run: ./scripts/ci-build-metadata.py - name: Run lint shell: bash @@ -247,6 +290,7 @@ jobs: -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease \ + "${SILO_CI_CACHE_FLAG}" "${SILO_CI_PARALLEL_FLAG}" "${SILO_CI_CONFIGURATION_FLAG}" \ --max-workers=2 - name: Upload lint reports diff --git a/build.gradle.kts b/build.gradle.kts index 78b8b2d57d..17ee3b8bf7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,14 @@ plugins { } allprojects { + tasks.withType().configureEach { + // Source-policy tests read workflows, manifests and sibling source trees + // directly. Until all those inputs are declared, always execute tests; + // compilation, resource, lint and R8 outputs can still use the cache. + outputs.cacheIf("Tests inspect files outside their runtime classpath") { false } + outputs.upToDateWhen { false } + } + dependencyLocking { lockAllConfigurations() } diff --git a/gradle.properties b/gradle.properties index febad1af53..99c6357757 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.caching=true android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true diff --git a/scripts/ci-build-metadata.py b/scripts/ci-build-metadata.py new file mode 100755 index 0000000000..d6c56be512 --- /dev/null +++ b/scripts/ci-build-metadata.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Record reproducible build inputs without publishing a Build Scan.""" + +import json +import os +import re +import subprocess +from pathlib import Path + +source_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() +wrapper = Path("gradle/wrapper/gradle-wrapper.properties").read_text() +catalog = Path("gradle/libs.versions.toml").read_text() +gradle = re.search(r"gradle-([\d.]+)-bin", wrapper).group(1) +agp = re.search(r'^agp = "([^"]+)"', catalog, re.MULTILINE).group(1) +cache = os.environ.get("SILO_CI_CACHE_FLAG", "--build-cache") +metadata = { + "source_sha": source_sha, + "workflow_sha": os.environ.get("GITHUB_SHA", "local"), + "variant": "baseline" if cache == "--no-build-cache" else "optimized", + "cache_regime": "unverified", + "cache_namespace": os.environ.get("GITHUB_REF", "local"), + "profile": " ".join( + os.environ.get(key, "") + for key in ("SILO_CI_CACHE_FLAG", "SILO_CI_PARALLEL_FLAG", "SILO_CI_CONFIGURATION_FLAG") + ).strip(), + "toolchain": f"JDK 21 / Gradle {gradle} / AGP {agp}", +} +print("SILO_CI_BENCHMARK " + json.dumps(metadata, sort_keys=True)) From 4f19bc0a22230337dad7f7f5cd55041ee2801f42 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:20:36 -0400 Subject: [PATCH 50/87] ci: keep cache baseline runs read only --- .github/workflows/trusted-linux-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml index 0bdba7a416..1431b249f9 100644 --- a/.github/workflows/trusted-linux-ci.yml +++ b/.github/workflows/trusted-linux-ci.yml @@ -142,7 +142,7 @@ jobs: # Writes are scoped to the default branch, manual runs requiring write # access, or internal PRs from an explicit trusted association. # Forks retain read-only access and cannot seed another ref's cache. - cache-read-only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + cache-read-only: ${{ !inputs.build_cache || !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} - name: Record build inputs run: ./scripts/ci-build-metadata.py @@ -187,7 +187,7 @@ jobs: # Writes are scoped to the default branch, manual runs requiring write # access, or internal PRs from an explicit trusted association. # Forks retain read-only access and cannot seed another ref's cache. - cache-read-only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + cache-read-only: ${{ !inputs.build_cache || !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} - name: Record build inputs run: ./scripts/ci-build-metadata.py @@ -264,7 +264,7 @@ jobs: # Writes are scoped to the default branch, manual runs requiring write # access, or internal PRs from an explicit trusted association. # Forks retain read-only access and cannot seed another ref's cache. - cache-read-only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + cache-read-only: ${{ !inputs.build_cache || !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} - name: Record build inputs run: ./scripts/ci-build-metadata.py From dcdb9184f92c41460cd173f6903d715bb6bffa53 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:34:54 -0400 Subject: [PATCH 51/87] test: prove Android source checks execute with warm caches --- scripts/ci-build-metadata.py | 4 +- scripts/test-ci-cache-invalidation.py | 92 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100755 scripts/test-ci-cache-invalidation.py diff --git a/scripts/ci-build-metadata.py b/scripts/ci-build-metadata.py index d6c56be512..2837b46566 100755 --- a/scripts/ci-build-metadata.py +++ b/scripts/ci-build-metadata.py @@ -15,7 +15,7 @@ cache = os.environ.get("SILO_CI_CACHE_FLAG", "--build-cache") metadata = { "source_sha": source_sha, - "workflow_sha": os.environ.get("GITHUB_SHA", "local"), + "workflow_sha": os.environ.get("GITHUB_WORKFLOW_SHA", os.environ.get("GITHUB_SHA", "local")), "variant": "baseline" if cache == "--no-build-cache" else "optimized", "cache_regime": "unverified", "cache_namespace": os.environ.get("GITHUB_REF", "local"), @@ -24,5 +24,7 @@ for key in ("SILO_CI_CACHE_FLAG", "SILO_CI_PARALLEL_FLAG", "SILO_CI_CONFIGURATION_FLAG") ).strip(), "toolchain": f"JDK 21 / Gradle {gradle} / AGP {agp}", + "java_version": subprocess.check_output(["java", "-version"], stderr=subprocess.STDOUT, text=True).splitlines()[0], + "runner_image": os.environ.get("ImageVersion", "unavailable"), } print("SILO_CI_BENCHMARK " + json.dumps(metadata, sort_keys=True)) diff --git a/scripts/test-ci-cache-invalidation.py b/scripts/test-ci-cache-invalidation.py new file mode 100755 index 0000000000..5e242d1f59 --- /dev/null +++ b/scripts/test-ci-cache-invalidation.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Prove source-reading tests fail after file-only edits in a warm workspace.""" + +import json +import re +import subprocess +from pathlib import Path +import xml.etree.ElementTree as ET + +root = Path(__file__).resolve().parents[1] +logs = root / "build/ci-cache-invalidation" +logs.mkdir(parents=True, exist_ok=True) +probes = [ + ( + "workflow", + ":androidTvApp:testDebugUnitTest", + "org.siloserver.silo.tv.ui.screens.player.TvFireTvRcFeedbackOwnershipTest", + "release display version keeps the complete tag", + root / ".github/workflows/release.yml", + lambda text: text.replace( + "SILO_DISPLAY_VERSION: ${{ needs.setup.outputs.version }}", + "SILO_DISPLAY_VERSION: ${{ needs.setup.outputs.version_name }}", + ), + ), + ( + "sibling-manifest", + ":androidApp:testDebugUnitTest", + "org.siloserver.silo.android.auth.NativeSignInManifestTest", + "tvNeverClaimsTheAppRedirect", + root / "androidTvApp/src/androidMain/AndroidManifest.xml", + lambda text: text + '\n\n', + ), + ( + "source-comment", + ":shared:testDebugUnitTest", + "org.siloserver.silo.network.apiv2.ApiV2NoV1TransportSourceTest", + "noV1PathLiteralUnderApiV2Package", + next((root / "shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2").glob("*.kt")), + lambda text: text + "\n// /api/v1\n", + ), +] + +results = [] +for name, task, test, method, source, mutate in probes: + command = [ + str(root / "gradlew"), task, "--tests", test, + "--build-cache", "--max-workers=2", "--console=plain", + '-Dorg.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8', + ] + report = root / task.split(":")[1] / "build/test-results/testDebugUnitTest" / f"TEST-{test}.xml" + + def run(label): + result = subprocess.run(command, cwd=root, capture_output=True, text=True) + output = result.stdout + result.stderr + (logs / f"{name}-{label}.log").write_text(output) + suffix = " FAILED" if label == "changed" else "" + if not re.search(r"^> Task " + re.escape(task) + suffix + r"$", output, re.MULTILINE): + raise RuntimeError(f"{name}/{label}: the target Test task did not execute") + if not report.exists(): + raise RuntimeError(f"{name}/{label}: the target test produced no report") + (logs / f"{name}-{label}.xml").write_bytes(report.read_bytes()) + cases = ET.parse(report).getroot().findall("testcase") + target = [case for case in cases if case.get("classname") == test and case.get("name") == method] + if len(target) != 1 or any(case.find("skipped") is not None for case in cases): + raise RuntimeError(f"{name}/{label}: the target test did not execute") + if label == "changed": + failures = target[0].findall("failure") + if len(failures) != 1 or failures[0].get("type") != "java.lang.AssertionError": + raise RuntimeError(f"{name}: mutation did not fail the expected assertion") + elif result.returncode or any(case.find("failure") is not None or case.find("error") is not None for case in cases): + raise RuntimeError(f"{name}/{label}: unchanged focused tests failed") + return result.returncode, output + + original = source.read_bytes() + changed = mutate(original.decode()) + if changed == original.decode(): + raise RuntimeError(f"{name}: mutation did not change the input") + try: + status, output = run("warm") + source.write_text(changed) + status, output = run("changed") + if not status or not re.search(r"^> Task " + re.escape(task) + r" FAILED$", output, re.MULTILINE): + raise RuntimeError(f"{name}: changed source was not rejected by the Test task") + finally: + source.write_bytes(original) + status, output = run("restored") + if status: + raise RuntimeError(f"{name}: restored focused test failed") + results.append({"probe": name, "changed_input": str(source.relative_to(root)), "result": "passed"}) + print(f"{name}: warm passed; file-only mutation failed; restoration passed", flush=True) + +(logs / "results.json").write_text(json.dumps(results, indent=2)) From d9e4a9b1b175132b1ab0b7291b97a04385d03aba Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:53:20 -0400 Subject: [PATCH 52/87] ci: report executed tests and explicit cache policy --- .github/workflows/trusted-linux-ci.yml | 40 ++++++++++++++++++++++---- scripts/ci-build-metadata.py | 10 +++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml index 1431b249f9..f6a4ce51d8 100644 --- a/.github/workflows/trusted-linux-ci.yml +++ b/.github/workflows/trusted-linux-ci.yml @@ -33,6 +33,9 @@ env: SILO_CI_CACHE_FLAG: ${{ inputs.build_cache && '--build-cache' || '--no-build-cache' }} SILO_CI_PARALLEL_FLAG: ${{ inputs.parallel && '--parallel' || '--no-parallel' }} SILO_CI_CONFIGURATION_FLAG: ${{ inputs.configuration_cache && '--configuration-cache' || '--no-configuration-cache' }} + # Pass a literal string to action inputs and record the same policy decision. + # Internal PR writes stay scoped to their merge ref; forks remain read-only. + SILO_CI_CACHE_READ_ONLY: ${{ inputs.build_cache && (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) && 'false' || 'true' }} jobs: ffmpeg-aar: @@ -142,7 +145,7 @@ jobs: # Writes are scoped to the default branch, manual runs requiring write # access, or internal PRs from an explicit trusted association. # Forks retain read-only access and cannot seed another ref's cache. - cache-read-only: ${{ !inputs.build_cache || !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + cache-read-only: ${{ env.SILO_CI_CACHE_READ_ONLY }} - name: Record build inputs run: ./scripts/ci-build-metadata.py @@ -187,7 +190,7 @@ jobs: # Writes are scoped to the default branch, manual runs requiring write # access, or internal PRs from an explicit trusted association. # Forks retain read-only access and cannot seed another ref's cache. - cache-read-only: ${{ !inputs.build_cache || !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + cache-read-only: ${{ env.SILO_CI_CACHE_READ_ONLY }} - name: Record build inputs run: ./scripts/ci-build-metadata.py @@ -224,14 +227,41 @@ jobs: run: | set -euo pipefail - # Debug-variant unit tests only. The release variant compiles and runs - # the exact same sources for no extra signal, and doubles CI time. + # CI exercises debug variants; release.yml retains the host tests for + # distinct release behavior and BuildConfig values. ./gradlew \ -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ testDebugUnitTest \ "${SILO_CI_CACHE_FLAG}" "${SILO_CI_PARALLEL_FLAG}" "${SILO_CI_CONFIGURATION_FLAG}" \ --max-workers=2 + python3 - <<'PY' + import json + from pathlib import Path + import xml.etree.ElementTree as ET + reports = sorted(Path('.').glob('*/build/test-results/*/TEST-*.xml')) + totals = dict.fromkeys(('tests', 'failures', 'errors', 'skipped'), 0) + if not reports: + raise SystemExit('No JUnit reports were produced') + for report in reports: + suite = ET.parse(report).getroot() + if suite.tag != 'testsuite': + raise SystemExit(f'Invalid JUnit suite: {report}') + cases = suite.findall('testcase') + actual = {'tests': len(cases), **{ + key: sum(case.find(tag) is not None for case in cases) + for key, tag in (('failures', 'failure'), ('errors', 'error'), ('skipped', 'skipped')) + }} + for key in totals: + count = int(suite.attrib[key]) + if count != actual[key]: + raise SystemExit(f'Inconsistent JUnit {key}: {report}') + totals[key] += count + if totals['tests'] == 0 or totals['failures'] or totals['errors']: + raise SystemExit(f'JUnit validation failed: {totals}') + print('SILO_CI_TEST_TOTALS ' + json.dumps({**totals, 'reports': len(reports)}, sort_keys=True)) + PY + - name: Upload test reports if: failure() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 @@ -264,7 +294,7 @@ jobs: # Writes are scoped to the default branch, manual runs requiring write # access, or internal PRs from an explicit trusted association. # Forks retain read-only access and cannot seed another ref's cache. - cache-read-only: ${{ !inputs.build_cache || !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) }} + cache-read-only: ${{ env.SILO_CI_CACHE_READ_ONLY }} - name: Record build inputs run: ./scripts/ci-build-metadata.py diff --git a/scripts/ci-build-metadata.py b/scripts/ci-build-metadata.py index 2837b46566..9454310818 100755 --- a/scripts/ci-build-metadata.py +++ b/scripts/ci-build-metadata.py @@ -13,6 +13,10 @@ gradle = re.search(r"gradle-([\d.]+)-bin", wrapper).group(1) agp = re.search(r'^agp = "([^"]+)"', catalog, re.MULTILINE).group(1) cache = os.environ.get("SILO_CI_CACHE_FLAG", "--build-cache") +event_path = os.environ.get("GITHUB_EVENT_PATH") +event = json.loads(Path(event_path).read_text()) if event_path else {} +pull_request = event.get("pull_request") or {} +head_repository = (pull_request.get("head") or {}).get("repo") or {} metadata = { "source_sha": source_sha, "workflow_sha": os.environ.get("GITHUB_WORKFLOW_SHA", os.environ.get("GITHUB_SHA", "local")), @@ -26,5 +30,11 @@ "toolchain": f"JDK 21 / Gradle {gradle} / AGP {agp}", "java_version": subprocess.check_output(["java", "-version"], stderr=subprocess.STDOUT, text=True).splitlines()[0], "runner_image": os.environ.get("ImageVersion", "unavailable"), + "cache_policy": { + "read_only": os.environ.get("SILO_CI_CACHE_READ_ONLY", "unavailable"), + "event_name": os.environ.get("GITHUB_EVENT_NAME", "local"), + "head_repository": head_repository.get("full_name", ""), + "author_association": pull_request.get("author_association", ""), + }, } print("SILO_CI_BENCHMARK " + json.dumps(metadata, sort_keys=True)) From d2a2bc425027b80421a32a0a9fc2e2a98f759288 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:55:40 -0400 Subject: [PATCH 53/87] ci: count only the requested debug test profile --- .github/workflows/trusted-linux-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml index f6a4ce51d8..befb7ff0ef 100644 --- a/.github/workflows/trusted-linux-ci.yml +++ b/.github/workflows/trusted-linux-ci.yml @@ -239,7 +239,7 @@ jobs: import json from pathlib import Path import xml.etree.ElementTree as ET - reports = sorted(Path('.').glob('*/build/test-results/*/TEST-*.xml')) + reports = sorted(Path('.').glob('*/build/test-results/testDebugUnitTest/TEST-*.xml')) totals = dict.fromkeys(('tests', 'failures', 'errors', 'skipped'), 0) if not reports: raise SystemExit('No JUnit reports were produced') From 7cb1fd2b4097cf0bf55fc59efb3fae97e6c4b3bb Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:37:24 -0400 Subject: [PATCH 54/87] ci(android): route prose changes and enforce validation results --- .github/workflows/trusted-linux-ci.yml | 76 ++++++++--- scripts/ci-result.py | 58 +++++++++ scripts/ci-routing.py | 147 ++++++++++++++++++++++ scripts/fixtures/ci-result.json | 23 ++++ scripts/fixtures/ci-routing.json | 38 ++++++ scripts/test-ci-result.py | 114 +++++++++++++++++ scripts/test-ci-routing.py | 167 +++++++++++++++++++++++++ 7 files changed, 608 insertions(+), 15 deletions(-) create mode 100644 scripts/ci-result.py create mode 100644 scripts/ci-routing.py create mode 100644 scripts/fixtures/ci-result.json create mode 100644 scripts/fixtures/ci-routing.json create mode 100644 scripts/test-ci-result.py create mode 100644 scripts/test-ci-routing.py diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml index befb7ff0ef..c14beb6da0 100644 --- a/.github/workflows/trusted-linux-ci.yml +++ b/.github/workflows/trusted-linux-ci.yml @@ -38,9 +38,43 @@ env: SILO_CI_CACHE_READ_ONLY: ${{ inputs.build_cache && (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association))) && 'false' || 'true' }} jobs: + changes: + name: Select CI work + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + schema: ${{ steps.route.outputs.schema }} + mode: ${{ steps.route.outputs.mode }} + unit_tests: ${{ steps.route.outputs.unit_tests }} + lint: ${{ steps.route.outputs.lint }} + release_readiness: ${{ steps.route.outputs.release_readiness }} + ffmpeg_aar: ${{ steps.route.outputs.ffmpeg_aar }} + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Check build supply chain and CI scripts + run: | + ./scripts/test-check-build-supply-chain.sh + ./scripts/check-build-supply-chain.sh + ./scripts/test-release-workflow.sh + python3 scripts/test-ci-routing.py -v + python3 scripts/test-ci-result.py -v + + - name: Select validation jobs + id: route + run: python3 scripts/ci-routing.py + env: + SILO_CI_FFMPEG_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.build_ffmpeg_aar && 'true' || 'false' }} + ffmpeg-aar: name: Media3 FFmpeg AAR - if: github.event_name == 'workflow_dispatch' && inputs.build_ffmpeg_aar + needs: changes + if: needs.changes.outputs.ffmpeg_aar == 'true' runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-ffmpeg-aar"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} timeout-minutes: 45 @@ -108,7 +142,8 @@ jobs: release-readiness: name: Release readiness - if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + needs: changes + if: needs.changes.outputs.release_readiness == 'true' runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-release-readiness"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} timeout-minutes: 45 @@ -164,7 +199,8 @@ jobs: unit-tests: name: Unit tests - if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + needs: changes + if: needs.changes.outputs.unit_tests == 'true' runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-unit-tests"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} steps: @@ -173,11 +209,6 @@ jobs: with: persist-credentials: false - - name: Check build supply chain - run: | - ./scripts/test-check-build-supply-chain.sh - ./scripts/check-build-supply-chain.sh - - name: Set up JDK 21 uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: @@ -273,7 +304,8 @@ jobs: lint: name: Lint - if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + needs: changes + if: needs.changes.outputs.lint == 'true' runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-lint"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} steps: @@ -311,15 +343,11 @@ jobs: # NEW violations fail. If this job fails, fix the call — do not # regenerate the baseline to make it pass. # - # lintVitalRelease runs here too. checkReleaseBuilds makes it part of - # assembleRelease, which only ever runs from release.yml on a v* tag — - # so without this the release variant is gated by a check that no pull - # request exercises, and the first run would be mid-release. It also - # covers the release-only source sets that lintDebug never sees. + # Release readiness builds both release bundles, which run fatal + # release lint. The final Android CI gate requires that job too. ./gradlew \ -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ - :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease \ "${SILO_CI_CACHE_FLAG}" "${SILO_CI_PARALLEL_FLAG}" "${SILO_CI_CONFIGURATION_FLAG}" \ --max-workers=2 @@ -332,3 +360,21 @@ jobs: **/build/reports/lint-results-*.html **/build/reports/lint-results-*.xml if-no-files-found: ignore + + android-ci: + name: Android CI + if: always() + needs: [changes, unit-tests, lint, release-readiness, ffmpeg-aar] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Require every selected validation job to succeed + run: python3 scripts/ci-result.py + env: + SILO_CI_NEEDS_JSON: ${{ toJSON(needs) }} diff --git a/scripts/ci-result.py b/scripts/ci-result.py new file mode 100644 index 0000000000..ff14aa465a --- /dev/null +++ b/scripts/ci-result.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Fail the stable Android CI check unless every selected job succeeded.""" + +import json +import os +import sys + + +JOBS = { + "unit-tests": "unit_tests", + "lint": "lint", + "release-readiness": "release_readiness", + "ffmpeg-aar": "ffmpeg_aar", +} + + +def check_results(needs): + if not isinstance(needs, dict) or set(needs) != {"changes", *JOBS}: + raise ValueError("Missing or unfamiliar dependency results") + classifier = needs["changes"] + if not isinstance(classifier, dict) or classifier.get("result") != "success": + raise ValueError("Change classification did not succeed") + selected = classifier.get("outputs") + if not isinstance(selected, dict): + raise ValueError("Missing classifier outputs") + mode = selected.get("mode") + if mode not in {"full", "docs", "ffmpeg"}: + raise ValueError("Invalid classifier mode") + expected = { + "schema": "1", + "mode": mode, + "unit_tests": "true" if mode == "full" else "false", + "lint": "true" if mode == "full" else "false", + "release_readiness": "true" if mode == "full" else "false", + "ffmpeg_aar": "true" if mode == "ffmpeg" else "false", + } + if selected != expected: + raise ValueError("Incomplete or inconsistent classifier outputs") + for job, output in JOBS.items(): + result = needs[job].get("result") if isinstance(needs[job], dict) else None + required_result = "success" if selected[output] == "true" else "skipped" + if result != required_result: + raise ValueError(f"{job}: expected {required_result}, got {result!r}") + return mode + + +def main(): + try: + mode = check_results(json.loads(os.environ["SILO_CI_NEEDS_JSON"])) + except (KeyError, ValueError, TypeError) as error: + print(f"Android CI failed: {error}", file=sys.stderr) + return 1 + print(f"Android CI passed ({mode})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci-routing.py b/scripts/ci-routing.py new file mode 100644 index 0000000000..5b48fffd7d --- /dev/null +++ b/scripts/ci-routing.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Select Android CI work from a complete Git diff; uncertainty runs all jobs.""" + +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess + + +# These are prose-only areas. Keep policies, API contracts, fixtures, native +# notices, scripts, and unfamiliar documentation locations on full CI. +DOC_FILES = { + "README.md", + "FEATURES.md", + "scripts/README-ffmpeg-aar.md", + "docs/playback/README.md", + "docs/playback/intro-skip.md", + "docs/playback/01-media3-only-player-architecture.md", + "docs/playback/02-migration-compatibility-validation.md", + "docs/playback/03-reference-implementation-review.md", + "docs/playback/04-implementation-status-and-dv-handoff.md", +} +DOC_DIRECTORIES = ( + "docs/notes/", + "docs/plans/", + "docs/superpowers/notes/", + "docs/superpowers/plans/", + "docs/superpowers/specs/", +) +SHA = re.compile(r"[0-9a-f]{40}") + + +def outputs(mode): + return { + "schema": "1", + "mode": mode, + "unit_tests": str(mode == "full").lower(), + "lint": str(mode == "full").lower(), + "release_readiness": str(mode == "full").lower(), + "ffmpeg_aar": str(mode == "ffmpeg").lower(), + } + + +def harmless_document(path): + parts = PurePosixPath(path).parts + if ( + not parts + or path.startswith("/") + or any(part in {"", ".", ".."} for part in path.split("/")) + or any(ord(character) < 32 for character in path) + or "\\" in path + ): + return False + # Contract and fixture documents are deliberately outside the prose route, + # even if a future one is added below an otherwise permitted directory. + lower_path = path.lower() + if any(word in lower_path for word in ("api-v2", "contract", "fixture")): + return False + return path in DOC_FILES or ( + path.endswith(".md") and path.startswith(DOC_DIRECTORIES) + ) + + +def classify_diff(data): + """Consume git diff --name-status -z, including rename source and target.""" + if not data or not data.endswith(b"\0"): + return "full", "Missing or incomplete diff" + try: + fields = data[:-1].decode("utf-8", errors="strict").split("\0") + except UnicodeDecodeError: + return "full", "Diff paths are not valid UTF-8" + index = 0 + while index < len(fields): + status = fields[index] + index += 1 + if status not in {"A", "M"}: + return "full", "Deletion, rename, or unfamiliar change status" + if index >= len(fields) or not harmless_document(fields[index]): + return "full", "Changed path requires Android validation" + index += 1 + return "docs", "Only prose documentation was added or modified" + + +def git(*arguments): + return subprocess.run( + ["git", *arguments], check=True, capture_output=True, timeout=30 + ).stdout + + +def classify_environment(environment): + event_name = environment.get("GITHUB_EVENT_NAME", "") + ffmpeg = environment.get("SILO_CI_FFMPEG_ONLY", "false") + if ffmpeg not in {"true", "false"}: + return "full", "Invalid manual FFmpeg input" + if event_name == "workflow_dispatch": + return ("ffmpeg", "Explicit FFmpeg-only dispatch") if ffmpeg == "true" else ( + "full", "Manual dispatch requests full Android validation" + ) + if event_name not in {"pull_request", "push"} or ffmpeg == "true": + return "full", "Unfamiliar event or invalid FFmpeg event" + try: + event = json.loads(Path(environment["GITHUB_EVENT_PATH"]).read_text()) + base = event["pull_request"]["base"]["sha"] if event_name == "pull_request" else event["before"] + head = environment["GITHUB_SHA"] + if not isinstance(base, str) or not SHA.fullmatch(base) or base == "0" * 40: + return "full", "No usable base commit" + if not SHA.fullmatch(head) or git("rev-parse", "HEAD").decode().strip() != head: + return "full", "Checkout does not match the requested commit" + # Checkout stays shallow. Fetch only the exact base/before commit if it + # is absent; failures keep all validation rather than trusting a file list + # from the API (which can be truncated). + try: + git("cat-file", "-e", f"{base}^{{commit}}") + except subprocess.CalledProcessError: + git("fetch", "--no-tags", "--depth=1", "origin", base) + data = git("diff", "--name-status", "-z", "--find-renames", base, head, "--") + mode, reason = classify_diff(data) + if mode == "docs": + paths = data[:-1].decode("utf-8").split("\0")[1::2] + tree = git("ls-tree", "-z", head, "--", *paths) + entries = tree[:-1].split(b"\0") if tree.endswith(b"\0") else [] + found = set() + for entry in entries: + metadata, path = entry.split(b"\t", 1) + if not metadata.startswith(b"100644 blob "): + return "full", "Documentation is not a regular prose file" + found.add(path.decode("utf-8")) + if found != set(paths): + return "full", "Could not verify every documentation file" + except (KeyError, TypeError, ValueError, OSError, subprocess.SubprocessError): + return "full", "Could not obtain a complete diff" + return mode, reason + + +def main(): + mode, reason = classify_environment(os.environ) + selected = outputs(mode) + print(json.dumps({**selected, "reason": reason}, sort_keys=True)) + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + for key, value in selected.items(): + output.write(f"{key}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/fixtures/ci-result.json b/scripts/fixtures/ci-result.json new file mode 100644 index 0000000000..522b76f792 --- /dev/null +++ b/scripts/fixtures/ci-result.json @@ -0,0 +1,23 @@ +{ + "full": { + "changes": {"result": "success", "outputs": {"schema": "1", "mode": "full", "unit_tests": "true", "lint": "true", "release_readiness": "true", "ffmpeg_aar": "false"}}, + "unit-tests": {"result": "success"}, + "lint": {"result": "success"}, + "release-readiness": {"result": "success"}, + "ffmpeg-aar": {"result": "skipped"} + }, + "docs": { + "changes": {"result": "success", "outputs": {"schema": "1", "mode": "docs", "unit_tests": "false", "lint": "false", "release_readiness": "false", "ffmpeg_aar": "false"}}, + "unit-tests": {"result": "skipped"}, + "lint": {"result": "skipped"}, + "release-readiness": {"result": "skipped"}, + "ffmpeg-aar": {"result": "skipped"} + }, + "ffmpeg": { + "changes": {"result": "success", "outputs": {"schema": "1", "mode": "ffmpeg", "unit_tests": "false", "lint": "false", "release_readiness": "false", "ffmpeg_aar": "true"}}, + "unit-tests": {"result": "skipped"}, + "lint": {"result": "skipped"}, + "release-readiness": {"result": "skipped"}, + "ffmpeg-aar": {"result": "success"} + } +} diff --git a/scripts/fixtures/ci-routing.json b/scripts/fixtures/ci-routing.json new file mode 100644 index 0000000000..dc44d7692c --- /dev/null +++ b/scripts/fixtures/ci-routing.json @@ -0,0 +1,38 @@ +[ + {"case": "modified readme", "diff": "M\u0000README.md\u0000", "mode": "docs"}, + {"case": "new prose plan", "diff": "A\u0000docs/plans/new-plan.md\u0000", "mode": "docs"}, + {"case": "multiple prose files", "diff": "M\u0000FEATURES.md\u0000A\u0000docs/notes/new note.md\u0000", "mode": "docs"}, + {"case": "native build guide", "diff": "M\u0000scripts/README-ffmpeg-aar.md\u0000", "mode": "docs"}, + {"case": "source change", "diff": "M\u0000androidApp/src/androidMain/kotlin/App.kt\u0000", "mode": "full"}, + {"case": "shared change", "diff": "M\u0000shared/src/commonMain/kotlin/App.kt\u0000", "mode": "full"}, + {"case": "native change", "diff": "M\u0000android-shared/src/native/dovi/build.sh\u0000", "mode": "full"}, + {"case": "new non-document", "diff": "A\u0000docs/plans/new-input.json\u0000", "mode": "full"}, + {"case": "unknown markdown", "diff": "A\u0000new-area/README.md\u0000", "mode": "full"}, + {"case": "unknown docs markdown", "diff": "A\u0000docs/new-area/note.md\u0000", "mode": "full"}, + {"case": "policy", "diff": "M\u0000AGENTS.md\u0000", "mode": "full"}, + {"case": "contribution policy", "diff": "M\u0000CONTRIBUTING.md\u0000", "mode": "full"}, + {"case": "legal document", "diff": "M\u0000TRADEMARK.md\u0000", "mode": "full"}, + {"case": "qualification plan", "diff": "M\u0000docs/watch-party-implementation-plan.md\u0000", "mode": "full"}, + {"case": "API document", "diff": "M\u0000docs/playback/sequenced-api-v2.md\u0000", "mode": "full"}, + {"case": "contract markdown in prose area", "diff": "A\u0000docs/plans/settings-contract.md\u0000", "mode": "full"}, + {"case": "fixture markdown in prose area", "diff": "A\u0000docs/notes/fixture.md\u0000", "mode": "full"}, + {"case": "vendored API fixture", "diff": "M\u0000shared/src/commonTest/resources/api/v2/fixtures/catalog/items.json\u0000", "mode": "full"}, + {"case": "vendored contract provenance", "diff": "M\u0000shared/src/commonTest/resources/diagnostics/v1/SOURCE.md\u0000", "mode": "full"}, + {"case": "Gradle catalog", "diff": "M\u0000gradle/libs.versions.toml\u0000", "mode": "full"}, + {"case": "Gradle build", "diff": "M\u0000build.gradle.kts\u0000", "mode": "full"}, + {"case": "manifest", "diff": "M\u0000androidTvApp/src/androidMain/AndroidManifest.xml\u0000", "mode": "full"}, + {"case": "workflow", "diff": "M\u0000.github/workflows/trusted-linux-ci.yml\u0000", "mode": "full"}, + {"case": "CI script", "diff": "M\u0000scripts/ci-routing.py\u0000", "mode": "full"}, + {"case": "mixed docs and code", "diff": "M\u0000README.md\u0000M\u0000shared/build.gradle.kts\u0000", "mode": "full"}, + {"case": "deleted documentation", "diff": "D\u0000README.md\u0000", "mode": "full"}, + {"case": "renamed documentation", "diff": "R100\u0000docs/plans/old.md\u0000docs/plans/new.md\u0000", "mode": "full"}, + {"case": "copied documentation", "diff": "C100\u0000README.md\u0000FEATURES.md\u0000", "mode": "full"}, + {"case": "unknown status", "diff": "Z\u0000README.md\u0000", "mode": "full"}, + {"case": "truncated path", "diff": "M\u0000README.md", "mode": "full"}, + {"case": "truncated record", "diff": "M\u0000README.md\u0000A\u0000", "mode": "full"}, + {"case": "empty path", "diff": "M\u0000\u0000", "mode": "full"}, + {"case": "empty diff", "diff": "", "mode": "full"}, + {"case": "path traversal", "diff": "A\u0000docs/plans/../../build.md\u0000", "mode": "full"}, + {"case": "absolute path", "diff": "M\u0000/README.md\u0000", "mode": "full"}, + {"case": "control character", "diff": "A\u0000docs/plans/new\nnote.md\u0000", "mode": "full"} +] diff --git a/scripts/test-ci-result.py b/scripts/test-ci-result.py new file mode 100644 index 0000000000..6987939a29 --- /dev/null +++ b/scripts/test-ci-result.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 + +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import unittest + + +SCRIPT_DIR = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("ci_result", SCRIPT_DIR / "ci-result.py") +result = importlib.util.module_from_spec(spec) +spec.loader.exec_module(result) +FIXTURES = json.loads((SCRIPT_DIR / "fixtures/ci-result.json").read_text()) + + +class AggregateResultTest(unittest.TestCase): + def test_successful_full_docs_and_ffmpeg_modes(self): + for mode, needs in FIXTURES.items(): + with self.subTest(mode=mode): + self.assertEqual(mode, result.check_results(needs)) + + def test_each_selected_job_must_succeed(self): + for mode, jobs in (("full", ("unit-tests", "lint", "release-readiness")), ("ffmpeg", ("ffmpeg-aar",))): + for job in jobs: + for outcome in ("skipped", "failure", "cancelled", "unknown", None): + with self.subTest(mode=mode, job=job, outcome=outcome): + needs = copy.deepcopy(FIXTURES[mode]) + needs[job]["result"] = outcome + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_unselected_jobs_must_be_intentionally_skipped(self): + for mode, jobs in (("full", ("ffmpeg-aar",)), ("docs", ("unit-tests", "lint", "release-readiness", "ffmpeg-aar")), + ("ffmpeg", ("unit-tests", "lint", "release-readiness"))): + for job in jobs: + for outcome in ("success", "failure", "cancelled", "unknown", None): + with self.subTest(mode=mode, job=job, outcome=outcome): + needs = copy.deepcopy(FIXTURES[mode]) + needs[job]["result"] = outcome + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_classifier_must_succeed_in_every_mode(self): + for mode in FIXTURES: + for outcome in ("skipped", "failure", "cancelled", None): + with self.subTest(mode=mode, outcome=outcome): + needs = copy.deepcopy(FIXTURES[mode]) + needs["changes"]["result"] = outcome + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_outputs_must_be_complete_and_consistent(self): + for mode in FIXTURES: + for key in FIXTURES[mode]["changes"]["outputs"]: + with self.subTest(mode=mode, missing=key): + needs = copy.deepcopy(FIXTURES[mode]) + del needs["changes"]["outputs"][key] + with self.assertRaises(ValueError): + result.check_results(needs) + for invalid in ("", "unknown", True, False, None): + with self.subTest(mode=mode, key=key, invalid=invalid): + needs = copy.deepcopy(FIXTURES[mode]) + needs["changes"]["outputs"][key] = invalid + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_docs_mode_cannot_turn_on_a_validation_job(self): + for key in ("unit_tests", "lint", "release_readiness", "ffmpeg_aar"): + with self.subTest(key=key): + needs = copy.deepcopy(FIXTURES["docs"]) + needs["changes"]["outputs"][key] = "true" + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_incomplete_dependency_payload_is_rejected(self): + for job in FIXTURES["full"]: + with self.subTest(job=job): + needs = copy.deepcopy(FIXTURES["full"]) + del needs[job] + with self.assertRaises(ValueError): + result.check_results(needs) + for needs in (None, [], {}, {**FIXTURES["full"], "unexpected": {"result": "success"}}): + with self.subTest(needs=needs): + with self.assertRaises(ValueError): + result.check_results(needs) + + def test_cli_exits_nonzero_for_failed_jobs_and_invalid_json(self): + failed = copy.deepcopy(FIXTURES["full"]) + failed["release-readiness"]["result"] = "failure" + for payload in (json.dumps(failed), "{", "null"): + with self.subTest(payload=payload): + process = self.run_cli(payload) + self.assertEqual(1, process.returncode) + self.assertIn("Android CI failed", process.stderr) + + def test_cli_accepts_only_valid_special_mode_results(self): + for mode in ("docs", "ffmpeg"): + with self.subTest(mode=mode): + process = self.run_cli(json.dumps(FIXTURES[mode])) + self.assertEqual(0, process.returncode, process.stderr) + self.assertIn(f"Android CI passed ({mode})", process.stdout) + + def run_cli(self, payload): + return subprocess.run( + ["python3", str(SCRIPT_DIR / "ci-result.py")], capture_output=True, text=True, + env={**os.environ, "SILO_CI_NEEDS_JSON": payload}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-ci-routing.py b/scripts/test-ci-routing.py new file mode 100644 index 0000000000..ed79e0c103 --- /dev/null +++ b/scripts/test-ci-routing.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +SCRIPT_DIR = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("ci_routing", SCRIPT_DIR / "ci-routing.py") +routing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(routing) + + +class DiffRoutingTest(unittest.TestCase): + def test_document_and_full_validation_fixtures(self): + fixtures = json.loads((SCRIPT_DIR / "fixtures/ci-routing.json").read_text()) + for fixture in fixtures: + with self.subTest(fixture["case"]): + self.assertEqual( + fixture["mode"], routing.classify_diff(fixture["diff"].encode())[0] + ) + + def test_invalid_path_encoding_runs_all_jobs(self): + self.assertEqual("full", routing.classify_diff(b"M\0docs/plans/\xff.md\0")[0]) + + def test_manual_dispatch_runs_all_jobs_without_needing_a_diff(self): + self.assertEqual("full", routing.classify_environment({"GITHUB_EVENT_NAME": "workflow_dispatch"})[0]) + + def test_only_explicit_ffmpeg_dispatch_selects_ffmpeg(self): + for event in ("workflow_dispatch", "pull_request", "push", "unknown"): + with self.subTest(event=event): + expected = "ffmpeg" if event == "workflow_dispatch" else "full" + self.assertEqual(expected, routing.classify_environment({ + "GITHUB_EVENT_NAME": event, "SILO_CI_FFMPEG_ONLY": "true" + })[0]) + + def test_invalid_dispatch_input_runs_all_jobs(self): + self.assertEqual("full", routing.classify_environment({ + "GITHUB_EVENT_NAME": "workflow_dispatch", "SILO_CI_FFMPEG_ONLY": "yes" + })[0]) + + def test_unavailable_event_runs_all_jobs(self): + for event in ("push", "pull_request", "unknown", ""): + with self.subTest(event=event): + self.assertEqual("full", routing.classify_environment({"GITHUB_EVENT_NAME": event})[0]) + + def test_timeout_runs_all_jobs(self): + with tempfile.TemporaryDirectory() as directory: + event = Path(directory) / "event.json" + event.write_text(json.dumps({"before": "a" * 40})) + with patch.object(routing, "git", side_effect=subprocess.TimeoutExpired("git", 30)): + self.assertEqual("full", routing.classify_environment({ + "GITHUB_EVENT_NAME": "push", "GITHUB_EVENT_PATH": str(event), "GITHUB_SHA": "b" * 40 + })[0]) + + +class GitRoutingTest(unittest.TestCase): + """Exercise the CLI against real commits and a shallow local checkout.""" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / "source" + self.source.mkdir() + self.run_git("init", "--quiet") + (self.source / "README.md").write_text("Original documentation\n") + (self.source / "build.gradle.kts").write_text("// build inputs\n") + self.run_git("add", ".") + self.base = self.commit("base") + + def run_git(self, *arguments, cwd=None, check=True): + return subprocess.run( + ["git", "-c", "user.name=CI Fixture", "-c", "user.email=ci@example.invalid", *arguments], + cwd=cwd or self.source, check=check, capture_output=True, text=True, + env={**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull}, + ) + + def commit(self, message): + self.run_git("commit", "--quiet", "-am", message) + return self.run_git("rev-parse", "HEAD").stdout.strip() + + def classify(self, *, before=None, event_name="push", head_override=None, invalid_event=False): + checkout = self.root / "checkout" + self.run_git("clone", "--quiet", "--depth=1", self.source.as_uri(), str(checkout)) + head = self.run_git("rev-parse", "HEAD", cwd=checkout).stdout.strip() + event = self.root / "event.json" + base = self.base if before is None else before + payload = {"before": base} if event_name == "push" else {"pull_request": {"base": {"sha": base}}} + event.write_text("{" if invalid_event else json.dumps(payload)) + output = self.root / "output.txt" + process = subprocess.run( + ["python3", str(SCRIPT_DIR / "ci-routing.py")], cwd=checkout, + capture_output=True, text=True, check=True, + env={**os.environ, "GITHUB_EVENT_NAME": event_name, "GITHUB_EVENT_PATH": str(event), + "GITHUB_SHA": head_override or head, "GITHUB_OUTPUT": str(output), + "SILO_CI_FFMPEG_ONLY": "false"}, + ) + result = json.loads(process.stdout) + saved = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual({key: value for key, value in result.items() if key != "reason"}, saved) + return result, checkout + + def test_fetches_exact_before_commit_from_shallow_checkout(self): + (self.source / "README.md").write_text("Updated documentation\n") + self.commit("docs") + result, checkout = self.classify() + self.assertEqual("docs", result["mode"]) + self.assertEqual(0, self.run_git("cat-file", "-e", f"{self.base}^{{commit}}", cwd=checkout).returncode) + self.assertEqual("false", result["unit_tests"]) + + def test_pull_request_uses_exact_base_commit(self): + (self.source / "README.md").write_text("Updated documentation\n") + self.commit("docs") + self.assertEqual("docs", self.classify(event_name="pull_request")[0]["mode"]) + + def test_mixed_code_and_documentation_runs_all_jobs(self): + (self.source / "README.md").write_text("Updated documentation\n") + (self.source / "build.gradle.kts").write_text("// changed build inputs\n") + self.commit("mixed") + result, _ = self.classify() + self.assertEqual("full", result["mode"]) + for key in ("unit_tests", "lint", "release_readiness"): + self.assertEqual("true", result[key]) + + def test_deleted_documentation_runs_all_jobs(self): + self.run_git("rm", "README.md") + self.commit("delete docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + def test_renamed_documentation_runs_all_jobs(self): + self.run_git("mv", "README.md", "FEATURES.md") + self.commit("rename docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + def test_missing_base_runs_all_jobs(self): + self.assertEqual("full", self.classify(before="f" * 40)[0]["mode"]) + + def test_new_branch_runs_all_jobs(self): + self.assertEqual("full", self.classify(before="0" * 40)[0]["mode"]) + + def test_wrong_checkout_runs_all_jobs(self): + self.assertEqual("full", self.classify(head_override="f" * 40)[0]["mode"]) + + def test_invalid_event_json_runs_all_jobs(self): + self.assertEqual("full", self.classify(invalid_event=True)[0]["mode"]) + + def test_added_document_symlink_runs_all_jobs(self): + (self.source / "docs/plans").mkdir(parents=True) + (self.source / "docs/plans/input.md").symlink_to("../../build.gradle.kts") + self.run_git("add", ".") + self.commit("symlink docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + def test_executable_document_runs_all_jobs(self): + self.run_git("update-index", "--chmod=+x", "README.md") + self.run_git("commit", "--quiet", "-m", "executable docs") + self.assertEqual("full", self.classify()[0]["mode"]) + + +if __name__ == "__main__": + unittest.main() From 0ba923316371a68e7bff4852ed373f93b483f376 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:37:37 -0400 Subject: [PATCH 55/87] ci: overlap Android APK preparation with Play --- .github/workflows/release.yml | 56 ++++--- scripts/test-release-workflow.sh | 275 +++++++++++++++++++++++++------ 2 files changed, 260 insertions(+), 71 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e894d037e..c3512b4ce8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,8 @@ name: Release # One release pipeline, playing the role of silo-apple's TestFlight # (release.yml) and sideload (sideload-ipa.yml) workflows combined: a version -# tag or a manual dispatch publishes both App Bundles to Google Play, then -# builds the signed sideload APKs and attaches them to a GitHub release for +# tag or a manual dispatch prepares both App Bundles for Google Play and +# signed sideload APKs in parallel, then attaches the APKs to a GitHub release for # the same version. Dispatch runs mint the v tag when creating the # GitHub release — no manual tagging is necessary (apple's sideload pattern). # @@ -207,6 +207,16 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + # Robolectric downloads its Android runtimes outside Gradle's cache. + # Share the normal CI key so releases reuse those runtime downloads. + - name: Cache Robolectric Android runtimes + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', '**/build.gradle.kts') }} + restore-keys: | + robolectric-${{ runner.os }}- + - name: Run unit tests shell: bash run: | @@ -270,17 +280,11 @@ jobs: SILO_RELEASE_KEY_ALIAS: ${{ secrets.SILO_RELEASE_KEY_ALIAS }} SILO_GOOGLE_SERVICES_JSON_B64: ${{ secrets.SILO_GOOGLE_SERVICES_JSON_B64 }} - # Signed sideload APKs (moved here from android-build.yml). Runs when the - # Play publish succeeded or was skipped (prerelease tags) — never when it - # failed, so the GitHub release can't outrun the Play track. + # Prepare signed sideload APKs alongside Play after setup and tests succeed. + # publish-release waits for both paths before exposing the APKs. apks: name: ${{ matrix.name }} - needs: [setup, unit-tests, play] - if: >- - ${{ !cancelled() && - needs.setup.result == 'success' && - needs.unit-tests.result == 'success' && - (needs.play.result == 'success' || needs.play.result == 'skipped') }} + needs: [setup, unit-tests] runs-on: ubuntu-latest strategy: fail-fast: false @@ -385,14 +389,6 @@ jobs: apk_name="$(basename "${apk}")" variant="${apk_name#${{ matrix.module }}-}" cp "${apk}" "${artifact_dir}/${{ matrix.release-prefix }}-${version_label}-${variant}" - - if [[ "${variant}" == "universal-release.apk" ]]; then - cp "${apk}" "${artifact_dir}/${{ matrix.release-prefix }}-latest-${variant}" - # The Downloader by AFTVnews short codes redirect to the old - # debug-named asset URL. Keep publishing the release build under - # that filename until the aftv.news codes are re-registered. - cp "${apk}" "${artifact_dir}/${{ matrix.release-prefix }}-latest-universal-debug.apk" - fi done - name: Upload release APKs @@ -409,11 +405,14 @@ jobs: # re-trigger workflows, so this cannot recurse. publish-release: name: Publish GitHub Release - needs: [setup, apks] + needs: [setup, unit-tests, play, apks] if: >- ${{ !cancelled() && needs.setup.result == 'success' && - needs.apks.result == 'success' }} + needs.unit-tests.result == 'success' && + needs.apks.result == 'success' && + ((needs.setup.outputs.play_publish == 'true' && needs.play.result == 'success') || + (needs.setup.outputs.play_publish == 'false' && needs.play.result == 'skipped')) }} runs-on: ubuntu-latest permissions: contents: write @@ -431,10 +430,25 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.setup.outputs.tag }} + RELEASE_VERSION: ${{ needs.setup.outputs.version }} run: | set -euo pipefail shopt -s globstar nullglob + # Transfer each APK once, then recreate the universal aliases here. + # Downloader by AFTVnews still redirects to the debug-named aliases. + for prefix in silo-android silo-android-tv; do + universal_apks=(release-artifacts/**/"${prefix}-${RELEASE_VERSION}-universal-release.apk") + if (( ${#universal_apks[@]} != 1 )) || [[ ! -s "${universal_apks[0]}" ]]; then + echo "::error::Expected one nonempty universal APK for ${prefix}." + exit 1 + fi + universal_apk="${universal_apks[0]}" + artifact_dir="$(dirname "${universal_apk}")" + cp "${universal_apk}" "${artifact_dir}/${prefix}-latest-universal-release.apk" + cp "${universal_apk}" "${artifact_dir}/${prefix}-latest-universal-debug.apk" + done + release_title="Silo Android ${RELEASE_TAG}" android_latest_url="https://github.com/${GH_REPO}/releases/latest/download/silo-android-latest-universal-release.apk" android_tv_latest_url="https://github.com/${GH_REPO}/releases/latest/download/silo-android-tv-latest-universal-release.apk" diff --git a/scripts/test-release-workflow.sh b/scripts/test-release-workflow.sh index 9da85f981e..22c59d0059 100755 --- a/scripts/test-release-workflow.sh +++ b/scripts/test-release-workflow.sh @@ -4,57 +4,232 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" -workflow_file="${repo_root}/.github/workflows/release.yml" -failures=0 -publish_job="$({ - awk ' - /^ publish-release:[[:space:]]*$/ { - in_publish_job = 1 - } - in_publish_job && - /^ [[:alnum:]_-]+:[[:space:]]*$/ && - $0 !~ /^ publish-release:/ { - exit +ruby - "${repo_root}" <<'RUBY' +require "fileutils" +require "json" +require "open3" +require "psych" +require "strscan" +require "tmpdir" + +repo_root = ARGV.fetch(0) +load_workflow = lambda do |name| + Psych.safe_load( + File.read(File.join(repo_root, ".github/workflows", name)), aliases: true + ).fetch("jobs") +end +jobs = load_workflow.call("release.yml") +failures = [] +check = lambda { |condition, message| failures << message unless condition } + +# Evaluate the actual workflow condition using its supported expression subset. +# GitHub compares strings without case sensitivity and supplies success() when +# an if condition has no status function. Only literals/operators reach eval; +# reject any new expression syntax until this evaluator explicitly supports it. +job_runs = lambda do |job, needs, cancelled| + implicit_success = Array(job.fetch("needs", [])).all? do |name| + needs.dig(name, "result") == "success" + end && !cancelled + condition = job["if"] + next implicit_success unless condition + + expression = condition.strip.sub(/\A\$\{\{\s*/, "").sub(/\s*\}\}\z/, "") + scanner = StringScanner.new(expression) + translated = [] + status_function = false + until scanner.eos? + next if scanner.skip(/\s+/) + token = scanner.scan( + /needs\.[\w-]+\.(?:result|outputs\.[\w-]+)|cancelled\(\)|'[^']*'|==|&&|\|\||[!()]/ + ) + raise "Unsupported job expression: #{scanner.rest}" unless token + + translated << case token + when "cancelled()" + status_function = true + cancelled.to_s + when /\Aneeds\./ + needs.dig(*token.split(".").drop(1)).to_s.downcase.dump + when /\A'/ + token[1...-1].downcase.dump + else + token + end + end + (status_function || implicit_success) && eval(translated.join(" ")) +end + +check.call(jobs.fetch("apks").fetch("needs").sort == %w[setup unit-tests].sort, + "APK preparation must wait for setup/tests and overlap Play") +check.call(jobs.fetch("play").fetch("needs").sort == %w[setup unit-tests].sort, + "Play must remain gated on setup/tests") +check.call(jobs.fetch("publish-release").fetch("needs").sort == %w[setup unit-tests play apks].sort, + "Publication must directly wait for setup/tests/Play/APKs") + +statuses = %w[success failure cancelled skipped] +flags = ["true", "false", "", "unexpected"] +condition_cases = 0 +statuses.repeated_permutation(4) do |setup, tests, play, apks| + flags.product([false, true]).each do |flag, cancelled| + needs = { + "setup" => {"result" => setup, "outputs" => {"play_publish" => flag}}, + "unit-tests" => {"result" => tests}, + "play" => {"result" => play}, + "apks" => {"result" => apks} } - in_publish_job { - print + expected = !cancelled && [setup, tests, apks].all? { |result| result == "success" } && + ((flag == "true" && play == "success") || (flag == "false" && play == "skipped")) + actual = job_runs.call(jobs.fetch("publish-release"), needs, cancelled) + check.call(actual == expected, + "Publication gate mismatch: #{[setup, tests, play, apks, flag, cancelled].inspect}") + condition_cases += 1 + end +end + +statuses.repeated_permutation(2) do |setup, tests| + flags.product([false, true]).each do |flag, cancelled| + needs = { + "setup" => {"result" => setup, "outputs" => {"play_publish" => flag}}, + "unit-tests" => {"result" => tests} } - ' "${workflow_file}" -} | sed '/^[[:space:]]*#/d')" -publish_job_header="$( - awk '/^ steps:[[:space:]]*$/ { exit } { print }' <<< "${publish_job}" -)" - -expect_publish_job_header_to_contain() { - local description="$1" - local expected="$2" - - if ! grep -Fq "${expected}" <<< "${publish_job_header}"; then - printf 'FAIL: publish-release must %s\n' "${description}" >&2 - failures=$((failures + 1)) - fi -} - -expect_publish_job_header_to_contain \ - "depend on setup and APK artifacts" \ - "needs: [setup, apks]" -expect_publish_job_header_to_contain \ - "define an explicit job condition" \ - "if: >-" -expect_publish_job_header_to_contain \ - "stop when the workflow is cancelled" \ - "!cancelled()" -expect_publish_job_header_to_contain \ - "require successful setup" \ - "needs.setup.result == 'success'" -expect_publish_job_header_to_contain \ - "require successful APK builds" \ - "needs.apks.result == 'success'" - -if ((failures > 0)); then - printf '%d release workflow self-test(s) failed\n' "${failures}" >&2 - exit 1 -fi + prerequisites_pass = !cancelled && setup == "success" && tests == "success" + check.call(job_runs.call(jobs.fetch("apks"), needs, cancelled) == prerequisites_pass, + "APK preparation gate mismatch: #{[setup, tests, flag, cancelled].inspect}") + check.call(job_runs.call(jobs.fetch("play"), needs, cancelled) == (prerequisites_pass && flag == "true"), + "Play gate mismatch: #{[setup, tests, flag, cancelled].inspect}") + condition_cases += 2 + end +end -printf 'All release workflow self-tests passed\n' +find_step = lambda do |job, name| + job.fetch("steps").find { |step| step["name"] == name } || raise("Missing step: #{name}") +end +cache_name = "Cache Robolectric Android runtimes" +release_cache = find_step.call(jobs.fetch("unit-tests"), cache_name) +ci_cache = find_step.call(load_workflow.call("trusted-linux-ci.yml").fetch("unit-tests"), cache_name) +check.call(release_cache.values_at("uses", "with") == ci_cache.values_at("uses", "with"), + "Release tests must share the normal CI Robolectric cache") +test_run = find_step.call(jobs.fetch("unit-tests"), "Run unit tests").fetch("run") +check.call(test_run.match?(/^\s+test\s*\\$/), "Release tests must keep the aggregate test task") + +matrix = jobs.fetch("apks").fetch("strategy").fetch("matrix").fetch("include") +check.call(matrix.map { |entry| entry.fetch("module") }.sort == %w[androidApp androidTvApp], + "APK preparation must include both phone and TV") +collect_step = find_step.call(jobs.fetch("apks"), "Collect release APKs") +publish_step = find_step.call(jobs.fetch("publish-release"), "Create or update release") +check.call(collect_step.fetch("env").fetch("SILO_VERSION_NAME") == "${{ needs.setup.outputs.version }}" && + publish_step.fetch("env").fetch("RELEASE_VERSION") == "${{ needs.setup.outputs.version }}", + "Artifact names and aliases must use the same full version, including tag suffixes") + +run_step = lambda do |step, root, env, entry = {}| + script = step.fetch("run").gsub(/\$\{\{\s*matrix\.([\w-]+)\s*\}\}/) do + entry.fetch(Regexp.last_match(1)) + end + raise "Unresolved workflow expression in shell fixture" if script.include?("${{") + Open3.capture3(env, "bash", "-c", script, chdir: root) +end + +prepare_apks = lambda do |root, version| + expected = {} + matrix.each do |entry| + apk_dir = File.join(root, File.dirname(entry.fetch("apk-path"))) + FileUtils.mkdir_p(apk_dir) + %w[arm64-v8a armeabi-v7a x86_64 universal].each do |abi| + bytes = "#{entry.fetch('module')} #{abi} APK fixture\0" + File.binwrite(File.join(apk_dir, "#{entry.fetch('module')}-#{abi}-release.apk"), bytes) + expected["#{entry.fetch('release-prefix')}-#{version}-#{abi}-release.apk"] = bytes + end + out, err, status = run_step.call(collect_step, root, {"SILO_VERSION_NAME" => version}, entry) + check.call(status.success?, "APK collection failed: #{out}#{err}") + end + files = Dir.glob(File.join(root, "release-artifacts/**/*.apk")) + check.call(files.map { |path| File.basename(path) }.sort == expected.keys.sort, + "Preparation must transfer exactly eight unique versioned APKs") + expected +end + +mock_gh = lambda do |root, version, existing| + bin_dir = File.join(root, "bin") + FileUtils.mkdir_p(bin_dir) + executable = File.join(bin_dir, "gh") + File.write(executable, <<~MOCK) + #!/usr/bin/env ruby + require "json" + File.open(ENV.fetch("GH_FIXTURE_CALLS"), "a") { |file| file.puts(ARGV.to_json) } + if ARGV.first(2) == ["release", "view"] + exit(ENV.fetch("GH_FIXTURE_EXISTING") == "true" ? 0 : 1) + end + MOCK + FileUtils.chmod(0755, executable) + { + "PATH" => "#{bin_dir}:#{ENV.fetch('PATH')}", + "GH_FIXTURE_CALLS" => File.join(root, "gh-calls.jsonl"), + "GH_FIXTURE_EXISTING" => existing.to_s, + "GH_REPO" => "fixture/silo-android", + "RELEASE_TAG" => "v#{version}", + "RELEASE_VERSION" => version, + "GITHUB_SHA" => "0" * 40 + } +end + +["1.2.3", "1.2.3-rc.1+2"].product([false, true]).each do |version, existing| + Dir.mktmpdir("release-workflow-") do |root| + expected = prepare_apks.call(root, version) + %w[silo-android silo-android-tv].each do |prefix| + bytes = expected.fetch("#{prefix}-#{version}-universal-release.apk") + expected["#{prefix}-latest-universal-release.apk"] = bytes + expected["#{prefix}-latest-universal-debug.apk"] = bytes + end + env = mock_gh.call(root, version, existing) + out, err, status = run_step.call(publish_step, root, env) + check.call(status.success?, "Publication fixture failed: #{out}#{err}") + next unless status.success? + calls = File.readlines(env.fetch("GH_FIXTURE_CALLS")).map { |line| JSON.parse(line) } + mutation = existing ? "edit" : "create" + check.call(calls.map { |call| call.first(2) } == [["release", "view"], ["release", mutation], ["release", "upload"]], + "Publication must retain the create/update/upload sequence") + upload = calls.last + check.call(upload.first(3) == ["release", "upload", "v#{version}"] && upload.last == "--clobber", + "Publication must retain its release tag and replacement behavior") + upload_paths = upload[3...-1] + check.call(upload_paths.map { |path| File.basename(path) }.sort == expected.keys.sort, + "Publication must upload the twelve expected asset filenames") + upload_paths.each do |path| + check.call(File.binread(File.join(root, path)) == expected[File.basename(path)], + "Publication changed APK bytes: #{path}") + end + end +end + +%w[silo-android silo-android-tv].product(%w[missing empty duplicate]).each do |prefix, defect| + Dir.mktmpdir("release-workflow-invalid-") do |root| + version = "1.2.3" + prepare_apks.call(root, version) + universal = Dir.glob(File.join(root, "release-artifacts/**/#{prefix}-#{version}-universal-release.apk")).fetch(0) + case defect + when "missing" + FileUtils.rm(universal) + when "empty" + File.binwrite(universal, "") + when "duplicate" + duplicate_dir = File.join(root, "release-artifacts/duplicate") + FileUtils.mkdir_p(duplicate_dir) + FileUtils.cp(universal, duplicate_dir) + end + env = mock_gh.call(root, version, false) + out, _err, status = run_step.call(publish_step, root, env) + check.call(!status.success? && out.include?("Expected one nonempty universal APK for #{prefix}."), + "Publication must reject a #{defect} universal APK for #{prefix}") + check.call(!File.exist?(env.fetch("GH_FIXTURE_CALLS")), + "Invalid APKs must fail before any release API call") + end +end + +unless failures.empty? + warn failures.first(20).map { |message| "FAIL: #{message}" }.join("\n") + warn "#{failures.length} release workflow self-test(s) failed" + exit 1 +end +puts "All release workflow self-tests passed (#{condition_cases} gate cases; APK filenames and bytes verified)" +RUBY From 2ed1882131777d3d19c503ab7af87887eee5457b Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:00 -0400 Subject: [PATCH 56/87] ci: parallelize Android unit projects within two workers --- .github/workflows/android-build.yml | 2 +- .github/workflows/trusted-linux-ci.yml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 6b6a30a2be..8929912952 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -22,7 +22,7 @@ on: default: true type: boolean parallel: - description: Benchmark parallel project execution at the existing worker limit + description: Benchmark lint parallelism at the existing worker limit required: false default: false type: boolean diff --git a/.github/workflows/trusted-linux-ci.yml b/.github/workflows/trusted-linux-ci.yml index c14beb6da0..e3e6eb2a37 100644 --- a/.github/workflows/trusted-linux-ci.yml +++ b/.github/workflows/trusted-linux-ci.yml @@ -142,6 +142,8 @@ jobs: release-readiness: name: Release readiness + env: + SILO_CI_PARALLEL_FLAG: --no-parallel needs: changes if: needs.changes.outputs.release_readiness == 'true' runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-release-readiness"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} @@ -191,6 +193,7 @@ jobs: run: | ./gradlew \ -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + -Pandroid.r8.maxWorkers=1 \ :androidApp:bundleRelease :androidTvApp:bundleRelease \ "${SILO_CI_CACHE_FLAG}" "${SILO_CI_PARALLEL_FLAG}" "${SILO_CI_CONFIGURATION_FLAG}" \ --max-workers=2 @@ -199,6 +202,8 @@ jobs: unit-tests: name: Unit tests + env: + SILO_CI_PARALLEL_FLAG: --parallel needs: changes if: needs.changes.outputs.unit_tests == 'true' runs-on: ${{ inputs.self_hosted && (github.repository == 'Silo-Server/silo-android' && ((github.event_name == 'pull_request' && contains(fromJSON(vars.SILO_CI_MEMBERS || '[]'), github.event.pull_request.user.login)) || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main'))) && fromJSON(format('["self-hosted","linux","x64","silo-linux","silo-run-{0}-{1}","silo-job-unit-tests"]', github.run_id, github.run_attempt)) || 'ubuntu-latest' }} From dbaec2ba3cb6caac856d1a04b808dd32f64738bb Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:32:02 -0400 Subject: [PATCH 57/87] ci: prepare Play bundles alongside release validation --- .github/workflows/release.yml | 105 ++++- fastlane/Fastfile | 161 ++++--- fastlane/play_bundle_manifest.rb | 141 ++++++ scripts/test-play-bundle-lanes.rb | 752 ++++++++++++++++++++++++++++++ scripts/test-release-workflow.sh | 30 +- 5 files changed, 1102 insertions(+), 87 deletions(-) create mode 100644 fastlane/play_bundle_manifest.rb create mode 100644 scripts/test-play-bundle-lanes.rb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3512b4ce8..97b050a5b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,14 +236,18 @@ jobs: **/build/reports/tests/** if-no-files-found: ignore - # Publish both App Bundles to Google Play via the fastlane android beta - # lane. No status function in the if -> implicit success() gate on needs, - # so failed setup/tests skip this job. - play: - name: Publish to Google Play - needs: [setup, unit-tests] + # Prepare final signed bundles while tests run. Publication consumes these + # exact bytes only after both preparation and tests succeed. + play-bundles: + name: Prepare Google Play bundles + needs: [setup] if: ${{ needs.setup.outputs.play_publish == 'true' }} runs-on: ubuntu-latest + outputs: + artifact_id: ${{ steps.bundles.outputs.artifact-id }} + artifact_name: ${{ steps.identity.outputs.artifact_name }} + producer_attempt: ${{ steps.identity.outputs.producer_attempt }} + manifest_sha256: ${{ steps.identity.outputs.manifest_sha256 }} steps: - name: Check out sources @@ -264,22 +268,93 @@ jobs: with: bundler-cache: true - # The Fastfile re-validates the version and derives the versionCode - # base from it before building. - - name: Build & upload to Google Play - run: bundle exec fastlane android beta + - name: Prepare signed Google Play bundles + run: bundle exec fastlane android prepare_bundles env: SILO_VERSION_NAME: ${{ needs.setup.outputs.version_name }} SILO_BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} PLAY_TRACK: ${{ inputs.track || 'beta' }} - PLAY_RELEASE_NOTES: ${{ vars.PLAY_RELEASE_NOTES }} - PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} SILO_RELEASE_KEYSTORE_B64: ${{ secrets.SILO_RELEASE_KEYSTORE_B64 }} SILO_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.SILO_RELEASE_KEYSTORE_PASSWORD }} SILO_RELEASE_KEY_PASSWORD: ${{ secrets.SILO_RELEASE_KEY_PASSWORD }} SILO_RELEASE_KEY_ALIAS: ${{ secrets.SILO_RELEASE_KEY_ALIAS }} SILO_GOOGLE_SERVICES_JSON_B64: ${{ secrets.SILO_GOOGLE_SERVICES_JSON_B64 }} + - name: Identify bundle artifact + id: identity + shell: bash + run: | + set -euo pipefail + manifest_sha256="$(sha256sum build/play-bundles-manifest.json | cut -d ' ' -f1)" + { + echo "artifact_name=android-play-bundles-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "producer_attempt=${GITHUB_RUN_ATTEMPT}" + echo "manifest_sha256=${manifest_sha256}" + } >> "${GITHUB_OUTPUT}" + + - name: Upload prepared Play bundles + id: bundles + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: | + androidApp/build/outputs/bundle/release/androidApp-release.aab + androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + build/play-bundles-manifest.json + if-no-files-found: error + compression-level: 0 + + play: + name: Publish to Google Play + needs: [setup, unit-tests, play-bundles] + if: ${{ needs.setup.outputs.play_publish == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + with: + bundler-cache: true + + - name: Verify bundle artifact provenance + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PLAY_BUNDLE_ARTIFACT_ID: ${{ needs.play-bundles.outputs.artifact_id }} + PLAY_BUNDLE_ARTIFACT_NAME: ${{ needs.play-bundles.outputs.artifact_name }} + SILO_PLAY_BUNDLE_ATTEMPT: ${{ needs.play-bundles.outputs.producer_attempt }} + SILO_PLAY_BUNDLE_MANIFEST_SHA256: ${{ needs.play-bundles.outputs.manifest_sha256 }} + run: | + set -euo pipefail + [[ "${GITHUB_REPOSITORY}" == 'Silo-Server/silo-android' ]] + [[ "${PLAY_BUNDLE_ARTIFACT_ID}" =~ ^[1-9][0-9]*$ ]] + [[ "${SILO_PLAY_BUNDLE_MANIFEST_SHA256}" =~ ^[0-9a-f]{64}$ ]] + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${PLAY_BUNDLE_ARTIFACT_ID}" | + ruby fastlane/play_bundle_manifest.rb verify-artifact + + - name: Download prepared Play bundles + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + artifact-ids: ${{ needs.play-bundles.outputs.artifact_id }} + path: . + + - name: Upload verified bundles to Google Play + run: bundle exec fastlane android upload_bundles + env: + SILO_VERSION_NAME: ${{ needs.setup.outputs.version_name }} + SILO_BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} + PLAY_TRACK: ${{ inputs.track || 'beta' }} + SILO_PLAY_BUNDLE_ATTEMPT: ${{ needs.play-bundles.outputs.producer_attempt }} + SILO_PLAY_BUNDLE_MANIFEST_SHA256: ${{ needs.play-bundles.outputs.manifest_sha256 }} + PLAY_RELEASE_NOTES: ${{ vars.PLAY_RELEASE_NOTES }} + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + # Prepare signed sideload APKs alongside Play after setup and tests succeed. # publish-release waits for both paths before exposing the APKs. apks: @@ -405,14 +480,14 @@ jobs: # re-trigger workflows, so this cannot recurse. publish-release: name: Publish GitHub Release - needs: [setup, unit-tests, play, apks] + needs: [setup, unit-tests, play-bundles, play, apks] if: >- ${{ !cancelled() && needs.setup.result == 'success' && needs.unit-tests.result == 'success' && needs.apks.result == 'success' && - ((needs.setup.outputs.play_publish == 'true' && needs.play.result == 'success') || - (needs.setup.outputs.play_publish == 'false' && needs.play.result == 'skipped')) }} + ((needs.setup.outputs.play_publish == 'true' && needs.play-bundles.result == 'success' && needs.play.result == 'success') || + (needs.setup.outputs.play_publish == 'false' && needs.play-bundles.result == 'skipped' && needs.play.result == 'skipped')) }} runs-on: ubuntu-latest permissions: contents: write diff --git a/fastlane/Fastfile b/fastlane/Fastfile index f78d9e6b4e..960f6c8c1f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,6 +1,7 @@ require "base64" require "fileutils" require "tmpdir" +require_relative "play_bundle_manifest" # Root-level Fastfile — Android lane for Google Play releases. Mirrors the # structure of silo-apple's Fastfile: @@ -194,74 +195,110 @@ end # ── Android ────────────────────────────────────────────────────────────────── +def build_play_bundles(version:, base_code:, build:, track:) + PlayBundleManifest.identity(File.expand_path("..", __dir__), ENV) + require_signing_env! + ENV["SILO_RELEASE_KEYSTORE"] = resolve_release_keystore + materialize_google_services_json + yield if block_given? + + # One invocation builds both bundles so shared modules compile once. Any + # task name containing "bundle" also disables the sideload APK ABI splits + # (see isBuildingBundle in the app build scripts) — the AABs do their own + # per-ABI delivery. R8 mapping files are embedded in the bundles + # (BUNDLE-METADATA), so Play picks up deobfuscation automatically. + gradle( + tasks: [":androidApp:bundleRelease", ":androidTvApp:bundleRelease"], + properties: { + "siloVersionName" => version, + "siloVersionCode" => base_code, + # The build counter as its own value: it is already folded into + # base_code, but the app reports it verbatim to the server. + "siloBuildNumber" => build, + # The track these bundles are actually uploaded to, so a beta-track + # tester and a production user are distinguishable in admin Activity + # instead of both reporting a generic "release". + "siloReleaseChannel" => track, + }, + system_properties: { + "org.gradle.jvmargs" => "-Xmx4g -Dfile.encoding=UTF-8", + }, + flags: "--max-workers=2", + ) + + [PHONE_AAB, TV_AAB].each do |aab| + UI.user_error!("Expected App Bundle not found at #{aab}") unless File.exist?(aab) + end + + PlayBundleManifest.write!(root: File.expand_path("..", __dir__), + version: version, base_code: base_code, build: build, track: track) +end + +def publish_play_bundles(version:, base_code:, build:, track:, json_key: nil, + manifest_sha256: ENV["SILO_PLAY_BUNDLE_MANIFEST_SHA256"], changelog_prepared: false) + PlayBundleManifest.verify!(root: File.expand_path("..", __dir__), + version: version, base_code: base_code, build: build, track: track, + producer_attempt: ENV["SILO_PLAY_BUNDLE_ATTEMPT"], manifest_sha256: manifest_sha256) + json_key ||= play_json_key_data + write_play_changelog unless changelog_prepared + + # Both AABs go up in a single Play edit/release — phone and TV must ship + # together (see the header comment). Listing metadata and imagery stay + # managed in the Play Console; only the changelog is uploaded from here. + # If Google rejects the edit with "changes cannot be sent for review + # automatically", a manual review step is pending in the Play Console — + # pass changes_not_sent_for_review: true temporarily and submit there. + upload_to_play_store( + package_name: PACKAGE_NAME, + json_key_data: json_key, + track: track, + release_status: "completed", + version_name: version, + aab_paths: [PHONE_AAB, TV_AAB], + # Belt-and-braces: never let a stale APK from the gradle lane context + # conflict with the explicit aab_paths above. + skip_upload_apk: true, + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true, + skip_upload_changelogs: false, + changes_not_sent_for_review: false, + timeout: 900, + ) + + UI.success("Uploaded Silo #{version} build #{build} (phone #{base_code * 2} + TV #{base_code * 2 + 1}) to the #{track} track.") +end + platform :android do + desc "Prepare signed phone and TV App Bundles without publishing" + lane :prepare_bundles do + version, base_code, build = resolve_release_version + track = play_track + build_play_bundles(version: version, base_code: base_code, build: build, track: track) + end + + desc "Upload the verified prepared phone and TV App Bundles" + lane :upload_bundles do + version, base_code, build = resolve_release_version + track = play_track + publish_play_bundles(version: version, base_code: base_code, build: build, track: track) + end + desc "Build both App Bundles (phone + TV) and upload to a Google Play track" lane :beta do version, base_code, build = resolve_release_version track = play_track + # Preserve the local lane's early publisher-key validation. json_key = play_json_key_data - require_signing_env! - ENV["SILO_RELEASE_KEYSTORE"] = resolve_release_keystore - materialize_google_services_json - write_play_changelog - - UI.message( - "Releasing Silo #{version} build #{build} to the #{track} track " \ - "(base code #{base_code} → phone #{base_code * 2}, TV #{base_code * 2 + 1})" - ) - - # One invocation builds both bundles so shared modules compile once. Any - # task name containing "bundle" also disables the sideload APK ABI splits - # (see isBuildingBundle in the app build scripts) — the AABs do their own - # per-ABI delivery. R8 mapping files are embedded in the bundles - # (BUNDLE-METADATA), so Play picks up deobfuscation automatically. - gradle( - tasks: [":androidApp:bundleRelease", ":androidTvApp:bundleRelease"], - properties: { - "siloVersionName" => version, - "siloVersionCode" => base_code, - # The build counter as its own value: it is already folded into - # base_code, but the app reports it verbatim to the server. - "siloBuildNumber" => build, - # The track these bundles are actually uploaded to, so a beta-track - # tester and a production user are distinguishable in admin Activity - # instead of both reporting a generic "release". - "siloReleaseChannel" => track, - }, - system_properties: { - "org.gradle.jvmargs" => "-Xmx4g -Dfile.encoding=UTF-8", - }, - flags: "--max-workers=2", - ) - - [PHONE_AAB, TV_AAB].each do |aab| - UI.user_error!("Expected App Bundle not found at #{aab}") unless File.exist?(aab) + build_play_bundles(version: version, base_code: base_code, build: build, track: track) do + write_play_changelog + UI.message( + "Releasing Silo #{version} build #{build} to the #{track} track " \ + "(base code #{base_code} → phone #{base_code * 2}, TV #{base_code * 2 + 1})" + ) end - - # Both AABs go up in a single Play edit/release — phone and TV must ship - # together (see the header comment). Listing metadata and imagery stay - # managed in the Play Console; only the changelog is uploaded from here. - # If Google rejects the edit with "changes cannot be sent for review - # automatically", a manual review step is pending in the Play Console — - # pass changes_not_sent_for_review: true temporarily and submit there. - upload_to_play_store( - package_name: PACKAGE_NAME, - json_key_data: json_key, - track: track, - release_status: "completed", - version_name: version, - aab_paths: [PHONE_AAB, TV_AAB], - # Belt-and-braces: never let a stale APK from the gradle lane context - # conflict with the explicit aab_paths above. - skip_upload_apk: true, - skip_upload_metadata: true, - skip_upload_images: true, - skip_upload_screenshots: true, - skip_upload_changelogs: false, - changes_not_sent_for_review: false, - timeout: 900, - ) - - UI.success("Uploaded Silo #{version} build #{build} (phone #{base_code * 2} + TV #{base_code * 2 + 1}) to the #{track} track.") + manifest = File.join(File.expand_path("..", __dir__), PlayBundleManifest::FILE_NAME) + publish_play_bundles(version: version, base_code: base_code, build: build, track: track, json_key: json_key, + manifest_sha256: Digest::SHA256.file(manifest).hexdigest, changelog_prepared: true) end end diff --git a/fastlane/play_bundle_manifest.rb b/fastlane/play_bundle_manifest.rb new file mode 100644 index 0000000000..440c9671db --- /dev/null +++ b/fastlane/play_bundle_manifest.rb @@ -0,0 +1,141 @@ +require "digest" +require "fileutils" +require "json" +require "open3" + +# The release producer and consumer exchange these two final files in one run. +# APK preparation remains separate because bundle task names disable APK splits. +module PlayBundleManifest + FILE_NAME = "build/play-bundles-manifest.json" + PATHS = %w[ + androidApp/build/outputs/bundle/release/androidApp-release.aab + androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + ].freeze + MAX_FILE_BYTES = 1_073_741_824 + MAX_MANIFEST_BYTES = 65_536 + REPOSITORY = "Silo-Server/silo-android" + + def self.require!(condition, message) + raise ArgumentError, message unless condition + end + + def self.positive_integer(value, name) + require!(value.to_s.match?(/\A[1-9]\d*\z/), "Invalid #{name}") + value.to_i + end + + def self.identity(root, context) + sha, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: root) + sha = sha.strip + require!(status.success? && sha.match?(/\A[0-9a-f]{40}\z/), "Source revision unavailable") + names = %w[GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_ATTEMPT GITHUB_SHA] + return {"source_sha" => sha, "repository" => nil, "run_id" => nil, "producer_attempt" => nil} if + names.all? { |name| context[name].to_s.empty? } + + require!(context["GITHUB_REPOSITORY"] == REPOSITORY && context["GITHUB_SHA"] == sha, + "Bundle source does not match this release run") + tracked, tracked_status = Open3.capture2("git", "status", "--porcelain=v1", "--untracked-files=no", + "--ignore-submodules=none", chdir: root) + require!(tracked_status.success? && tracked.empty?, "CI bundle source has tracked changes") + {"source_sha" => sha, "repository" => REPOSITORY, + "run_id" => positive_integer(context["GITHUB_RUN_ID"], "run ID"), + "producer_attempt" => positive_integer(context["GITHUB_RUN_ATTEMPT"], "run attempt")} + end + + def self.bundle_record(root, relative) + root = File.realpath(root) + path = File.join(root, relative) + components = relative.split("/") + require!(components.each_index.none? { |index| File.symlink?(File.join(root, *components[0..index])) }, + "Bundle path must not contain a symlink") + require!(File.file?(path) && File.size(path).between?(1, MAX_FILE_BYTES), + "Expected nonempty App Bundle not found") + {"path" => relative, "bytes" => File.size(path), "sha256" => Digest::SHA256.file(path).hexdigest} + end + + def self.write!(root:, version:, base_code:, build:, track:, context: ENV) + manifest = {"schema" => 1, **identity(root, context), "version" => version, + "base_code" => base_code, "build" => build, "track" => track, + "bundles" => PATHS.map { |path| bundle_record(root, path) }} + path = File.join(root, FILE_NAME) + require!(!File.symlink?(File.dirname(path)) && !File.symlink?(path), "Manifest must not be a symlink") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate(manifest) + "\n") + manifest + end + + def self.verify!(root:, version:, base_code:, build:, track:, context: ENV, producer_attempt: nil, manifest_sha256: nil) + path = File.join(root, FILE_NAME) + require!(!File.symlink?(File.dirname(path)) && !File.symlink?(path) && + File.file?(path) && File.size(path).between?(1, MAX_MANIFEST_BYTES), + "Play bundle manifest is missing or invalid") + source = identity(root, context) + if source["run_id"] || manifest_sha256 + require!(manifest_sha256.is_a?(String) && manifest_sha256.match?(/\A[0-9a-f]{64}\z/) && + Digest::SHA256.file(path).hexdigest == manifest_sha256, + "Play bundle manifest does not match its producer") + end + manifest = JSON.parse(File.read(path)) + require!(manifest.is_a?(Hash) && + %w[schema base_code build].all? { |key| manifest[key].is_a?(Integer) } && + %w[run_id producer_attempt].all? { |key| manifest[key].nil? || manifest[key].is_a?(Integer) } && + manifest["bundles"].is_a?(Array) && manifest["bundles"].all? { |bundle| + bundle.is_a?(Hash) && bundle["bytes"].is_a?(Integer) + }, "Play bundle manifest has invalid field types") + expected = {"schema" => 1, **source, "version" => version, + "base_code" => base_code, "build" => build, "track" => track, + "bundles" => PATHS.map { |relative| bundle_record(root, relative) }} + if expected["run_id"] + attempt = positive_integer(producer_attempt || expected["producer_attempt"], "producer attempt") + require!(attempt <= expected["producer_attempt"], "Producer attempt is newer than this consumer") + expected["producer_attempt"] = attempt + else + require!(producer_attempt.nil?, "Local bundle has no CI producer") + end + require!(manifest == expected, "Prepared Play bundles do not match this release") + manifest + rescue JSON::ParserError + raise ArgumentError, "Play bundle manifest is malformed" + end + + def self.verify_artifact!(artifact:, artifact_id:, artifact_name:, producer_attempt:, context: ENV) + id = positive_integer(artifact_id, "artifact ID") + attempt = positive_integer(producer_attempt, "producer attempt") + run = positive_integer(context["GITHUB_RUN_ID"], "run ID") + current_attempt = positive_integer(context["GITHUB_RUN_ATTEMPT"], "run attempt") + repository_id = positive_integer(context["GITHUB_REPOSITORY_ID"], "repository ID") + require!(context["GITHUB_REPOSITORY"] == REPOSITORY && + context["GITHUB_SHA"].to_s.match?(/\A[0-9a-f]{40}\z/) && attempt <= current_attempt, + "Unexpected release artifact context") + expected_name = "android-play-bundles-#{run}-#{attempt}" + workflow = artifact.is_a?(Hash) ? artifact["workflow_run"] : nil + require!(artifact_name == expected_name && artifact.is_a?(Hash) && + artifact["id"].is_a?(Integer) && artifact["id"] == id && + artifact["name"] == expected_name && artifact["expired"] == false && + artifact["size_in_bytes"].is_a?(Integer) && artifact["size_in_bytes"].between?(1, 2 * MAX_FILE_BYTES) && + workflow.is_a?(Hash) && + %w[id repository_id head_repository_id].all? { |key| workflow[key].is_a?(Integer) } && + workflow["id"] == run && workflow["repository_id"] == repository_id && + workflow["head_repository_id"] == repository_id && + workflow["head_sha"] == context["GITHUB_SHA"], + "Artifact does not belong to this release producer") + true + end +end + +if $PROGRAM_NAME == __FILE__ + begin + raise ArgumentError, "Unsupported manifest operation" unless ARGV == ["verify-artifact"] + input = STDIN.read(PlayBundleManifest::MAX_MANIFEST_BYTES + 1) + raise ArgumentError, "Artifact metadata exceeds its limit" if input.bytesize > PlayBundleManifest::MAX_MANIFEST_BYTES + PlayBundleManifest.verify_artifact!( + artifact: JSON.parse(input), artifact_id: ENV.fetch("PLAY_BUNDLE_ARTIFACT_ID", ""), + artifact_name: ENV.fetch("PLAY_BUNDLE_ARTIFACT_NAME", ""), + producer_attempt: ENV.fetch("SILO_PLAY_BUNDLE_ATTEMPT", "") + ) + puts "Verified Play bundle artifact provenance" + rescue ArgumentError, JSON::ParserError + warn "Play bundle artifact verification failed" + exit 1 + end +end diff --git a/scripts/test-play-bundle-lanes.rb b/scripts/test-play-bundle-lanes.rb new file mode 100644 index 0000000000..dac3372222 --- /dev/null +++ b/scripts/test-play-bundle-lanes.rb @@ -0,0 +1,752 @@ +#!/usr/bin/env ruby + +require "base64" +require "fileutils" +require "json" +require "open3" +require "rbconfig" +require "tmpdir" + +# Execute the real Fastfile and manifest module with temporary source and bytes. +# The only action implementations below are inert; unexpected actions fail. +class FixtureFailure < StandardError; end +class LaneRejected < StandardError; end +class SyntheticBuildFailure < StandardError; end +class SyntheticKeyFailure < StandardError; end +class SyntheticChangelogFailure < StandardError; end + +module FixtureUI + class << self + attr_accessor :messages + + def user_error!(message) + raise LaneRejected, message + end + + %i[message important success].each do |name| + define_method(name) { |message| messages << [name, message] } + end + end +end + +class LaneFixture + UI = FixtureUI + PHONE = "androidApp/build/outputs/bundle/release/androidApp-release.aab" + TV = "androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab" + KEY_DATA = '{"fixture":"publisher"}' + + class << self + attr_reader :lanes + + def default_platform(name) + raise FixtureFailure, "Unexpected default platform" unless name == :android + end + + def platform(name) + raise FixtureFailure, "Unexpected platform" unless name == :android + yield + end + + def desc(_description); end + + def lane(name, &block) + @lanes ||= {} + raise FixtureFailure, "Duplicate lane" if @lanes.key?(name) + @lanes[name] = block + end + end + + attr_reader :root, :events, :gradle_calls, :uploads, :messages_before_gradle + attr_accessor :fail_build, :fail_key, :fail_changelog, :produce + + def initialize(root) + @root = root + @events, @gradle_calls, @uploads = [], [], [] + @produce = [PHONE, TV] + end + + def invoke(name) + instance_exec(&self.class.lanes.fetch(name)) + end + + def gradle(**options) + events << :gradle + gradle_calls << options + @messages_before_gradle = FixtureUI.messages.map(&:dup) + raise SyntheticBuildFailure, "Deliberate fixture build failure" if fail_build + produce.each do |relative| + path = File.join(root, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, relative == PHONE ? "phone AAB fixture\0" : "TV AAB fixture\0") + end + end + + def upload_to_play_store(**options) + events << :upload + uploads << options + end + + def sh(command, log:) + unless command == "git log -1 --pretty=format:%s" && log == false + raise FixtureFailure, "Unexpected shell action" + end + events << :changelog_fallback + "Fixture commit subject\n" + end + + def method_missing(name, *_arguments, **_keywords, &_block) + raise FixtureFailure, "Unexpected Fastlane action: #{name}" + end + + def respond_to_missing?(_name, _private = false) + false + end +end + +module FixtureHelpers + def play_json_key_data + events << :publisher_key + raise SyntheticKeyFailure, "Deliberate fixture key failure" if fail_key + LaneFixture::KEY_DATA + end + + def require_signing_env! + events << :signing_env + super + end + + def resolve_release_keystore + events << :keystore + # Never permit the real helper's local credential fallback in this harness. + path = ENV["SILO_RELEASE_KEYSTORE"].to_s + unless ENV["SILO_RELEASE_KEYSTORE_B64"].to_s.empty? && path == File.join(root, "fixture.jks") + raise FixtureFailure, "Unexpected keystore lookup" + end + super + end + + def materialize_google_services_json + events << :fcm + super + end + + def write_play_changelog + events << :changelog + raise SyntheticChangelogFailure, "Deliberate fixture changelog failure" if fail_changelog + super + end +end + +class BundleFixtureSuite + def initialize(root, runner_class, source_sha) + @root, @runner_class, @source_sha = root, runner_class, source_sha + @cases, @assertions, @failures = 0, 0, [] + @process_environment = ENV.to_h.select { |key, _| %w[PATH HOME TMPDIR LANG LC_ALL].include?(key) } + @process_environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => File::NULL) + end + + def check(condition, message) + @assertions += 1 + raise FixtureFailure, message unless condition + end + + def rejected(*types) + types = [ArgumentError, LaneRejected] if types.empty? + begin + yield + rescue *types + @assertions += 1 + return + end + raise FixtureFailure, "Expected rejection" + end + + def fixture(name) + @cases += 1 + previous = ENV.to_h + ENV.replace(@process_environment) + git("reset", "--hard", @source_sha) + %w[androidApp androidTvApp build manifest-target bundle-target fixture.jks].each do |relative| + FileUtils.rm_rf(File.join(@root, relative)) + end + FileUtils.rm_rf(File.join(@root, "fastlane/metadata")) + %w[androidApp androidTvApp].each { |relative| FileUtils.mkdir_p(File.join(@root, relative)) } + File.binwrite(File.join(@root, "fixture.jks"), "disposable keystore bytes") + ENV.update("SILO_VERSION_NAME" => "1.2.3", "SILO_BUILD_NUMBER" => "2", "PLAY_TRACK" => "beta", + "SILO_RELEASE_KEYSTORE" => File.join(@root, "fixture.jks"), + "SILO_RELEASE_KEYSTORE_PASSWORD" => "fixture-password", "SILO_RELEASE_KEY_ALIAS" => "fixture-key", + "PLAY_RELEASE_NOTES" => "Fixture release notes") + FixtureUI.messages = [] + runner = @runner_class.new(@root) + yield runner + rescue StandardError => error + @failures << "#{name}: #{error.class}: #{error.message}" + ensure + ENV.replace(previous) if previous + end + + def git(*arguments) + out, err, status = Open3.capture3(@process_environment, "git", *arguments, chdir: @root, unsetenv_others: true) + raise FixtureFailure, "Fixture git failed: #{err}" unless status.success? + out.strip + end + + def manifest_path + File.join(@root, PlayBundleManifest::FILE_NAME) + end + + def expected_gradle(version: "1.2.3", build: 2, track: "beta") + major, minor, patch = version.split(".").map(&:to_i) + { + tasks: [":androidApp:bundleRelease", ":androidTvApp:bundleRelease"], + properties: {"siloVersionName" => version, "siloVersionCode" => 100_000_000 + + (major * 10_000 + minor * 100 + patch) * 1_000 + build, + "siloBuildNumber" => build, "siloReleaseChannel" => track}, + system_properties: {"org.gradle.jvmargs" => "-Xmx4g -Dfile.encoding=UTF-8"}, + flags: "--max-workers=2" + } + end + + def expected_upload(version: "1.2.3", track: "beta") + {package_name: "org.siloserver.silo", json_key_data: LaneFixture::KEY_DATA, track: track, + release_status: "completed", version_name: version, + aab_paths: [LaneFixture::PHONE, LaneFixture::TV].map { |path| File.join(@root, path) }, + skip_upload_apk: true, skip_upload_metadata: true, skip_upload_images: true, + skip_upload_screenshots: true, skip_upload_changelogs: false, + changes_not_sent_for_review: false, timeout: 900} + end + + def prepare(runner) + runner.invoke(:prepare_bundles) + trust_manifest unless ENV["GITHUB_RUN_ID"].to_s.empty? + runner.events.clear + runner.gradle_calls.clear + end + + def trust_manifest + ENV["SILO_PLAY_BUNDLE_MANIFEST_SHA256"] = Digest::SHA256.file(manifest_path).hexdigest + end + + def upload_rejected(runner) + rejected { runner.invoke(:upload_bundles) } + check(runner.events.empty? && runner.uploads.empty?, "Invalid handoff reached credentials or actions") + end + + def change_manifest + value = JSON.parse(File.read(manifest_path)) + yield value + File.write(manifest_path, JSON.generate(value)) + end + + def ci_context(attempt = "1") + {"GITHUB_REPOSITORY" => "Silo-Server/silo-android", "GITHUB_REPOSITORY_ID" => "7001", + "GITHUB_RUN_ID" => "9001", "GITHUB_RUN_ATTEMPT" => attempt, "GITHUB_SHA" => @source_sha} + end + + def artifact + {"id" => 8001, "name" => "android-play-bundles-9001-1", "expired" => false, + "size_in_bytes" => 1000, "workflow_run" => {"id" => 9001, "repository_id" => 7001, + "head_repository_id" => 7001, "head_sha" => @source_sha}} + end + + def verify_artifact(value = artifact, **overrides) + PlayBundleManifest.verify_artifact!(artifact: value, artifact_id: "8001", + artifact_name: "android-play-bundles-9001-1", producer_attempt: "1", context: ci_context, **overrides) + end + + def run + fixture("preparation action and manifest contract") do |runner| + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle], "Preparation changed the Gradle contract") + check(runner.events == %i[signing_env keystore fcm gradle], "Preparation reached publishing work") + check(runner.uploads.empty? && !File.exist?(File.join(@root, "fastlane/metadata")), "Preparation wrote publishing data") + value = PlayBundleManifest.verify!(root: @root, version: "1.2.3", base_code: 110_203_002, build: 2, track: "beta") + check(value["source_sha"] == @source_sha && value.values_at("repository", "run_id", "producer_attempt") == [nil, nil, nil], + "Local identity was not bound to actual source") + check(value["bundles"].map { |entry| entry["path"] } == [LaneFixture::PHONE, LaneFixture::TV], "Wrong bundle paths") + end + + fixture("upload uses both verified bundles without build secrets") do |runner| + prepare(runner) + ENV.keys.select { |key| key.start_with?("SILO_RELEASE_", "SILO_GOOGLE_SERVICES_") }.each { |key| ENV.delete(key) } + runner.invoke(:upload_bundles) + check(runner.events == %i[publisher_key changelog upload], "Upload performed preparation work") + check(runner.gradle_calls.empty? && runner.uploads == [expected_upload], "Upload changed its two-bundle options") + check(File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "Fixture release notes", + "Upload changed changelog bytes") + end + fixture("normalized nondefault track survives preparation and upload") do |runner| + ENV["PLAY_TRACK"] = " ALPHA " + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle(track: "alpha")], "Preparation changed the selected channel") + runner.events.clear + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload(track: "alpha")], "Upload changed the prepared track") + end + + fixture("beta preserves early publisher lookup and one build/upload") do |runner| + runner.invoke(:beta) + check(runner.events == %i[publisher_key signing_env keystore fcm changelog gradle upload], "Beta changed baseline action order") + check(runner.gradle_calls == [expected_gradle] && runner.uploads == [expected_upload], "Beta duplicated or changed actions") + check(runner.messages_before_gradle.include?([:message, + "Releasing Silo 1.2.3 build 2 to the beta track (base code 110203002 → phone 220406004, TV 220406005)"]), + "Beta changed or delayed its baseline release message") + check(FixtureUI.messages.last == [:success, + "Uploaded Silo 1.2.3 build 2 (phone 220406004 + TV 220406005) to the beta track."], + "Beta changed its baseline success message") + end + fixture("beta uses its fresh manifest digest") do |runner| + ENV["SILO_PLAY_BUNDLE_MANIFEST_SHA256"] = "0" * 64 + runner.invoke(:beta) + check(runner.events == %i[publisher_key signing_env keystore fcm changelog gradle upload], "Beta changed baseline action order") + check(runner.gradle_calls == [expected_gradle] && runner.uploads == [expected_upload], "Beta used a stale caller digest") + end + fixture("beta rejects key before preparation") do |runner| + runner.fail_key = true + rejected(SyntheticKeyFailure) { runner.invoke(:beta) } + check(runner.events == [:publisher_key] && runner.uploads.empty?, "Failed key reached build or upload") + end + fixture("beta build failure prevents publication") do |runner| + runner.fail_build = true + rejected(SyntheticBuildFailure) { runner.invoke(:beta) } + check(runner.events == %i[publisher_key signing_env keystore fcm changelog gradle] && runner.uploads.empty? && + !File.exist?(manifest_path), "Failed build produced a handoff or upload") + check(File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "Fixture release notes", + "Failed beta build lost its baseline prepared changelog") + check(runner.messages_before_gradle.include?([:message, + "Releasing Silo 1.2.3 build 2 to the beta track (base code 110203002 → phone 220406004, TV 220406005)"]), + "Failed beta build lost its baseline release message") + end + fixture("beta changelog failure precedes build") do |runner| + runner.fail_changelog = true + rejected(SyntheticChangelogFailure) { runner.invoke(:beta) } + check(runner.events == %i[publisher_key signing_env keystore fcm changelog] && + runner.gradle_calls.empty? && runner.uploads.empty? && !File.exist?(manifest_path), + "Failed beta changelog reached build or upload") + end + [LaneFixture::PHONE, LaneFixture::TV].each do |missing| + fixture("successful action with missing #{missing}") do |runner| + runner.produce -= [missing] + rejected { runner.invoke(:prepare_bundles) } + check(runner.uploads.empty? && !File.exist?(manifest_path), "Missing bundle created an accepted handoff") + end + end + + [[nil, "beta"], ["", "beta"], ["internal", "internal"], ["alpha", "alpha"], + ["beta", "beta"], ["production", "production"], [" BETA ", "beta"]].each do |input, expected| + fixture("track #{input.inspect}") do |runner| + input.nil? ? ENV.delete("PLAY_TRACK") : ENV["PLAY_TRACK"] = input + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle(track: expected)], "Track normalization changed") + end + end + ["sideload", "dev", "unknown", "beta; command"].each do |track| + fixture("invalid track #{track}") do |runner| + ENV["PLAY_TRACK"] = track + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty?, "Invalid track reached actions") + end + end + [["0.0.0", "1", 1], ["94.99.99", "999", 999], ["1.2.3", nil, 1], ["1.2.3", "", 1]].each do |version, input, build| + fixture("valid version/build #{version}/#{input.inspect}") do |runner| + ENV["SILO_VERSION_NAME"] = version + input.nil? ? ENV.delete("SILO_BUILD_NUMBER") : ENV["SILO_BUILD_NUMBER"] = input + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle(version: version, build: build)], "Version/build derivation changed") + end + end + ["", "v1.2.3", "1.2.3-rc.1", "95.0.0", "1.100.0", "1.0.100", "1.2.3; command"].each do |version| + fixture("invalid version #{version.inspect}") do |runner| + ENV["SILO_VERSION_NAME"] = version + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty?, "Invalid version reached actions") + end + end + %w[0 1000 -1 1.2 invalid].each do |build| + fixture("invalid build #{build}") do |runner| + ENV["SILO_BUILD_NUMBER"] = build + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty?, "Invalid build reached actions") + end + end + %w[SILO_RELEASE_KEYSTORE_PASSWORD SILO_RELEASE_KEY_ALIAS].product([nil, " "]).each do |name, value| + fixture("missing signing #{name}/#{value.inspect}") do |runner| + value.nil? ? ENV.delete(name) : ENV[name] = value + rejected { runner.invoke(:prepare_bundles) } + check(runner.events == [:signing_env] && runner.gradle_calls.empty?, "Missing signing input reached Gradle") + end + end + fixture("key password remains optional") do |runner| + ENV.delete("SILO_RELEASE_KEY_PASSWORD") + runner.invoke(:prepare_bundles) + check(runner.gradle_calls == [expected_gradle], "Optional key password blocked preparation") + end + fixture("FCM absent warns and remains absent") do |runner| + runner.invoke(:prepare_bundles) + check(!File.exist?(File.join(@root, "androidApp/google-services.json")) && + FixtureUI.messages.any? { |type, text| type == :important && text.include?("without FCM push support") }, + "Absent optional FCM behavior changed") + end + fixture("FCM synthetic input materializes in phone only") do |runner| + bytes = "{\"fixture\":\"FCM\"}\n" + ENV["SILO_GOOGLE_SERVICES_JSON_B64"] = Base64.strict_encode64(bytes) + runner.invoke(:prepare_bundles) + check(File.binread(File.join(@root, "androidApp/google-services.json")) == bytes && + !File.exist?(File.join(@root, "androidTvApp/google-services.json")), "FCM materialization changed") + end + fixture("existing FCM configuration is preserved") do |runner| + FileUtils.mkdir_p(File.join(@root, "androidApp")) + File.binwrite(File.join(@root, "androidApp/google-services.json"), "existing fixture FCM") + ENV["SILO_GOOGLE_SERVICES_JSON_B64"] = Base64.strict_encode64("replacement fixture FCM") + runner.invoke(:prepare_bundles) + check(File.binread(File.join(@root, "androidApp/google-services.json")) == "existing fixture FCM", "Existing FCM was replaced") + end + fixture("changelog truncation") do |runner| + prepare(runner) + ENV["PLAY_RELEASE_NOTES"] = "n" * 501 + runner.invoke(:upload_bundles) + check(File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "n" * 500, + "Changelog length changed") + end + fixture("changelog fallback is inert") do |runner| + prepare(runner) + ENV.delete("PLAY_RELEASE_NOTES") + runner.invoke(:upload_bundles) + check(runner.events == %i[publisher_key changelog changelog_fallback upload] && + File.read(File.join(@root, "fastlane/metadata/android/en-US/changelogs/default.txt")) == "Fixture commit subject", + "Changelog fallback changed") + end + + [LaneFixture::PHONE, LaneFixture::TV].product(%w[missing empty corrupt]).each do |relative, defect| + fixture("handoff #{defect} #{relative}") do |runner| + prepare(runner) + path = File.join(@root, relative) + defect == "missing" ? File.unlink(path) : File.binwrite(path, defect == "empty" ? "" : "corrupt fixture") + upload_rejected(runner) + end + end + fixture("handoff swapped phone and TV bytes") do |runner| + prepare(runner) + phone, tv = [LaneFixture::PHONE, LaneFixture::TV].map { |relative| File.join(@root, relative) } + first, second = File.binread(phone), File.binread(tv) + File.binwrite(phone, second) + File.binwrite(tv, first) + upload_rejected(runner) + end + {"schema" => 2, "source_sha" => "0" * 40, "version" => "1.2.4", "base_code" => 110_203_003, + "build" => 3, "track" => "alpha", "repository" => "other/repo", "run_id" => 1, + "producer_attempt" => 1, "unexpected" => true}.each do |key, value| + fixture("manifest mismatch #{key}") do |runner| + prepare(runner) + change_manifest { |manifest| manifest[key] = value } + upload_rejected(runner) + end + end + %w[missing malformed null array empty oversized symlink unexpected-path extra-bundle].each do |defect| + fixture("manifest #{defect}") do |runner| + prepare(runner) + case defect + when "missing" then File.unlink(manifest_path) + when "malformed" then File.write(manifest_path, "{") + when "null" then File.write(manifest_path, "null") + when "array" then File.write(manifest_path, "[]") + when "empty" then File.write(manifest_path, "") + when "oversized" then File.write(manifest_path, " " * 65_537) + when "symlink" + FileUtils.mv(manifest_path, File.join(@root, "manifest-target")) + File.symlink(File.join(@root, "manifest-target"), manifest_path) + when "unexpected-path" then change_manifest { |value| value["bundles"][0]["path"] = "other.aab" } + when "extra-bundle" then change_manifest { |value| value["bundles"] << value["bundles"].first.dup } + end + upload_rejected(runner) + end + end + fixture("bundle file symlink") do |runner| + prepare(runner) + path = File.join(@root, LaneFixture::PHONE) + FileUtils.mv(path, File.join(@root, "bundle-target")) + File.symlink(File.join(@root, "bundle-target"), path) + upload_rejected(runner) + end + fixture("bundle ancestor symlink") do |runner| + prepare(runner) + path = File.join(@root, "androidApp/build/outputs/bundle") + FileUtils.mv(path, File.join(@root, "bundle-target")) + File.symlink(File.join(@root, "bundle-target"), path) + upload_rejected(runner) + end + fixture("manifest parent symlink") do |runner| + prepare(runner) + FileUtils.mv(File.join(@root, "build"), File.join(@root, "manifest-target")) + File.symlink(File.join(@root, "manifest-target"), File.join(@root, "build")) + upload_rejected(runner) + end + fixture("checkout changed after preparation") do |runner| + prepare(runner) + File.write(File.join(@root, "fixture-source.txt"), "changed source") + git("add", "fixture-source.txt") + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", + "commit", "--quiet", "-m", "Changed fixture source") + upload_rejected(runner) + end + + fixture("same-attempt CI handoff") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload], "Valid CI handoff rejected") + end + [false, true].each do |staged| + fixture("CI preparation rejects #{staged ? 'staged' : 'unstaged'} tracked changes") do |runner| + ENV.update(ci_context) + File.write(File.join(@root, "fixture-source.txt"), "modified tracked build input") + git("add", "fixture-source.txt") if staged + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty? && runner.gradle_calls.empty? && !File.exist?(manifest_path) && runner.uploads.empty?, + "Known dirty CI source reached credentials, build or handoff") + end + fixture("CI upload rejects #{staged ? 'staged' : 'unstaged'} tracked changes") do |runner| + ENV.update(ci_context) + prepare(runner) + File.write(File.join(@root, "fixture-source.txt"), "modified tracked build input") + git("add", "fixture-source.txt") if staged + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + fixture("local beta accepts #{staged ? 'staged' : 'unstaged'} tracked changes") do |runner| + File.write(File.join(@root, "fixture-source.txt"), "modified local source") + git("add", "fixture-source.txt") if staged + runner.invoke(:beta) + check(runner.gradle_calls == [expected_gradle] && runner.uploads == [expected_upload], + "Local beta lost its dirty-checkout compatibility") + end + end + fixture("CI permits ignored build FCM and changelog files") do |runner| + ENV.update(ci_context) + prepare(runner) + paths = [LaneFixture::PHONE, LaneFixture::TV, PlayBundleManifest::FILE_NAME, + "androidApp/google-services.json", "fastlane/metadata/android/en-US/changelogs/default.txt"] + paths.last(2).each do |relative| + path = File.join(@root, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "owned ignored fixture input") + end + check(git("check-ignore", *paths).lines.map(&:chomp).sort == paths.sort, + "Positive CI fixture did not exercise ignored release outputs") + check(git("status", "--porcelain=v1", "--untracked-files=no").empty?, "Positive CI fixture changed tracked source") + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload], "Ignored release outputs blocked a valid CI handoff") + end + fixture("earlier producer accepted by later consumer") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["GITHUB_RUN_ATTEMPT"] = "3" + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + runner.invoke(:upload_bundles) + check(runner.uploads == [expected_upload] && verify_artifact(context: ci_context("3")), "Prior producer handoff rejected") + end + [nil, "", "invalid", "A" * 64, "0" * 64].each do |digest| + fixture("CI manifest digest rejected #{digest.inspect}") do |runner| + ENV.update(ci_context) + prepare(runner) + digest.nil? ? ENV.delete("SILO_PLAY_BUNDLE_MANIFEST_SHA256") : ENV["SILO_PLAY_BUNDLE_MANIFEST_SHA256"] = digest + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + fixture("CI rejects changed manifest whitespace") do |runner| + ENV.update(ci_context) + prepare(runner) + original = File.read(manifest_path) + File.write(manifest_path, original + " ") + check(JSON.parse(File.read(manifest_path)) == JSON.parse(original), "Whitespace fixture changed parsed metadata") + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + [LaneFixture::PHONE, LaneFixture::TV].each do |relative| + fixture("CI rejects rewritten bundle and matching manifest #{relative}") do |runner| + ENV.update(ci_context) + prepare(runner) + producer_digest = ENV.fetch("SILO_PLAY_BUNDLE_MANIFEST_SHA256") + File.binwrite(File.join(@root, relative), "rewritten fixture bundle") + change_manifest do |value| + record = value["bundles"].find { |entry| entry["path"] == relative } + record["bytes"] = File.size(File.join(@root, relative)) + record["sha256"] = Digest::SHA256.file(File.join(@root, relative)).hexdigest + end + check(Digest::SHA256.file(manifest_path).hexdigest != producer_digest, "Tamper fixture kept producer digest") + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + fixture("local absent digest keeps compatible manifest parsing") do |runner| + prepare(runner) + File.open(manifest_path, "a") { |file| file.write(" ") } + check(ENV["SILO_PLAY_BUNDLE_MANIFEST_SHA256"].nil?, "Local fixture unexpectedly had a producer digest") + runner.invoke(:upload_bundles) + check(runner.events == %i[publisher_key changelog upload] && runner.uploads == [expected_upload], + "Local upload requires a CI digest") + end + fixture("earlier producer requires an explicit retained attempt") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["GITHUB_RUN_ATTEMPT"] = "3" + ENV.delete("SILO_PLAY_BUNDLE_ATTEMPT") + upload_rejected(runner) + end + ["", "0", "2", "01", "invalid"].each do |attempt| + fixture("CI invalid producer attempt #{attempt.inspect}") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = attempt + upload_rejected(runner) + end + end + {"GITHUB_REPOSITORY" => "other/repo", "GITHUB_RUN_ID" => "9002", "GITHUB_SHA" => "0" * 40}.each do |key, value| + fixture("CI changed #{key}") do |runner| + ENV.update(ci_context) + prepare(runner) + ENV[key] = value + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + %w[GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_ATTEMPT GITHUB_SHA].each do |missing| + fixture("partial CI context missing #{missing}") do |runner| + ENV.update(ci_context) + ENV.delete(missing) + rejected { runner.invoke(:prepare_bundles) } + check(runner.events.empty? && runner.gradle_calls.empty? && !File.exist?(manifest_path) && runner.uploads.empty?, + "Partial CI context reached credentials, build or handoff") + end + end + + fixture("artifact identity positive") { check(verify_artifact, "Valid API artifact rejected") } + [nil, "", "0", "01", "invalid"].each do |id| + fixture("artifact bad selector #{id.inspect}") { rejected { verify_artifact(artifact_id: id) } } + end + {"id" => 8002, "name" => "wrong", "expired" => true, "size_in_bytes" => 0, + "workflow_run" => nil}.each do |key, value| + fixture("artifact mismatch #{key}") do + value_with_defect = artifact + value_with_defect[key] = value + rejected { verify_artifact(value_with_defect) } + end + end + {"id" => 9002, "repository_id" => 7002, "head_repository_id" => 7002, "head_sha" => "0" * 40}.each do |key, value| + fixture("artifact workflow mismatch #{key}") do + value_with_defect = artifact + value_with_defect["workflow_run"][key] = value + rejected { verify_artifact(value_with_defect) } + end + end + %w[schema run_id producer_attempt base_code build].each do |key| + fixture("manifest noninteger #{key}") do |runner| + ENV.update(ci_context) + prepare(runner) + change_manifest { |value| value[key] = value.fetch(key).to_f } + trust_manifest + ENV["SILO_PLAY_BUNDLE_ATTEMPT"] = "1" + upload_rejected(runner) + end + end + fixture("manifest noninteger bundle bytes") do |runner| + prepare(runner) + change_manifest { |value| value["bundles"][0]["bytes"] = value["bundles"][0]["bytes"].to_f } + upload_rejected(runner) + end + %w[id repository_id head_repository_id].each do |key| + [nil, true, :string, :float].each do |invalid| + fixture("artifact workflow noninteger #{key}/#{invalid.inspect}") do + value = artifact + original = value["workflow_run"].fetch(key) + value["workflow_run"][key] = case invalid + when :float then original.to_f + when :string then original.to_s + else invalid + end + rejected { verify_artifact(value) } + end + end + end + [nil, true, "8001", 8001.0].each do |invalid| + fixture("artifact ID noninteger #{invalid.inspect}") do + value = artifact + value["id"] = invalid + rejected { verify_artifact(value) } + end + end + fixture("artifact wrong output name") { rejected { verify_artifact(artifact_name: "android-play-bundles-9001-2") } } + fixture("artifact future producer") { rejected { verify_artifact(producer_attempt: "2") } } + fixture("earlier artifact requires exact producer name and ID") do + rejected { verify_artifact(producer_attempt: "2", context: ci_context("3")) } + rejected { verify_artifact(artifact_name: "android-play-bundles-9001-2", context: ci_context("3")) } + rejected { verify_artifact(artifact_id: "8002", context: ci_context("3")) } + end + [nil, "false"].each do |invalid| + fixture("artifact expired flag type #{invalid.inspect}") do + value = artifact + value["expired"] = invalid + rejected { verify_artifact(value) } + end + end + ["1000", 1000.0, -1, 2_147_483_649].each do |invalid| + fixture("artifact size rejected #{invalid.inspect}") do + value = artifact + value["size_in_bytes"] = invalid + rejected { verify_artifact(value) } + end + end + [nil, [], "metadata"].each do |value| + fixture("artifact malformed #{value.inspect}") { rejected { verify_artifact(value) } } + end + fixture("artifact CLI bounded metadata") do + env = ci_context.merge("PLAY_BUNDLE_ARTIFACT_ID" => "8001", "PLAY_BUNDLE_ARTIFACT_NAME" => "android-play-bundles-9001-1", + "SILO_PLAY_BUNDLE_ATTEMPT" => "1") + command = [RbConfig.ruby, File.join(@root, "fastlane/play_bundle_manifest.rb"), "verify-artifact"] + out, err, status = Open3.capture3(env, *command, stdin_data: JSON.generate(artifact)) + check(status.success? && out == "Verified Play bundle artifact provenance\n" && err.empty?, "Valid artifact CLI rejected") + ["{", "x" * 65_537].each do |input| + out, err, status = Open3.capture3(env, *command, stdin_data: input) + check(!status.success? && out.empty? && err == "Play bundle artifact verification failed\n", "Malformed CLI metadata accepted or exposed") + end + out, err, status = Open3.capture3(env.merge("PLAY_BUNDLE_ARTIFACT_ID" => nil), *command, + stdin_data: JSON.generate(artifact)) + check(!status.success? && out.empty? && err == "Play bundle artifact verification failed\n", "Missing CLI selector accepted") + end + + unless @failures.empty? + warn @failures.first(20).map { |message| "FAIL: #{message}" }.join("\n") + warn "#{@failures.length} Play bundle fixture(s) failed" + exit 1 + end + puts "All Play bundle lane/manifest fixtures passed (#{@cases} cases; #{@assertions} assertions; no native build or publishing)" + end +end + +repo_root = File.expand_path("..", __dir__) +Dir.mktmpdir("play-bundle-lanes-") do |root| + FileUtils.mkdir_p(File.join(root, "fastlane")) + %w[Fastfile play_bundle_manifest.rb].each do |name| + FileUtils.cp(File.join(repo_root, "fastlane", name), File.join(root, "fastlane", name)) + end + File.write(File.join(root, "fixture-source.txt"), "original fixture source") + File.write(File.join(root, ".gitignore"), "**/build/\nandroidApp/google-services.json\nfastlane/metadata/\n") + git_env = ENV.to_h.select { |key, _| %w[PATH HOME TMPDIR LANG LC_ALL].include?(key) } + git_env.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => File::NULL) + [["init", "--quiet"], ["config", "core.hooksPath", File.join(root, "empty-hooks")], ["add", "."], + ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", + "commit", "--quiet", "-m", "Fixture source"]].each do |arguments| + _out, err, status = Open3.capture3(git_env, "git", *arguments, chdir: root, unsetenv_others: true) + raise FixtureFailure, "Fixture initialization failed: #{err}" unless status.success? + end + source_sha, status = Open3.capture2(git_env, "git", "rev-parse", "HEAD", chdir: root, unsetenv_others: true) + raise FixtureFailure, "Fixture source unavailable" unless status.success? + runner_class = Class.new(LaneFixture) + fastfile = File.join(root, "fastlane/Fastfile") + runner_class.class_eval(File.read(fastfile), fastfile, 1) + runner_class.prepend(FixtureHelpers) + BundleFixtureSuite.new(root, runner_class, source_sha.strip).run +end diff --git a/scripts/test-release-workflow.sh b/scripts/test-release-workflow.sh index 22c59d0059..b3944d50c6 100755 --- a/scripts/test-release-workflow.sh +++ b/scripts/test-release-workflow.sh @@ -62,43 +62,50 @@ end check.call(jobs.fetch("apks").fetch("needs").sort == %w[setup unit-tests].sort, "APK preparation must wait for setup/tests and overlap Play") -check.call(jobs.fetch("play").fetch("needs").sort == %w[setup unit-tests].sort, - "Play must remain gated on setup/tests") -check.call(jobs.fetch("publish-release").fetch("needs").sort == %w[setup unit-tests play apks].sort, +check.call(jobs.fetch("play-bundles").fetch("needs") == ["setup"], + "Bundle preparation must overlap tests after setup") +check.call(jobs.fetch("play").fetch("needs").sort == %w[setup unit-tests play-bundles].sort, + "Play must remain gated on setup/tests/bundles") +check.call(jobs.fetch("publish-release").fetch("needs").sort == %w[setup unit-tests play-bundles play apks].sort, "Publication must directly wait for setup/tests/Play/APKs") statuses = %w[success failure cancelled skipped] flags = ["true", "false", "", "unexpected"] condition_cases = 0 -statuses.repeated_permutation(4) do |setup, tests, play, apks| +statuses.repeated_permutation(5) do |setup, tests, bundles, play, apks| flags.product([false, true]).each do |flag, cancelled| needs = { "setup" => {"result" => setup, "outputs" => {"play_publish" => flag}}, "unit-tests" => {"result" => tests}, + "play-bundles" => {"result" => bundles}, "play" => {"result" => play}, "apks" => {"result" => apks} } expected = !cancelled && [setup, tests, apks].all? { |result| result == "success" } && - ((flag == "true" && play == "success") || (flag == "false" && play == "skipped")) + ((flag == "true" && bundles == "success" && play == "success") || + (flag == "false" && bundles == "skipped" && play == "skipped")) actual = job_runs.call(jobs.fetch("publish-release"), needs, cancelled) check.call(actual == expected, - "Publication gate mismatch: #{[setup, tests, play, apks, flag, cancelled].inspect}") + "Publication gate mismatch: #{[setup, tests, bundles, play, apks, flag, cancelled].inspect}") condition_cases += 1 end end -statuses.repeated_permutation(2) do |setup, tests| +statuses.repeated_permutation(3) do |setup, tests, bundles| flags.product([false, true]).each do |flag, cancelled| needs = { "setup" => {"result" => setup, "outputs" => {"play_publish" => flag}}, - "unit-tests" => {"result" => tests} + "unit-tests" => {"result" => tests}, + "play-bundles" => {"result" => bundles} } prerequisites_pass = !cancelled && setup == "success" && tests == "success" check.call(job_runs.call(jobs.fetch("apks"), needs, cancelled) == prerequisites_pass, "APK preparation gate mismatch: #{[setup, tests, flag, cancelled].inspect}") - check.call(job_runs.call(jobs.fetch("play"), needs, cancelled) == (prerequisites_pass && flag == "true"), + check.call(job_runs.call(jobs.fetch("play-bundles"), needs, cancelled) == (!cancelled && setup == "success" && flag == "true"), + "Bundle preparation gate mismatch: #{[setup, tests, bundles, flag, cancelled].inspect}") + check.call(job_runs.call(jobs.fetch("play"), needs, cancelled) == (prerequisites_pass && bundles == "success" && flag == "true"), "Play gate mismatch: #{[setup, tests, flag, cancelled].inspect}") - condition_cases += 2 + condition_cases += 3 end end @@ -231,5 +238,8 @@ unless failures.empty? warn "#{failures.length} release workflow self-test(s) failed" exit 1 end +out, err, status = Open3.capture3("ruby", File.join(repo_root, "scripts/test-play-bundle-lanes.rb")) +raise "Play bundle lane fixtures failed: #{out}#{err}" unless status.success? +puts out puts "All release workflow self-tests passed (#{condition_cases} gate cases; APK filenames and bytes verified)" RUBY From f34f36001ce2fe5e4b4486ae55d0f98e96ca4752 Mon Sep 17 00:00:00 2001 From: RXWatcher Date: Thu, 8 Oct 2026 01:13:35 +0200 Subject: [PATCH 58/87] fix(tv): show mixed libraries in movie and series tabs (#441) * fix(tv): split mixed libraries between movie and series tabs * perf(tv): refill mixed shelves with bounded concurrency * fix(tv): add retry action for partially loaded shelves * perf(tv): skip refills for hidden featured shelves * fix(tv): refill scoped shelves despite bounded inline counts The inline total_count on library shelves is not an exhaustion signal. Recently added (and several other server shelves) report len(items) after the item limit, so 20 inline movies with total_count 20 hid the older series from the Series tab: no refill ran and Recommended showed an empty shelf while Browse had series. Refill whenever the scoped slice is short of the shelf target and let the catalog continuation (has_more / empty page) decide exhaustion. Co-Authored-By: Claude Opus 5.5 * fix(tv): shuffle personal collections in their own scope, cancel superseded shelf loads A mixed library opens personal collections through the library collection screen, which always started shuffles in the library-collection scope; pick the scope from the collection source instead. Retrying Recommended now cancels the running load, so its refill cursor chains stop rather than paging until the final publish check. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): offer no shuffle in Movies- or Series-scoped collection views A shuffle request carries only the collection kind and id, so from a scoped view of a mixed library's collection it would draw from both types. Hide shuffle there until the request can carry the scope. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): keep shuffle and refills within a mixed library's resolved scope Library shuffle is not offered in a Movies- or Series-scoped view, since a shuffle request carries no media type. Customized and profile-added shelves keep their profile-resolved inline items instead of refilling from the stored definition, which would drop the profile's overrides. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: rxwatcher Co-authored-by: Claude Opus 5.5 --- .../siloserver/silo/tv/di/AndroidTvModule.kt | 3 + .../silo/tv/ui/navigation/TvAppNavigation.kt | 14 +- .../silo/tv/ui/navigation/TvRoute.kt | 8 +- .../TvLibraryCollectionDetailScreen.kt | 31 +- .../TvLibraryCollectionDetailViewModel.kt | 7 +- .../screens/library/TvLibraryDetailScreen.kt | 44 ++- .../library/TvLibraryDetailViewModel.kt | 31 +- .../ui/screens/library/TvLibraryMediaScope.kt | 57 ++++ .../silo/tv/ui/shell/TvLibraryTabType.kt | 9 +- .../silo/tv/ui/shell/TvMainShell.kt | 20 +- .../library/TvMixedLibraryScopeTest.kt | 273 ++++++++++++++++++ .../silo/tv/ui/shell/TvLibraryTabTypeTest.kt | 11 + .../silo/model/catalog/CatalogModels.kt | 10 +- .../siloserver/silo/network/api/SectionApi.kt | 15 +- .../silo/repository/SectionRepository.kt | 10 + .../api/SectionApiCollectionItemsTest.kt | 17 ++ 16 files changed, 526 insertions(+), 34 deletions(-) create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 7be730521b..e217ddcd1a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -472,6 +472,7 @@ val androidTvModule = module { libraryId = params.get(), libraryTitle = params.get(), libraryType = params.get(), + mediaScope = params.values.getOrNull(3) as? String, ) } viewModel { params -> @@ -481,6 +482,8 @@ val androidTvModule = module { libraryId = params.get(), collectionId = params.get(), title = params.get(), + mediaScope = params.values.getOrNull(3) as? String, + collectionSource = params.values.getOrNull(4) as? String ?: "library_collection", ) } viewModel { TvSearchViewModel(get(), get(), get()) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 1a69b704dd..036832bf6c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -908,9 +908,9 @@ fun TvAppNavigation( onOpenWatchParty = { navController.navigateToWatchParty(null, watchPartyRepository, lastPlaybackNavigation) }, - onOpenLibraryCollectionDetail = { libraryId, collectionId, title, libraryType -> + onOpenLibraryCollectionDetail = { libraryId, collectionId, title, libraryType, source, scope -> navController.navigate( - TvRoute.LibraryCollectionDetail(libraryId, collectionId, title, libraryType).route, + TvRoute.LibraryCollectionDetail(libraryId, collectionId, title, libraryType, source, scope).route, ) }, onOpenCollectionDetail = { collectionId, title -> @@ -1512,6 +1512,14 @@ fun TvAppNavigation( type = NavType.StringType defaultValue = "" }, + navArgument(TvRoute.LibraryCollectionDetail.ARG_MEDIA_SCOPE) { + type = NavType.StringType + defaultValue = "" + }, + navArgument(TvRoute.LibraryCollectionDetail.ARG_SOURCE) { + type = NavType.StringType + defaultValue = "library_collection" + }, navArgument(TvRoute.LibraryCollectionDetail.ARG_LIBRARY_TYPE) { type = NavType.StringType defaultValue = "" @@ -1531,6 +1539,8 @@ fun TvAppNavigation( ?.getString(TvRoute.LibraryCollectionDetail.ARG_LIBRARY_TYPE) .orEmpty() TvLibraryCollectionDetailScreen( + mediaScope = backStack.arguments?.getString(TvRoute.LibraryCollectionDetail.ARG_MEDIA_SCOPE)?.takeIf { it in setOf("movie", "series") }, + collectionSource = backStack.arguments?.getString(TvRoute.LibraryCollectionDetail.ARG_SOURCE) ?: "library_collection", libraryId = libraryId, collectionId = collectionId, title = title, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt index 0e176f0aea..b7fa5836a1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt @@ -248,17 +248,21 @@ sealed class TvRoute(val route: String) { val title: String, /** Drives which sort keys and filter facets the page offers. */ val libraryType: String = "", + val collectionSource: String = "library_collection", + val mediaScope: String? = null, ) : TvRoute( "library/$libraryId/collection/${collectionId.routeEncode()}" + - "?title=${title.routeEncode()}&libraryType=${libraryType.routeEncode()}" + "?title=${title.routeEncode()}&libraryType=${libraryType.routeEncode()}&source=${collectionSource.routeEncode()}&mediaScope=${mediaScope.orEmpty().routeEncode()}" ) { companion object { const val ROUTE = - "library/{libraryId}/collection/{collectionId}?title={title}&libraryType={libraryType}" + "library/{libraryId}/collection/{collectionId}?title={title}&libraryType={libraryType}&source={collectionSource}&mediaScope={mediaScope}" const val ARG_LIBRARY_ID = "libraryId" const val ARG_COLLECTION_ID = "collectionId" const val ARG_TITLE = "title" const val ARG_LIBRARY_TYPE = "libraryType" + const val ARG_SOURCE = "collectionSource" + const val ARG_MEDIA_SCOPE = "mediaScope" } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt index eddc523bb3..aa25b25b61 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt @@ -35,6 +35,22 @@ import org.siloserver.silo.tv.ui.theme.tvPresetGridColumns import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +/** + * A mixed library opens personal collections here too, so the shuffle scope + * follows the collection's source: a personal collection id means nothing to + * the library-collection namespace. A Movies- or Series-scoped view offers no + * shuffle, since a shuffle request carries no media type or library and would + * draw from the whole collection. + */ +internal fun collectionShuffleKind( + collectionSource: String, + mediaScope: String?, +): org.siloserver.silo.model.shuffle.ShuffleScopeKind? = when { + mediaScope != null -> null + collectionSource == "user_collection" -> org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION + else -> org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION +} + /** Which overlay panel is open over the collection grid (mirrors Browse). */ private enum class TvCollectionPanel { Sort, Filter } @@ -44,13 +60,15 @@ fun TvLibraryCollectionDetailScreen( collectionId: String, title: String, libraryType: String, + collectionSource: String = "library_collection", + mediaScope: String? = null, onItemClick: (contentId: String) -> Unit, onBack: () -> Unit, // Plays the first pick of a shuffle started from the header. onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvLibraryCollectionDetailViewModel = koinViewModel( - key = "library-collection-$libraryId-$collectionId", - parameters = { parametersOf(libraryId, collectionId, title) }, + key = "library-collection-$libraryId-$collectionId-$libraryType-$collectionSource-${mediaScope.orEmpty()}", + parameters = { parametersOf(libraryId, collectionId, title, mediaScope, collectionSource) }, ), ) { val state by viewModel.uiState.collectAsState() @@ -58,15 +76,14 @@ fun TvLibraryCollectionDetailScreen( org.koin.compose.koinInject(), onShuffleStarted, ) + val shuffleKind = collectionShuffleKind(collectionSource, mediaScope) val onShuffle = if ( + shuffleKind != null && state.items.isNotEmpty() && - shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION) + shuffleLauncher.supports(shuffleKind) ) { { - shuffleLauncher.start( - org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION, - collectionId, - ) + shuffleLauncher.start(shuffleKind, collectionId) } } else { null diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt index 766cf53b38..0481b95efb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt @@ -23,6 +23,8 @@ class TvLibraryCollectionDetailViewModel( private val libraryId: Int, private val collectionId: String, val title: String, + private val mediaScope: String? = null, + private val collectionSource: String = "library_collection", ) : ViewModel() { data class UiState( @@ -99,7 +101,7 @@ class TvLibraryCollectionDetailViewModel( viewModelScope.launch { val result = catalogRepository.getFilters( includeTechnical = true, - source = "library_collection", + source = collectionSource, collectionId = collectionId, ) if (result is ApiResult.Success) { @@ -177,6 +179,9 @@ class TvLibraryCollectionDetailViewModel( collectionId, continuation = cursor, limit = PAGE_SIZE, + mediaType = mediaScope, + libraryId = libraryId.takeIf { mediaScope != null }, + source = collectionSource, sort = state.sort.ifBlank { null }, order = state.order, queryGroups = facetGroups, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt index f44a0a5681..ca99a4457f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -57,6 +57,7 @@ import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.tv.material3.Card +import androidx.tv.material3.Button import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon @@ -101,6 +102,7 @@ fun TvLibraryDetailScreen( libraryId: Int, libraryTitle: String, libraryType: String, + mediaScope: String? = null, onItemClick: (contentId: String) -> Unit, onCollectionClick: (collectionId: String, title: String, isUserCollection: Boolean) -> Unit, onInitialContentFocus: () -> Unit = {}, @@ -117,8 +119,8 @@ fun TvLibraryDetailScreen( // Plays the first pick of a shuffle started from the Library tab. onShuffleStarted: (org.siloserver.silo.model.shuffle.Shuffle) -> Unit = {}, viewModel: TvLibraryDetailViewModel = koinViewModel( - key = "library-$libraryId", - parameters = { parametersOf(libraryId, libraryTitle, libraryType) }, + key = "library-$libraryId-${mediaScope.orEmpty()}", + parameters = { parametersOf(libraryId, libraryTitle, libraryType, mediaScope) }, ), ) { val state by viewModel.uiState.collectAsState() @@ -126,8 +128,11 @@ fun TvLibraryDetailScreen( org.koin.compose.koinInject(), onShuffleStarted, ) - // Movie, TV, and mixed libraries shuffle when the server offers it. + // Movie, TV, and mixed libraries shuffle when the server offers it. A + // Movies- or Series-scoped view of a mixed library does not: a shuffle + // request carries no media type, so it would draw from both. val onShuffleLibrary = if ( + mediaScope == null && org.siloserver.silo.model.shuffle.isShuffleLibraryType(libraryType) && shuffleLauncher.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY) ) { @@ -153,7 +158,7 @@ fun TvLibraryDetailScreen( ) { when (state.selectedTab) { TvLibraryTab.Recommended -> RecommendedTab( - surfaceKey = "library-$libraryId", + surfaceKey = "library-$libraryId-${mediaScope.orEmpty()}", state = state, onItemClick = onItemClick, onRetry = viewModel::retryRecommended, @@ -268,6 +273,7 @@ fun TvLibraryDetailScreen( // Tab content // ============================================================================ +@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun RecommendedTab( /** Distinguishes this feed's saveable slots from other surfaces'. */ @@ -307,14 +313,28 @@ private fun RecommendedTab( ) } else -> { - TvSkylineSectionFeed( - surfaceKey = surfaceKey, - sections = rows, - onItemClick = onItemClick, - focusRequest = focusRequest, - onInitialContentFocus = onInitialContentFocus, - onContentUpFallbackChanged = onContentUpFallbackChanged, - ) + Box { + TvSkylineSectionFeed( + surfaceKey = surfaceKey, + sections = rows, + onItemClick = onItemClick, + focusRequest = focusRequest, + onInitialContentFocus = onInitialContentFocus, + onContentUpFallbackChanged = onContentUpFallbackChanged, + ) + state.recommendedError?.let { message -> + Row( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = Spacing.safeArea, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(message, modifier = Modifier.weight(1f)) + Button(onClick = onRetry) { Text("Retry") } + } + } + } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt index c6757c7666..bd6c2059fc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -16,13 +16,17 @@ import org.siloserver.silo.repository.SectionRepository import org.siloserver.silo.tv.ui.util.tvCatalogMediaTypeFor import org.siloserver.silo.tv.ui.util.visibleOnTv import kotlinx.coroutines.async +import kotlinx.coroutines.Job import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.ensureActive 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.isActive +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit /** * Library content sections committed by the Skyline cascade. The extra browse @@ -158,6 +162,7 @@ class TvLibraryDetailViewModel( private val libraryId: Int, private val libraryTitle: String, private val libraryType: String, + private val mediaScope: String? = null, ) : ViewModel() { data class UiState( @@ -399,11 +404,15 @@ class TvLibraryDetailViewModel( } private var recommendedGeneration = 0L + private var recommendedJob: Job? = null private fun loadRecommended() { val run = ++recommendedGeneration loadedRecommended = true - viewModelScope.launch { + // A retry supersedes the running load; cancel it so its refill + // cursor chains stop instead of paging until the final publish check. + recommendedJob?.cancel() + recommendedJob = viewModelScope.launch { _uiState.update { it.copy(recommendedLoading = true, recommendedError = null) } val owner = sectionRepository.captureLibrarySectionAuthority() @@ -479,12 +488,26 @@ class TvLibraryDetailViewModel( sections.map { section -> resolvedById[section.id] ?: section } } + // Independent shelves can overlap, but keep each cursor chain sequential. + val refillPermits = Semaphore(3) + val scoped = resolved.filterNot { it.featured }.map { section -> + async { + refillPermits.withPermit { + scopeTvLibrarySection(section, mediaScope) { cursor -> + // Each page request also rechecks the auth identity. + kotlinx.coroutines.currentCoroutineContext().ensureActive() + sectionRepository.getLibrarySectionCatalogItems(libraryId, section.id, owner, cursor) + } + } + } + }.awaitAll() if (!mayPublish()) return@launch _uiState.update { it.copy( - sections = resolved.visibleOnTv(), + sections = scoped.map { it.section }.visibleOnTv(), recommendedLoading = false, - recommendedError = null, + recommendedError = if (scoped.any { it.incomplete }) + "Some shelves could not be fully loaded for this media type. Retry or open Browse." else null, ) } } @@ -547,7 +570,7 @@ class TvLibraryDetailViewModel( val facetGroups = filter.facetSelection.toQueryGroups() val result = catalogRepository.browse( source = "query", - mediaType = mediaTypeFor(libraryType), + mediaType = mediaScope ?: mediaTypeFor(libraryType), libraryId = libraryId, genre = filter.genre, sort = filter.sort, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt new file mode 100644 index 0000000000..ec01336069 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt @@ -0,0 +1,57 @@ +package org.siloserver.silo.tv.ui.screens.library + +import kotlinx.serialization.encodeToString +import org.siloserver.silo.model.catalog.CatalogResponse +import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.apiv2.CatalogContinuationV2 + +internal fun matchesTvMediaScope(type: String, scope: String?): Boolean = when (scope) { + "movie" -> type.trim().lowercase() in setOf("movie", "movies", "film") + "series" -> type.trim().lowercase() in setOf("series", "show", "shows", "tv", "episode") + else -> true +} + +internal data class ScopedTvSection(val section: ResolvedSection, val incomplete: Boolean = false) + +/** Section sources reject type overlays. Page their original order and filter locally. */ +internal suspend fun scopeTvLibrarySection( + section: ResolvedSection, + mediaScope: String?, + loadPage: suspend (CatalogContinuationV2?) -> ApiResult, +): ScopedTvSection { + if (mediaScope == null) return ScopedTvSection(section) + val initial = section.items.filter { matchesTvMediaScope(it.type, mediaScope) } + val target = (section.itemLimit.takeIf { it > 0 } ?: section.items.size.coerceAtLeast(20)).coerceAtMost(100) + fun result(items: List, incomplete: Boolean = false) = + ScopedTvSection(section.copy(items = items.take(target), totalCount = items.size.coerceAtMost(target)), incomplete) + // Inline total_count is not an exhaustion signal: several server shelves (e.g. recently added) + // report the bounded inline count. Only the catalog continuation can say nothing else remains. + if (initial.size >= target) return result(initial) + // The catalog section source pages the stored admin definition, not the profile-resolved one, so a + // customized or profile-added shelf cannot be refilled without dropping the profile's overrides. + if (section.customized || section.isCustom) return result(initial, incomplete = true) + + val originals = section.items.associateBy { it.contentId } + val visible = linkedMapOf() + var continuation: CatalogContinuationV2? = null + repeat(8) { + when (val page = loadPage(continuation)) { + is ApiResult.Success -> { + page.data.items.filter { matchesTvMediaScope(it.type, mediaScope) }.forEach { item -> + visible.putIfAbsent(item.contentId, originals[item.contentId] + ?: SiloJson.decodeFromString(SiloJson.encodeToString(item))) + } + if (visible.size >= target || !page.data.hasMore) return result(visible.values.toList()) + val next = page.data.continuation + if (next == null || next === continuation || page.data.items.isEmpty()) + return result((visible.values + initial).distinctBy { it.contentId }, true) + continuation = next + } + else -> return result((visible.values + initial).distinctBy { it.contentId }, true) + } + } + return result((visible.values + initial).distinctBy { it.contentId }, true) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabType.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabType.kt index 3c8cc6066c..df71960fd9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabType.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabType.kt @@ -71,13 +71,18 @@ enum class TvLibraryTabType { fun matches(library: UserLibrary): Boolean { val type = library.type.trim().lowercase() return when (this) { - Movies -> type in MOVIE_TYPES - Series -> type in SERIES_TYPES + Movies -> type in MOVIE_TYPES || type == "mixed" + Series -> type in SERIES_TYPES || type == "mixed" Music -> type in MUSIC_TYPES Audiobooks -> isAudiobookLikeLibraryType(library.type) } } + /** A mixed source keeps its server identity while each video tab has its own view. */ + fun mediaScope(library: UserLibrary): String? = + if (!library.type.trim().equals("mixed", ignoreCase = true)) null + else when (this) { Movies -> "movie"; Series -> "series"; else -> null } + companion object { // Subsets of the shared videoLibraryTypes set, split by tvOS's // movies-vs-series tab distinction (which the shared set doesn't make). diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt index 5c63167077..b90459c0f2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt @@ -196,6 +196,8 @@ fun TvMainShell( collectionId: String, title: String, libraryType: String, + collectionSource: String, + mediaScope: String?, ) -> Unit, onOpenCollectionDetail: (collectionId: String, title: String) -> Unit, onSignedOut: () -> Unit, @@ -492,8 +494,13 @@ fun TvMainShell( { libraryId, collectionId, title, libraryType -> restoreContentAfterDetail = true detailReturnRoot = null - onOpenLibraryCollectionDetail(libraryId, collectionId, title, libraryType) + onOpenLibraryCollectionDetail(libraryId, collectionId, title, libraryType, "library_collection", null) } + val openScopedCollection: (Int, String, String, String, Boolean) -> Unit = { libraryId, id, title, scope, isUser -> + restoreContentAfterDetail = true + detailReturnRoot = null + onOpenLibraryCollectionDetail(libraryId, id, title, "mixed", if (isUser) "user_collection" else "library_collection", scope) + } val openCollectionDetail: (String, String) -> Unit = { collectionId, title -> restoreContentAfterDetail = true detailReturnRoot = null @@ -1262,6 +1269,7 @@ fun TvMainShell( shellComposable(TvMainRoute.Movies.route) { TvLibraryTypeContent( type = TvLibraryTabType.Movies, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Movies), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Movies.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Movies] ?: TvLibraryPill.Recommended, @@ -1279,6 +1287,7 @@ fun TvMainShell( shellComposable(TvMainRoute.Series.route) { TvLibraryTypeContent( type = TvLibraryTabType.Series, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Series), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Series.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Series] ?: TvLibraryPill.Recommended, @@ -1296,6 +1305,7 @@ fun TvMainShell( shellComposable(TvMainRoute.Music.route) { TvLibraryTypeContent( type = TvLibraryTabType.Music, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Music), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Music.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Music] ?: TvLibraryPill.Recommended, @@ -1313,6 +1323,7 @@ fun TvMainShell( shellComposable(TvMainRoute.Audiobooks.route) { TvLibraryTypeContent( type = TvLibraryTabType.Audiobooks, + onScopedCollectionClick = openScopedCollection, library = activeLibrary(TvLibraryTabType.Audiobooks), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Audiobooks.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Audiobooks] ?: TvLibraryPill.Recommended, @@ -1707,6 +1718,7 @@ fun TvMainShell( @Composable private fun TvLibraryTypeContent( type: TvLibraryTabType, + onScopedCollectionClick: (Int, String, String, String, Boolean) -> Unit, library: UserLibrary?, emptyConfirmed: Boolean, selectedPill: TvLibraryPill, @@ -1753,9 +1765,13 @@ private fun TvLibraryTypeContent( libraryId = library.id, libraryTitle = library.name, libraryType = library.type, + mediaScope = type.mediaScope(library), onItemClick = onItemClick, onCollectionClick = { collectionId, title, isUserCollection -> - if (isUserCollection) { + val scope = type.mediaScope(library) + if (scope != null) { + onScopedCollectionClick(library.id, collectionId, title, scope, isUserCollection) + } else if (isUserCollection) { onUserCollectionClick(collectionId, title) } else { onLibraryCollectionClick(library.id, collectionId, title, library.type) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt new file mode 100644 index 0000000000..2e30d4ee69 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt @@ -0,0 +1,273 @@ +package org.siloserver.silo.tv.ui.screens.library + +import androidx.lifecycle.ViewModelStore +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.siloserver.silo.network.* +import org.siloserver.silo.network.api.* +import org.siloserver.silo.network.apiv2.* +import org.siloserver.silo.repository.* +import kotlin.test.* + +@OptIn(ExperimentalCoroutinesApi::class) +class TvMixedLibraryScopeTest { + @Test fun refillsThreeShelvesAtATimeWithoutReorderingShelvesOrCursors() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val owner = AuthScopeSnapshot("server", "profile", "https://example.invalid", "pin", identityGeneration = 1) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + val release = CompletableDeferred() + var active = 0 + var peak = 0 + val cursors = mutableMapOf>() + val rows = (0..6).joinToString(",") { id -> + """{"id":"$id","title":"Shelf $id","featured":${id == 6},"section_type":"recently_added","item_limit":1,"total_count":2,"items":[{"content_id":"film","type":"movie","title":"Film"}]}""" + } + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val body = when { + request.url.encodedPath.endsWith("/sections") -> """{"sections":[$rows]}""" + request.url.encodedPath.endsWith("/filters") -> """{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""" + else -> { + val id = request.url.parameters["section_id"]!! + val cursor = request.url.parameters["cursor"] + cursors.getOrPut(id) { mutableListOf() } += cursor + active++; peak = maxOf(peak, active) + try { + release.await() + delay((6 - id.toInt()) * 10L) + if (cursor == null) page("film", "movie", "next") else page("show-$id", "series") + } finally { active-- } + } + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(SiloJson) } } + val store = ViewModelStore() + try { + val sections = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val vm = TvLibraryDetailViewModel(sections, CatalogRepository(CatalogApi(client)), 7, "Anime", "mixed", "series") + store.put("series", vm) + runCurrent() + assertEquals(3, active, "Three independent shelves should load without waiting on the first") + release.complete(Unit) + advanceUntilIdle() + assertEquals(3, peak, "Do not flood the server with every shelf at once") + assertEquals((0..5).map(Int::toString), vm.uiState.value.sections.map { it.id }) + assertEquals((0..5).map { "show-$it" }, vm.uiState.value.sections.flatMap { it.items }.map { it.contentId }) + assertEquals(6, cursors.size) + cursors.values.forEach { assertEquals(listOf(null, "next"), it) } + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun seriesShelfRefillsPastMoviesAndBrowseKeepsItsScope() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val owner = AuthScopeSnapshot("server", "profile", "https://example.invalid", "pin", identityGeneration = 1) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + val sectionCursors = mutableListOf() + val browseTypes = mutableListOf() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val body = when { + request.url.encodedPath.endsWith("/sections") -> """{"sections":[{"id":"recent","title":"Recent","section_type":"recently_added","item_limit":1,"total_count":2,"items":[{"content_id":"film","type":"movie","title":"Film"}]}]}""" + request.url.encodedPath.endsWith("/filters") -> """{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""" + request.url.parameters["source"] == "section" -> { + assertNull(request.url.parameters["type"], "Sections reject a type overlay") + assertNull(request.url.parameters["match"], "Sections reject even the default match overlay") + assertEquals("library", request.url.parameters["scope"]) + assertEquals("7", request.url.parameters["library_id"]) + assertEquals("recent", request.url.parameters["section_id"]) + val cursor = request.url.parameters["cursor"]; sectionCursors += cursor + if (cursor == null) page("film", "movie", "next") else page("show", "series") + } + else -> { + browseTypes += request.url.parameters["type"] + assertEquals("7", request.url.parameters["library_id"]) + page("show", "series") + } + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(SiloJson) } } + val store = ViewModelStore() + try { + val sections = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val vm = TvLibraryDetailViewModel(sections, CatalogRepository(CatalogApi(client)), 7, "Anime", "mixed", "series") + store.put("series", vm) + advanceUntilIdle() + assertEquals(listOf(null, "next"), sectionCursors) + assertEquals(listOf("show"), vm.uiState.value.sections.single().items.map { it.contentId }) + vm.onTabSelected(TvLibraryTab.Browse); advanceUntilIdle() + vm.onSortKeySelected(TvLibrarySortOption.Title); advanceUntilIdle() + assertEquals(listOf("series", "series"), browseTypes) + assertEquals(listOf("show"), vm.uiState.value.browseItems.map { it.contentId }) + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun scopedPersonalCollectionKeepsLibraryAndTypeWhenSorting() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler); Dispatchers.setMain(dispatcher) + var requests = 0 + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + if (request.url.encodedPath.endsWith("/filters")) { + respond("""{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""", headers = headersOf(HttpHeaders.ContentType, "application/json")) + } else { + requests++ + assertEquals("user_collection", request.url.parameters["source"]) + assertEquals("series", request.url.parameters["type"]) + assertEquals("7", request.url.parameters["library_id"]) + respond(page("show", "series"), headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + } + })) + val store = ViewModelStore() + try { + val vm = TvLibraryCollectionDetailViewModel(SectionRepository(SectionApi(client)), CatalogRepository(CatalogApi(client)), 7, "c1", "Collection", "series", "user_collection") + store.put("collection", vm); advanceUntilIdle() + assertEquals(listOf("show"), vm.uiState.value.items.map { it.contentId }) + vm.onSortSelected(TvLibrarySortOption.Title); advanceUntilIdle() + assertEquals(2, requests) + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun retryCancelsTheSupersededRefillChain() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val owner = AuthScopeSnapshot("server", "profile", "https://example.invalid", "pin", identityGeneration = 1) + val tokens = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = owner + } + val firstPageHeld = CompletableDeferred() + val cursors = mutableListOf() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val body = when { + request.url.encodedPath.endsWith("/sections") -> """{"sections":[{"id":"recent","title":"Recent","section_type":"recently_added","item_limit":1,"total_count":2,"items":[{"content_id":"film","type":"movie","title":"Film"}]}]}""" + request.url.encodedPath.endsWith("/filters") -> """{"genres":[],"studios":[],"networks":[],"countries":[],"original_languages":[],"content_ratings":[],"authors":[],"narrators":[],"series":[]}""" + else -> { + val cursor = request.url.parameters["cursor"]; cursors += cursor + // The first load's first page stays in flight until after the retry. + if (cursors.size == 1) firstPageHeld.await() + if (cursor == null) page("film", "movie", "next") else page("show", "series") + } + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(SiloJson) } } + val store = ViewModelStore() + try { + val sections = SectionRepository(SectionApi(client, sectionItems = LibrarySectionItemsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val vm = TvLibraryDetailViewModel(sections, CatalogRepository(CatalogApi(client)), 7, "Anime", "mixed", "series") + store.put("series", vm) + runCurrent() + assertEquals(listOf(null), cursors) + vm.retryRecommended() + firstPageHeld.complete(Unit) + advanceUntilIdle() + // Only the retry pages on; the superseded load never asks for "next". + assertEquals(listOf(null, null, "next"), cursors) + assertEquals(listOf("show"), vm.uiState.value.sections.single().items.map { it.contentId }) + } finally { store.clear(); client.close(); Dispatchers.resetMain() } + } + + @Test fun profileCustomizedShelvesNeverRefillFromTheStoredDefinition() = runTest { + val film = org.siloserver.silo.model.section.SectionItem("film", "movie", "Film") + for (row in listOf( + org.siloserver.silo.model.section.ResolvedSection("random", "random", "Random", itemLimit = 1, totalCount = 21, customized = true, items = listOf(film)), + org.siloserver.silo.model.section.ResolvedSection("mine", "random", "Mine", itemLimit = 1, totalCount = 21, isCustom = true, items = listOf(film)), + )) { + // The refill would page the admin's unfiltered definition, not the profile's override. + val result = scopeTvLibrarySection(row, "series") { error("Customized shelf must not refill") } + assertEquals(emptyList(), result.section.items) + assertTrue(result.incomplete) + } + } + + @Test fun personalCollectionsShuffleInTheUserCollectionScope() { + assertEquals(org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION, collectionShuffleKind("user_collection", null)) + assertEquals(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION, collectionShuffleKind("library_collection", null)) + } + + @Test fun scopedCollectionViewsOfferNoShuffle() { + // A shuffle request can't carry the Movies/Series scope, so it would draw from both. + assertNull(collectionShuffleKind("user_collection", "series")) + assertNull(collectionShuffleKind("library_collection", "movie")) + } + + @Test fun seriesShelvesKeepEpisodeProgressAndHideMovies() = runTest { + val episode = org.siloserver.silo.model.section.SectionItem("episode", "episode", "Episode", positionSeconds = 73.0) + val row = org.siloserver.silo.model.section.ResolvedSection("continue", "continue_watching", "Continue", totalCount = 2, + items = listOf(episode, org.siloserver.silo.model.section.SectionItem("film", "movie", "Film"))) + val result = scopeTvLibrarySection(row, "series") { + ApiResult.Success(org.siloserver.silo.model.catalog.CatalogResponse(items = listOf( + SiloJson.decodeFromString("""{"content_id":"episode","type":"episode","title":"Episode"}"""), + SiloJson.decodeFromString("""{"content_id":"film","type":"movie","title":"Film"}"""), + ))) + } + assertEquals(listOf(episode), result.section.items, "Inline items keep their richer section payload") + assertFalse(result.incomplete) + } + + @Test fun boundedInlineCountStillRefillsScopedShelf() = runTest { + // Server recently-added shelves report total_count = len(items) after the limit, so 20 inline + // movies with total_count 20 says nothing about older series further down the source. + val movies = (1..20).map { org.siloserver.silo.model.section.SectionItem("film-$it", "movie", "Film $it") } + val row = org.siloserver.silo.model.section.ResolvedSection("recent", "recently_added", "Recent", + itemLimit = 20, totalCount = 20, items = movies) + var refills = 0 + val result = scopeTvLibrarySection(row, "series") { + refills++ + // The source continues past the inline window: the same 20 movies, then an older series. + val json = movies.map { """{"content_id":"${it.contentId}","type":"movie","title":"${it.title}"}""" } + + """{"content_id":"show","type":"series","title":"Show"}""" + ApiResult.Success(org.siloserver.silo.model.catalog.CatalogResponse(items = json.map { SiloJson.decodeFromString(it) })) + } + assertEquals(1, refills, "A short scoped slice must ask the catalog instead of trusting total_count") + assertEquals(listOf("show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + @Test fun pagedEpisodeKeepsItsPlaybackContext() = runTest { + val row = org.siloserver.silo.model.section.ResolvedSection("continue", "continue_watching", "Continue", totalCount = 1) + val item = SiloJson.decodeFromString(""" + {"content_id":"episode","type":"episode","title":"Episode","series_id":"show","series_title":"Show", + "season_number":2,"episode_number":3,"position_seconds":73,"duration_seconds":1200,"item_source":"continue_watching"} + """) + val result = scopeTvLibrarySection(row, "series") { + ApiResult.Success(org.siloserver.silo.model.catalog.CatalogResponse(items = listOf(item))) + } + val episode = result.section.items.single() + assertEquals("show", episode.seriesId) + assertEquals(2, episode.seasonNumber) + assertEquals(3, episode.episodeNumber) + assertEquals(73.0, episode.positionSeconds) + assertEquals(1200.0, episode.durationSeconds) + assertEquals("continue_watching", episode.itemSource) + assertFalse(result.incomplete) + } + + @Test fun failedRefillRetainsMatchingInlineItemsAndReportsIncomplete() = runTest { + val episode = org.siloserver.silo.model.section.SectionItem("episode", "episode", "Episode") + val row = org.siloserver.silo.model.section.ResolvedSection("continue", "continue_watching", "Continue", totalCount = 100, items = listOf(episode)) + val result = scopeTvLibrarySection(row, "series") { ApiResult.Error(503, "unavailable", "Unavailable") } + assertEquals(listOf(episode), result.section.items) + assertTrue(result.incomplete) + } + + private fun page(id: String, type: String, next: String? = null) = + """{"items":[{"content_id":"$id","type":"$type","title":"$id"}],"page":{"has_more":${next != null},"next_cursor":${next?.let { "\"$it\"" } ?: "null"}},"total":2,"total_exact":true,"window_cursor":"window"}""" +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabTypeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabTypeTest.kt index e17c24ba80..7bb8699a55 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabTypeTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/shell/TvLibraryTabTypeTest.kt @@ -13,6 +13,17 @@ import kotlin.test.assertTrue */ class TvLibraryTabTypeTest { + @Test + fun `mixed libraries are reachable from both video tabs only`() { + for (value in listOf("mixed", " Mixed ")) { + val mixed = library(value) + assertTrue(TvLibraryTabType.Movies.matches(mixed)) + assertTrue(TvLibraryTabType.Series.matches(mixed)) + assertFalse(TvLibraryTabType.Music.matches(mixed)) + assertFalse(TvLibraryTabType.Audiobooks.matches(mixed)) + } + } + private fun library(type: String) = UserLibrary(id = 1, name = type, type = type) @Test diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt index 0e0f6c5957..b7910c858b 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt @@ -66,7 +66,15 @@ data class BrowseItem( @SerialName("release_date") val releaseDate: String? = null, @SerialName("last_air_date") val lastAirDate: String? = null, @SerialName("user_state") val userState: MediaItemUserState? = null, - @SerialName("overlay_summary") val overlaySummary: OverlaySummary? = null + @SerialName("overlay_summary") val overlaySummary: OverlaySummary? = null, + @SerialName("series_id") val seriesId: String? = null, + @SerialName("series_title") val seriesTitle: String? = null, + @SerialName("season_number") val seasonNumber: Int? = null, + @SerialName("episode_number") val episodeNumber: Int? = null, + @SerialName("item_source") val itemSource: String? = null, + @SerialName("position_seconds") val positionSeconds: Double? = null, + @SerialName("duration_seconds") val durationSeconds: Double? = null, + @SerialName("progress_updated_at") val progressUpdatedAt: String? = null, ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt index f728569d4b..17f15527a1 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt @@ -37,6 +37,16 @@ class SectionApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api suspend fun getLibrarySectionItems(libraryId: Int, sectionId: String, owner: AuthScopeSnapshot): ApiResult = sectionItems.read(libraryId, sectionId, owner) + suspend fun getLibrarySectionCatalogItems(libraryId: Int, sectionId: String, + owner: AuthScopeSnapshot, continuation: CatalogContinuationV2?): ApiResult { + if (!sectionItems.current(owner)) return identityChanged() + val result = v2.browse(CatalogQueryV2(source = "section", scope = "library", + libraryId = libraryId.toString(), sectionId = sectionId, limit = 100), continuation) + .map { it.toCatalogResponse() } + if (!sectionItems.current(owner)) return identityChanged() + return result + } + // --- Library Collections --- suspend fun getLibraryCollections(libraryId: Int): ApiResult = @@ -70,8 +80,11 @@ class SectionApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api order: String? = null, queryGroups: List = emptyList(), match: String? = null, + mediaType: String? = null, + libraryId: Int? = null, + source: String = "library_collection", ): ApiResult = v2.browse(CatalogQueryV2( - source = "library_collection", collectionId = collectionId, limit = limit, + source = source, collectionId = collectionId, limit = limit, type = mediaType, libraryId = libraryId?.toString(), sort = sort, order = order.takeIf { sort != null }, groups = queryGroups.toV2Groups(), match = match, ), continuation).map { it.toCatalogResponse() } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt index 29cd858f51..9e4c7368f3 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt @@ -69,6 +69,10 @@ class SectionRepository( ): ApiResult = sectionApi.getLibrarySectionItems(libraryId, sectionId, owner) + suspend fun getLibrarySectionCatalogItems(libraryId: Int, sectionId: String, + owner: org.siloserver.silo.network.AuthScopeSnapshot, continuation: CatalogContinuationV2?) = + sectionApi.getLibrarySectionCatalogItems(libraryId, sectionId, owner, continuation) + /** Lists collections within a library as a flat list. Callers that need * the grouped layout should use [getLibraryCollectionsGrouped]. */ suspend fun getLibraryCollections(libraryId: Int): ApiResult> = @@ -88,6 +92,9 @@ class SectionRepository( order: String? = null, queryGroups: List = emptyList(), match: String? = null, + mediaType: String? = null, + libraryId: Int? = null, + source: String = "library_collection", ): ApiResult = sectionApi.getLibraryCollectionItems( collectionId = collectionId, @@ -97,5 +104,8 @@ class SectionRepository( order = order, queryGroups = queryGroups, match = match, + mediaType = mediaType, + libraryId = libraryId, + source = source, ) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/SectionApiCollectionItemsTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/SectionApiCollectionItemsTest.kt index 0ec2242655..9db31a75f1 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/SectionApiCollectionItemsTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/api/SectionApiCollectionItemsTest.kt @@ -22,6 +22,23 @@ import kotlin.test.assertTrue class SectionApiCollectionItemsTest { + @Test + fun mixedCollectionTypeRemainsRequiredWithMatchAny() = runTest { + val requests = mutableListOf>() + val api = SectionApi(clientFor(requests, """{"total":0,"total_exact":true,"window_cursor":"w","page":{"has_more":false},"items":[]}""")) + val result = api.getLibraryCollectionItems( + "c1", mediaType = "series", libraryId = 7, source = "user_collection", match = "any", + queryGroups = listOf(CatalogQueryGroup(rules = listOf(CatalogQueryRule("genre", "contains", "Drama")))), + ) + assertTrue(result is ApiResult.Success) + val body = SiloJson.parseToJsonElement(requests.single().getValue("body")!!).jsonObject + assertEquals("series", body["type"]?.jsonPrimitive?.content) + assertEquals("7", body["library_id"]?.jsonPrimitive?.content) + assertEquals("user_collection", body["source"]?.jsonPrimitive?.content) + assertEquals("any", body["match"]?.jsonPrimitive?.content) + assertEquals("genre", body["groups"]!!.jsonArray.single().jsonObject["rules"]!!.jsonArray.single().jsonObject["field"]?.jsonPrimitive?.content) + } + /** * The collection's own order is expressed by sending no sort at all, so a * null sort must not leak an `order` param either. From 365153670fb65b0f84c939e3e44aab26b2325d34 Mon Sep 17 00:00:00 2001 From: amokster88 <69913373+amokster88@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:30:18 +0530 Subject: [PATCH 59/87] feat(tv): ask who's watching at launch with a Profile Selection setting (#498) * feat(tv): ask who's watching at launch with a Profile Selection setting Android TV always reopened the last profile, so on a shared TV the next person landed in someone else's profile unless every profile had a PIN. Add Settings > General > Profile at Launch > Profile Selection, matching the tvOS setting: Automatic (default, today's behavior), Every Time, After 1 Hour, and After 12 Hours. The choice is device-local and never sent to the server. When the setting asks, the saved profile and its PIN proof are cleared before Who's Watching shows, so a protected profile needs its PIN again. This applies at launch, when Silo returns from the background (an audiobook still playing counts as in use), and on a server switch. On a return, the previous profile's screens stay covered until the picker replaces them, and on Android 13+ no screenshot of them is kept for the warm-start window. Content deep links wait until a profile is chosen. Every Time keeps the launcher's Watch Next row empty, and the timed choices remove it once they expire. Closes #473 Co-Authored-By: Claude Opus 5.5 * fix(tv): close review gaps in the Profile Selection return rule The return rule treated Manage Servers and the add-server screens as signed-out routes, so leaving Silo from them and coming back dismissed the request without clearing the profile, and Back reopened the previous profile. Decide by whether a profile is active instead. End a phone-launched title's temporary identity with the receiver's run guard, so a handoff a newer receiver run claims during the teardown wait survives, and re-check the rule after that wait. Settings re-checks the choice before refilling Watch Next, so a quick switch back to Every Time cannot restart the refresh. Co-Authored-By: Claude Opus 5.5 * fix(tv): keep a superseded Watch Next seed from re-enqueuing seedNow() waits for any in-flight wipe before enqueuing. A clear() that ran during that wait had already cancelled the named work, so the older seed enqueued a one-shot after the cancellation. Capture the write generation when seedNow() is called and skip the enqueue if a later clear() invalidated it. Co-Authored-By: Claude Opus 5.5 * fix(tv): remove Watch Next on time when a timed Profile Selection runs out After 1 Hour and After 12 Hours removed the launcher's Watch Next row only from a delayed WorkManager job. That delay is a minimum: JobScheduler never wakes a TV in standby and can defer work for an app not used lately, so the previous viewer's titles could stay on the launcher well past the limit. A wake-up alarm at the expiry time now removes the row, within Android's 10-minute minimum window for inexact alarms, so no exact-alarm permission is needed. The same receiver re-applies the setting after a reboot, which clears alarms, and after a clock change, which can end the interval early. It wipes through WatchNextSeeder on the main thread, so a profile chosen at that moment re-seeds after the wipe rather than racing it, and it keeps the hourly refresh, which brings the row back if a corrected clock allows it again. The WorkManager job stays as a backstop. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): show Who's Watching when Android restores Silo's old screens When Profile Selection asks on a return that Android rebuilds Silo for (a configuration change such as font scale while Silo was away, or a process killed in the background), MainTvActivity cleared the profile and chose Who's Watching, but the NavHost restored the previous back stack. The TV was left on an empty Home reading "Sign in to load Home." A restored stack that still holds Main with no profile now goes to Who's Watching, and the return rule also replaces those screens when the profile is already gone. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- FEATURES.md | 1 + .../src/androidMain/AndroidManifest.xml | 12 ++ .../org/siloserver/silo/tv/MainTvActivity.kt | 71 +++++++- .../preferences/TvProfileLaunchPreferences.kt | 171 ++++++++++++++++++ .../siloserver/silo/tv/di/AndroidTvModule.kt | 14 +- .../silo/tv/profiles/TvActiveProfileReset.kt | 43 +++++ .../silo/tv/profiles/TvProfileAwayTracker.kt | 103 +++++++++++ .../silo/tv/ui/navigation/TvAppNavigation.kt | 131 ++++++++++++-- .../screens/servers/TvServerListViewModel.kt | 46 +++-- .../ui/screens/settings/TvSettingsScreen.kt | 62 ++++++- .../screens/settings/TvSettingsViewModel.kt | 42 +++++ .../silo/tv/watchnext/TvWorkerFactory.kt | 2 + .../WatchNextProfileExpiryReceiver.kt | 62 +++++++ .../silo/tv/watchnext/WatchNextSeeder.kt | 64 ++++++- .../silo/tv/watchnext/WatchNextSyncWorker.kt | 26 ++- .../TvProfileLaunchPreferencesTest.kt | 169 +++++++++++++++++ 16 files changed, 972 insertions(+), 47 deletions(-) create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt diff --git a/FEATURES.md b/FEATURES.md index fc94599a7a..1471640fc5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -129,6 +129,7 @@ Shown only while Settings → Experimental → Watch Party is on (default on in |---|:---:|:---:|---| | Household profiles (multiple per account) | ✅ | ✅ | `ProfileRepository` | | PIN-protected & child profiles, content-rating limits | ✅ | ✅ | | +| Profile Selection at launch (Automatic, Every Time, After 1 Hour, After 12 Hours) | ➖ | ✅ | TV: Settings → General; device-local; tvOS parity, including hiding Watch Next | | Per-profile language / subtitle / playback prefs | ✅ | ✅ | | | Library access restrictions per profile | ✅ | ✅ | | | Favorites & watchlist | ✅ | ✅ | TV: from Settings | diff --git a/androidTvApp/src/androidMain/AndroidManifest.xml b/androidTvApp/src/androidMain/AndroidManifest.xml index d06a1b6a87..c683382045 100644 --- a/androidTvApp/src/androidMain/AndroidManifest.xml +++ b/androidTvApp/src/androidMain/AndroidManifest.xml @@ -71,6 +71,18 @@ + + + + + + + + = Build.VERSION_CODES.TIRAMISU) { + lifecycleScope.launch { + get(TvProfileLaunchPreferences::class.java).state + .map { it.behavior == ProfileLaunchBehavior.Automatic } + .distinctUntilChanged() + .collect { automatic -> setRecentsScreenshotEnabled(automatic) } + } + } + // Run the SiloCast receiver while the app is started AND signed in, // following sign-in, profile selection and sign-out without needing a // background/foreground cycle. start()/stop() run here on the main @@ -150,6 +176,12 @@ class MainTvActivity : ComponentActivity() { launchAuthenticatedStartupWarmup(route) } + // Profile Selection: from the moment Silo returns and the setting + // asks, cover the previous profile's screens until Who's Watching + // has replaced them (TvAppNavigation lifts the request). + val profileAwayTracker = remember { get(TvProfileAwayTracker::class.java) } + val profileCoverVisible by profileAwayTracker.selectionRequired.collectAsState() + SiloTvTheme { val resolvedRoute = startRoute val splashVisible = resolvedRoute == null || !splashPlaybackComplete @@ -160,11 +192,12 @@ class MainTvActivity : ComponentActivity() { // Consume ALL input at the root while the splash // overlay is up: no input-dispatch-timeout ANR, and // no keys leak into the app pre-rendering below — - // even after its content grabs focus. + // even after its content grabs focus. The profile + // cover gates input the same way. if (splashVisible) { TvFocusLog.d { "key swallowed by splash gate" } } - splashVisible + splashVisible || profileCoverVisible }, ) { if (resolvedRoute != null) { @@ -179,6 +212,9 @@ class MainTvActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), ) } + if (profileCoverVisible && !splashVisible) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) + } if (splashVisible) { val splashFocus = remember { FocusRequester() } // PURE black — the splash video's encoded black is #000000, and any @@ -232,6 +268,7 @@ class MainTvActivity : ComponentActivity() { override fun onStart() { super.onStart() DiagnosticsLifecycleLogger.state("foreground") + get(TvProfileAwayTracker::class.java).onForeground() val refresher = get(ServerDrivenConfigRefresher::class.java) val monitor = get(ServerReachabilityMonitor::class.java) monitor.startForeground() @@ -300,6 +337,10 @@ class MainTvActivity : ComponentActivity() { override fun onStop() { DiagnosticsLifecycleLogger.state("background") super.onStop() + // A configuration-change recreation is not leaving Silo. + if (!isChangingConfigurations) { + get(TvProfileAwayTracker::class.java).onBackground() + } val monitor = get(ServerReachabilityMonitor::class.java) monitor.stopForeground() get(TvSiloCastReceiver::class.java).stop() @@ -317,6 +358,8 @@ class MainTvActivity : ComponentActivity() { private suspend fun resolveStartDestination(): String { val registry = get(ServerRegistry::class.java) val tokenManager = get(TokenManager::class.java) + val processStart = !hasResolvedLaunchProfile + hasResolvedLaunchProfile = true val activeEntry = registry.activeEntry.value ?: return TvRoute.ServerSetup.route @@ -350,6 +393,30 @@ class MainTvActivity : ComponentActivity() { val profileId = tokenManager.getProfileId() if (profileId.isNullOrBlank()) return TvRoute.ProfileSelection.route + // A remote-playback overlay (only possible when the Activity is + // recreated in a live process) owns identity until it ends; the + // navigation graph applies the return rule once it has. + if (tokenManager.hasTemporaryScope()) return TvRoute.Main.route + + // Profile Selection (silo-apple `ProfileLaunchState.resolution`). A + // process start applies the launch rule: Every Time, or a timed choice + // whose away interval ran out while Silo was closed. A later Activity + // in this process (Back left Silo, then it was reopened) follows the + // return rule the away tracker evaluated in onStart. Either way the + // profile is cleared here, before the picker loads, and losing its PIN + // proof makes a protected profile ask for its PIN again. + val launchPreferences = get(TvProfileLaunchPreferences::class.java) + val requiresSelection = if (processStart) { + launchPreferences.requiresSelectionAtLaunch() + } else { + get(TvProfileAwayTracker::class.java).selectionRequired.value + } + if (requiresSelection) { + get(TvActiveProfileReset::class.java).clearActiveProfile() + return TvRoute.ProfileSelection.route + } + if (processStart) launchPreferences.clearBackgroundedAt() + return TvRoute.Main.route } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt new file mode 100644 index 0000000000..5a3febeede --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt @@ -0,0 +1,171 @@ +package org.siloserver.silo.tv.data.preferences + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * When this Android TV asks "Who's watching?" instead of reopening the last + * profile. Mirrors silo-apple `ProfileLaunchBehavior` (tvOS General → PROFILE + * AT LAUNCH → Profile Selection), including its titles and descriptions. + */ +enum class ProfileLaunchBehavior( + /** Stored value. Same strings as silo-apple's raw values. */ + val raw: String, + val title: String, + val description: String, + /** How long Silo can be away before the picker shows again; null for the untimed choices. */ + val awayTimeoutMs: Long?, +) { + Automatic( + raw = "automatic", + title = "Automatic", + description = "Use the last profile selected on this Android TV. " + + "A protected profile can reopen without asking for its PIN.", + awayTimeoutMs = null, + ), + EveryTime( + raw = "askEveryLaunch", + title = "Every Time", + description = "Show Who's Watching whenever you return to Silo.", + awayTimeoutMs = null, + ), + AfterOneHour( + raw = "afterOneHour", + title = "After 1 Hour", + description = "Show Who's Watching when you've been away from Silo for 1 hour.", + awayTimeoutMs = 60 * 60 * 1000L, + ), + AfterTwelveHours( + raw = "afterTwelveHours", + title = "After 12 Hours", + description = "Show Who's Watching when you've been away from Silo for 12 hours.", + awayTimeoutMs = 12 * 60 * 60 * 1000L, + ), + ; + + companion object { + /** Unknown or missing values fall back to today's behavior. */ + fun fromRaw(raw: String?): ProfileLaunchBehavior = + entries.firstOrNull { it.raw == raw } ?: Automatic + } +} + +/** + * The policy plus the start of the current interval away from Silo. Pure, so + * the timing rules are unit-testable. Mirrors silo-apple `ProfileLaunchState`. + */ +data class ProfileLaunchState( + val behavior: ProfileLaunchBehavior = ProfileLaunchBehavior.Automatic, + /** Epoch ms when Silo last went to the background unused; null while in use. */ + val backgroundedAtMs: Long? = null, +) { + /** + * Cold start. Every Time asks even when the previous process never + * recorded a background; the timed choices ask only once a recorded away + * interval has run out. + */ + fun requiresSelectionAtLaunch(nowMs: Long): Boolean = + behavior == ProfileLaunchBehavior.EveryTime || hasExpiredAwayInterval(nowMs) + + /** Warm return. Needs a recorded background, so a brief pause never locks. */ + fun requiresSelectionAfterBackground(nowMs: Long): Boolean { + if (backgroundedAtMs == null) return false + return behavior == ProfileLaunchBehavior.EveryTime || hasExpiredAwayInterval(nowMs) + } + + /** + * Whether the launcher's Watch Next row may show the active profile's + * titles. silo-apple `TopShelfProfilePolicy`: never with Every Time, and + * not once a timed choice has expired. + */ + fun allowsWatchNext(nowMs: Long): Boolean = + behavior != ProfileLaunchBehavior.EveryTime && !requiresSelectionAfterBackground(nowMs) + + /** When the current away interval runs out; null when nothing is timing out. */ + val awayExpiresAtMs: Long? + get() { + val since = backgroundedAtMs ?: return null + val timeout = behavior.awayTimeoutMs ?: return null + return since + timeout + } + + /** Automatic never keeps an away interval. */ + fun withBehavior(behavior: ProfileLaunchBehavior): ProfileLaunchState = copy( + behavior = behavior, + backgroundedAtMs = if (behavior == ProfileLaunchBehavior.Automatic) null else backgroundedAtMs, + ) + + fun markedBackgrounded(atMs: Long): ProfileLaunchState = + if (behavior == ProfileLaunchBehavior.Automatic) copy(backgroundedAtMs = null) else copy(backgroundedAtMs = atMs) + + private fun hasExpiredAwayInterval(nowMs: Long): Boolean { + val since = backgroundedAtMs ?: return false + val timeout = behavior.awayTimeoutMs ?: return false + // A clock behind the recorded start (a TV that booted before its time + // synced, or a manual change) cannot show how long Silo was away, so + // ask rather than keep the profile open indefinitely. + if (nowMs < since) return true + return nowMs - since >= timeout + } +} + +/** + * Device-wide Profile Selection setting. It is never sent to the server, so a + * choice on this TV leaves every other device alone (silo-apple + * profile-launch design §12). It survives sign-out; the profile itself does + * not. + * + * Backed by SharedPreferences rather than DataStore: `onStop` writes the away + * marker and `onStart` reads it straight after, and SharedPreferences makes + * that write visible synchronously. + */ +class TvProfileLaunchPreferences( + private val prefs: SharedPreferences, + private val clock: () -> Long = System::currentTimeMillis, +) { + constructor(context: Context) : this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)) + + private val _state = MutableStateFlow(read()) + val state: StateFlow = _state.asStateFlow() + + fun requiresSelectionAtLaunch(): Boolean = state.value.requiresSelectionAtLaunch(clock()) + + fun requiresSelectionAfterBackground(): Boolean = state.value.requiresSelectionAfterBackground(clock()) + + fun allowsWatchNext(): Boolean = state.value.allowsWatchNext(clock()) + + fun setBehavior(behavior: ProfileLaunchBehavior) = update { it.withBehavior(behavior) } + + /** Start the away clock. A no-op under Automatic. */ + fun markBackgrounded() = update { it.markedBackgrounded(clock()) } + + /** End the away interval: Silo is in use again with a chosen profile. */ + fun clearBackgroundedAt() = update { it.copy(backgroundedAtMs = null) } + + @Synchronized + private fun update(change: (ProfileLaunchState) -> ProfileLaunchState) { + val next = change(_state.value) + if (next == _state.value) return + prefs.edit { + putString(KEY_BEHAVIOR, next.behavior.raw) + val at = next.backgroundedAtMs + if (at == null) remove(KEY_BACKGROUNDED_AT) else putLong(KEY_BACKGROUNDED_AT, at) + } + _state.value = next + } + + private fun read(): ProfileLaunchState = ProfileLaunchState( + behavior = ProfileLaunchBehavior.fromRaw(prefs.getString(KEY_BEHAVIOR, null)), + backgroundedAtMs = if (prefs.contains(KEY_BACKGROUNDED_AT)) prefs.getLong(KEY_BACKGROUNDED_AT, 0L) else null, + ) + + private companion object { + const val PREFS_NAME = "silo_tv_profile_launch" + const val KEY_BEHAVIOR = "behavior" + const val KEY_BACKGROUNDED_AT = "backgrounded_at_ms" + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index e217ddcd1a..5eaaa56a01 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -62,6 +62,9 @@ import org.siloserver.silo.tv.ui.screens.player.TvVideoPlaybackStarter import org.siloserver.silo.tv.ui.screens.profiles.TvProfileSelectionViewModel import org.siloserver.silo.tv.ui.screens.search.TvSearchViewModel import org.siloserver.silo.tv.data.preferences.TvLibraryScopeStore +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.tv.profiles.TvActiveProfileReset +import org.siloserver.silo.tv.profiles.TvProfileAwayTracker import org.siloserver.silo.tv.watchnext.WatchNextRepository import org.siloserver.silo.tv.watchnext.WatchNextSeeder import android.net.Uri @@ -320,6 +323,13 @@ val androidTvModule = module { single { WatchNextRepository(androidContext()) } single { WatchNextSeeder(androidContext(), get()) } + // Profile Selection (tvOS General → PROFILE AT LAUNCH): the device-wide + // choice, the tracker MainTvActivity drives from onStop/onStart, and the + // reset every path uses to drop the active profile. + single { TvProfileLaunchPreferences(androidContext()) } + single { TvProfileAwayTracker(get(), get(), get()) } + single { TvActiveProfileReset(get(), get(), get(), get(), get(), get(), get()) } + // Deep-link bridge between MainTvActivity (producer) and TvAppNavigation // (consumer). The Activity writes incoming Silo app-scheme URIs here on // cold-launch (read from launching intent in onCreate) and warm-launch @@ -416,7 +426,7 @@ val androidTvModule = module { profileId = params.get(), ) } - viewModel { TvServerListViewModel(get(), get(), get()) } + viewModel { TvServerListViewModel(get(), get(), get(), get(), get()) } viewModel { params -> org.siloserver.silo.viewmodel.RequestDetailViewModel(get(), params.get(), params.get(), featureStore = get()) @@ -604,6 +614,8 @@ val androidTvModule = module { tvLibraryScopeStore = getOrNull(), seekIntervalStore = get(), audiobookSettingsStore = get(), + profileLaunchPreferences = get(), + watchNextSeeder = get(), ) } viewModel { TvDiagnosticsViewModel(get()) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt new file mode 100644 index 0000000000..0aab21798f --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt @@ -0,0 +1,43 @@ +package org.siloserver.silo.tv.profiles + +import org.siloserver.silo.common.settings.CardPresentationStore +import org.siloserver.silo.common.settings.LibraryPlaybackPrefsStore +import org.siloserver.silo.common.settings.OverlayPrefsStore +import org.siloserver.silo.common.settings.SeekIntervalStore +import org.siloserver.silo.common.settings.TitleArtStore +import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.tv.watchnext.WatchNextSeeder + +/** + * Drops the active profile and everything cached for it, so the next profile + * starts clean. Shared by Switch Profile and every Profile Selection path + * (launch, return to Silo, server switch) so they all leave the same state. + */ +class TvActiveProfileReset( + private val profileRepository: ProfileRepository, + private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, + private val overlayPrefsStore: OverlayPrefsStore, + private val cardPresentationStore: CardPresentationStore, + private val seekIntervalStore: SeekIntervalStore, + private val titleArtStore: TitleArtStore, + private val watchNextSeeder: WatchNextSeeder, +) { + suspend fun clearActiveProfile() { + // The profile id and its PIN proof go together, so a protected + // profile asks for its PIN again. + profileRepository.clearProfile() + // Library/overlay prefs are per-profile — drop the caches + // so the next profile's prefs don't ghost-render the + // previous user's rows. Parity with the Settings + // switch-profile path. + libraryPlaybackPrefsStore.clear() + overlayPrefsStore.clear() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + // Clear the previous profile's Watch Next rows before + // landing on the picker; the new profile will re-seed + // when it is selected. + watchNextSeeder.clear() + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt new file mode 100644 index 0000000000..708f421892 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt @@ -0,0 +1,103 @@ +package org.siloserver.silo.tv.profiles + +import androidx.media3.common.Player +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.siloserver.silo.common.player.ActivePlayerHolder +import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.tv.watchnext.WatchNextSeeder + +/** + * Applies the Profile Selection setting when Silo leaves and returns to the + * foreground. Mirrors silo-apple ContentView's `markProfileAwayStartIfNeeded` + * and `handleReturnFromBackgroundIfNeeded`. + * + * Main thread only: [org.siloserver.silo.tv.MainTvActivity] calls it from + * onStop/onStart, and it reads the shared Media3 player, which lives on the + * main looper. + */ +class TvProfileAwayTracker( + private val preferences: TvProfileLaunchPreferences, + private val activePlayerHolder: ActivePlayerHolder, + private val watchNextSeeder: WatchNextSeeder, +) { + private val _selectionRequired = MutableStateFlow(false) + + /** + * Raised when a return to Silo must show Who's Watching. While it is up, + * MainTvActivity covers the screen; the navigation graph performs the + * switch and then calls [onSelectionHandled]. + */ + val selectionRequired: StateFlow = _selectionRequired.asStateFlow() + + private var returningFromBackground = false + private var watchedPlayer: Player? = null + + // Playback that stops while Silo is hidden starts the away clock then; + // playback that resumes stops it. This also catches the video player's + // own pause, which reaches the session after onStop has run. + private val playbackListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny(Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED)) { + markAwayStart() + } + } + } + + fun onBackground() { + returningFromBackground = true + // Every Time keeps the launcher's Watch Next row empty whenever Silo + // is not on screen (silo-apple Top Shelf parity). + if (preferences.state.value.behavior == ProfileLaunchBehavior.EveryTime) watchNextSeeder.clear() + markAwayStart() + watchedPlayer?.removeListener(playbackListener) + watchedPlayer = activePlayerHolder.player.value?.also { it.addListener(playbackListener) } + } + + fun onForeground() { + watchedPlayer?.removeListener(playbackListener) + watchedPlayer = null + // The first start of a process is a launch, which MainTvActivity's + // start-route resolution handles. + if (!returningFromBackground) return + returningFromBackground = false + if (!keepsProfileActive() && preferences.requiresSelectionAfterBackground()) { + _selectionRequired.value = true + } else { + preferences.clearBackgroundedAt() + } + } + + fun onSelectionHandled() { + _selectionRequired.value = false + } + + /** + * An audiobook still playing in the background is still use of the + * profile, as on Apple. Video never qualifies: the TV player stops when + * Silo is hidden. Uses play intent rather than `isPlaying` so a rebuffer + * mid-book does not count as leaving. + */ + private fun keepsProfileActive(): Boolean { + val player = activePlayerHolder.player.value ?: return false + return player.playWhenReady && + player.playbackState != Player.STATE_IDLE && + player.playbackState != Player.STATE_ENDED + } + + private fun markAwayStart() { + if (keepsProfileActive()) { + preferences.clearBackgroundedAt() + return + } + // Keep the earliest start: later player events (a stop after the + // pause) must not restart a clock that is already running. + if (preferences.state.value.backgroundedAtMs != null) return + preferences.markBackgrounded() + // The timed choices keep the Watch Next row until the interval runs + // out, then a check removes it. Automatic records no interval. + preferences.state.value.awayExpiresAtMs?.let(watchNextSeeder::scheduleProfileExpiryCheck) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 036832bf6c..67f918c9e8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -70,7 +70,14 @@ import org.siloserver.silo.common.settings.CardPresentationStore import org.siloserver.silo.common.settings.LibraryPlaybackPrefsStore import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.tv.watchnext.WatchNextSeeder +import org.siloserver.silo.tv.cast.RemotePlaybackIdentityManager import org.siloserver.silo.tv.cast.TvSiloCastReceiver +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.tv.profiles.TvActiveProfileReset +import org.siloserver.silo.tv.profiles.TvProfileAwayTracker +import org.siloserver.silo.common.player.PlaybackSessionLifecycle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import org.siloserver.silo.tv.ui.screens.cast.TvSiloCastStandbyView import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -103,6 +110,16 @@ internal fun tvShouldShowDiagnosticsPrompt( */ private const val MAX_DEEP_LINK_NAV_ATTEMPTS = 3 +/** + * How long the Profile Selection return rule waits for a phone-launched + * title's playback teardown before ending its identity. Same bound as + * TvSiloCastReceiver's PLAYBACK_TEARDOWN_TIMEOUT_MS. + */ +private const val CAST_PLAYBACK_TEARDOWN_TIMEOUT_MS = 15_000L + +/** Upper bound on how long the Profile Selection cover waits for the picker's transition. */ +private const val PROFILE_COVER_MAX_MS = 2_000L + /** * True when item detail for exactly [contentId]/[seasonNumber] is already the * top of the stack. @@ -440,6 +457,93 @@ fun TvAppNavigation( val pendingDeepLink: MutableStateFlow = koinInject(qualifier = named("pendingDeepLink")) val siloCastStandby by siloCastReceiver.standbyState.collectAsState() + val profileAwayTracker: TvProfileAwayTracker = koinInject() + val profileLaunchPreferences: TvProfileLaunchPreferences = koinInject() + val activeProfileReset: TvActiveProfileReset = koinInject() + val remotePlaybackIdentityManager: RemotePlaybackIdentityManager = koinInject() + val playbackLifecycle: PlaybackSessionLifecycle = koinInject() + + // Android can restore Silo's previous back stack over the start route + // MainTvActivity chose: after a configuration change while Silo was away, + // or after its process was killed in the background. When Profile + // Selection has already cleared the profile, that brings back Home with + // no profile behind it, so go to Who's Watching instead. + LaunchedEffect(navController) { + navController.currentBackStackEntryFlow.first() + val restoredMain = runCatching { navController.getBackStackEntry(TvRoute.Main.route) }.isSuccess + if (restoredMain && + !tokenManager.getAccessToken().isNullOrBlank() && + tokenManager.getProfileId().isNullOrBlank() + ) { + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + } + + // Profile Selection's return rule (silo-apple `applyProfileReturnPolicy`): + // when Silo comes back from the background and the setting asks, Who's + // Watching replaces whatever was on screen, as Switch Profile does. + LaunchedEffect(profileAwayTracker) { + kotlinx.coroutines.flow.combine( + profileAwayTracker.selectionRequired, + navController.currentBackStackEntryFlow, + ) { required, entry -> required to entry.destination.route }.collect { (required, route) -> + if (!required) return@collect + // Nothing to replace when signed out, or when neither a profile + // nor its screens are left (already choosing a profile, or adding + // a server). Judge by the profile and the back stack, not the + // route: Manage Servers and the add-server screens can sit on top + // of Main, and an Activity recreated during the return (a + // configuration change while Silo was away) restores Main after + // MainTvActivity has already cleared the profile. + val mainOnBackStack = runCatching { navController.getBackStackEntry(TvRoute.Main.route) }.isSuccess + val showsProfileScreens = !tokenManager.getAccessToken().isNullOrBlank() && + (!tokenManager.getProfileId().isNullOrBlank() || mainOnBackStack) + if (route == null || !showsProfileScreens) { + profileAwayTracker.onSelectionHandled() + return@collect + } + // A phone-launched title's temporary identity is not this TV's + // profile to clear. End it first, as tvOS does, but in + // TvSiloCastReceiver.stop()'s order and with its guard: the + // player's queued final report and stopSession ride on that + // identity, so let them land (bounded) first, and leave it alone + // if a newer receiver run installed or reused it meanwhile. That + // is a phone's live title, and its viewer is the one watching. + if (tokenManager.hasTemporaryScope()) { + val claimedByRun = remotePlaybackIdentityManager.activeIdentity?.receiverRun + withTimeoutOrNull(CAST_PLAYBACK_TEARDOWN_TIMEOUT_MS) { playbackLifecycle.awaitPendingStop() } + if (claimedByRun != null) remotePlaybackIdentityManager.endIfNotClaimedSince(claimedByRun) + if (tokenManager.hasTemporaryScope()) { + profileAwayTracker.onSelectionHandled() + return@collect + } + } + // Re-check after the wait, as silo-apple's return policy does. + if (!profileLaunchPreferences.requiresSelectionAfterBackground()) { + profileAwayTracker.onSelectionHandled() + return@collect + } + // Leave the profile's screens before clearing its state (see + // onSwitchProfile). MainTvActivity.onStop already flushed pending + // device-setting writes under this profile. + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + activeProfileReset.clearActiveProfile() + // MainTvActivity covers the screen while the request is up; lift + // it once the previous profile's screens have faded out. + withTimeoutOrNull(PROFILE_COVER_MAX_MS) { + navController.visibleEntries.first { entries -> + entries.all { it.destination.route in preMainAuthRoutes } + } + } + profileAwayTracker.onSelectionHandled() + } + } LaunchedEffect(siloCastReceiver) { siloCastReceiver.launchRequests.collect { request -> @@ -487,7 +591,9 @@ fun TvAppNavigation( kotlinx.coroutines.flow.combine( pendingDeepLink, navController.currentBackStackEntryFlow, - ) { uri, entry -> uri to entry }.collect { (uri, entry) -> + // Re-runs a held link once a chosen profile ends the away interval. + profileLaunchPreferences.state, + ) { uri, entry, _ -> uri to entry }.collect { (uri, entry) -> if (uri == null) return@collect if (uri != attemptUri) { attemptUri = uri @@ -528,6 +634,11 @@ fun TvAppNavigation( // never go stale on an authenticated session. val route = entry.destination.route if (route == null || route in preMainAuthRoutes) return@collect // unauthenticated flow: keep queued until Main + // Profile Selection (silo-apple `handleDeepLink`): a link that + // arrives while Silo is returning must not open the previous + // profile's data before Who's Watching replaces it. Keep it queued + // until a profile is chosen. + if (profileLaunchPreferences.requiresSelectionAfterBackground()) return@collect val contentId = uri.pathSegments.lastOrNull() ?: run { pendingDeepLink.value = null return@collect @@ -812,6 +923,9 @@ fun TvAppNavigation( composable(TvRoute.ProfileSelection.route) { TvProfileSelectionScreen( onProfileSelected = { + // A chosen profile ends any away interval (silo-apple + // `remember`), which lets Watch Next and held links through. + profileLaunchPreferences.clearBackgroundedAt() navController.navigate(TvRoute.Main.route) { popUpTo(TvRoute.ProfileSelection.route) { inclusive = true } } @@ -959,20 +1073,7 @@ fun TvAppNavigation( navController.navigate(TvRoute.ProfileSelection.route) { popUpTo(TvRoute.Main.route) { inclusive = true } } - profileRepository.clearProfile() - // Library/overlay prefs are per-profile — drop the caches - // so the next profile's prefs don't ghost-render the - // previous user's rows. Parity with the Settings - // switch-profile path. - libraryPlaybackPrefsStore.clear() - overlayPrefsStore.clear() - cardPresentationStore.clear() - seekIntervalStore.clear() - titleArtStore.clear() - // Clear the previous profile's Watch Next rows before - // landing on the picker; the new profile will re-seed - // via [onProfileSelected]. - watchNextSeeder.clear() + activeProfileReset.clearActiveProfile() } }, // Android TV is now multi-server (parity with tvOS). "Switch diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt index 91cb56547b..f0671715f7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt @@ -6,6 +6,8 @@ import org.siloserver.silo.model.server.ServerEntry import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.tv.profiles.TvActiveProfileReset import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -37,6 +39,8 @@ class TvServerListViewModel( private val serverRegistry: ServerRegistry, private val tokenManager: TokenManager, private val authRepository: AuthRepository, + private val profileLaunchPreferences: TvProfileLaunchPreferences, + private val activeProfileReset: TvActiveProfileReset, ) : ViewModel() { private val _uiState = MutableStateFlow(TvServerListUiState()) @@ -69,14 +73,7 @@ class TvServerListViewModel( // Land on the deepest screen the new server's stored credentials // can reach — preserves the signed-in user when tokens are present. - val accessToken = tokenManager.getAccessToken() - val activeEntry = serverRegistry.activeEntry.value - val profileId = activeEntry?.profileId ?: tokenManager.getProfileId() - val destination = when { - accessToken.isNullOrBlank() -> TvServerSwitchDestination.Login - profileId.isNullOrBlank() -> TvServerSwitchDestination.ProfileSelection - else -> TvServerSwitchDestination.Home - } + val destination = switchDestination() _uiState.update { it.copy(pendingSwitchToId = null, switchedTo = destination) @@ -123,17 +120,34 @@ class TvServerListViewModel( // Land on the deepest screen the promoted server's stored // credentials can reach — preserves that server's session if tokens // are present, otherwise falls back to Login. - val accessToken = tokenManager.getAccessToken() - val activeEntry = serverRegistry.activeEntry.value - val profileId = activeEntry?.profileId ?: tokenManager.getProfileId() - val destination = when { - accessToken.isNullOrBlank() -> TvServerSwitchDestination.Login - profileId.isNullOrBlank() -> TvServerSwitchDestination.ProfileSelection - else -> TvServerSwitchDestination.Home - } + val destination = switchDestination() _uiState.update { it.copy(switchedTo = destination) } } } + /** + * The deepest screen the now-active server's stored credentials reach. + * + * Profile Selection covers a server switch too (silo-apple design §7.5): + * when it would ask at launch (Every Time, or a timed choice that has run + * out), the server's saved profile is cleared and Who's Watching shows + * instead. Otherwise switching servers would reopen a protected profile + * there without its PIN. Clearing before navigating keeps the picker from + * loading under an identity that is about to change. + */ + private suspend fun switchDestination(): TvServerSwitchDestination { + val accessToken = tokenManager.getAccessToken() + val activeEntry = serverRegistry.activeEntry.value + val profileId = activeEntry?.profileId ?: tokenManager.getProfileId() + return when { + accessToken.isNullOrBlank() -> TvServerSwitchDestination.Login + profileId.isNullOrBlank() -> TvServerSwitchDestination.ProfileSelection + profileLaunchPreferences.requiresSelectionAtLaunch() -> { + activeProfileReset.clearActiveProfile() + TvServerSwitchDestination.ProfileSelection + } + else -> TvServerSwitchDestination.Home + } + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 111d48de9a..e83058ff51 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -123,6 +123,7 @@ import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.common.ui.components.profileAvatarDisplayText import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage import androidx.compose.ui.layout.ContentScale +import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior import org.siloserver.silo.tv.data.preferences.SubtitleMode import org.siloserver.silo.tv.ui.screens.player.TvSubtitleAppearanceOptions import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsSettingsPane @@ -315,6 +316,7 @@ fun TvSettingsScreen( onSubtitleDeviceOverrideEnabledChanged = viewModel::setSubtitleDeviceOverrideEnabled, onSubtitleMatchesDeviceChanged = viewModel::onSubtitleMatchesDeviceChanged, onShowAudiobooksTabChanged = viewModel::onShowAudiobooksTabChanged, + onProfileLaunchBehaviorChanged = viewModel::onProfileLaunchBehaviorChanged, ) if (showSignOutConfirm) { @@ -421,6 +423,7 @@ private fun SettingsSplitLayout( onCategorySelected: (TvSettingsCategory) -> Unit, onEnterCategory: (TvSettingsCategory) -> Unit, onShowAudiobooksTabChanged: (Boolean) -> Unit, + onProfileLaunchBehaviorChanged: (ProfileLaunchBehavior) -> Unit, onSwitchProfile: () -> Unit, onManageServers: () -> Unit, onOpenDiagnosticsReport: (reportId: String) -> Unit, @@ -504,6 +507,7 @@ private fun SettingsSplitLayout( detailFocusRequester = detailFocusRequester, onDetailFocusChanged = onDetailFocusChanged, onShowAudiobooksTabChanged = onShowAudiobooksTabChanged, + onProfileLaunchBehaviorChanged = onProfileLaunchBehaviorChanged, onManageServers = onManageServers, onOpenDiagnosticsReport = onOpenDiagnosticsReport, onQualityPresetSelected = onQualityPresetSelected, @@ -785,6 +789,7 @@ private fun SettingsDetailPane( detailFocusRequester: FocusRequester, onDetailFocusChanged: (Boolean) -> Unit, onShowAudiobooksTabChanged: (Boolean) -> Unit, + onProfileLaunchBehaviorChanged: (ProfileLaunchBehavior) -> Unit, onManageServers: () -> Unit, onOpenDiagnosticsReport: (reportId: String) -> Unit, /** Receives a [QualityPresets] preset id. */ @@ -836,6 +841,7 @@ private fun SettingsDetailPane( homeSectionsViewModel = homeSectionsViewModel, firstFocusRequester = detailFocusRequester, onShowAudiobooksTabChanged = onShowAudiobooksTabChanged, + onProfileLaunchBehaviorChanged = onProfileLaunchBehaviorChanged, onCardPresentationChanged = onCardPresentationChanged, onCardPresentationDeviceOnlyChanged = onCardPresentationDeviceOnlyChanged, onUseProfileCardDefault = onUseProfileCardDefault, @@ -953,11 +959,13 @@ private fun TvGeneralSettingsPane( homeSectionsViewModel: HomeViewModel, firstFocusRequester: FocusRequester, onShowAudiobooksTabChanged: (Boolean) -> Unit, + onProfileLaunchBehaviorChanged: (ProfileLaunchBehavior) -> Unit, onCardPresentationChanged: (CardPresentation) -> Unit, onCardPresentationDeviceOnlyChanged: (Boolean) -> Unit, onUseProfileCardDefault: () -> Unit, ) { var activeCardPicker by remember { mutableStateOf(null) } + var showProfileLaunchPicker by remember { mutableStateOf(false) } var showHomeSectionsEditor by remember { mutableStateOf(false) } val titleArtStore: TitleArtStore = koinInject() // Opening General is a refresh edge for a title art choice made on another @@ -970,6 +978,19 @@ private fun TvGeneralSettingsPane( verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = Spacing.xxxl), ) { + item { + // tvOS General → PROFILE AT LAUNCH. Device-wide and never synced, + // so it reads the same whichever profile is active. + SettingsGroup(title = "Profile at Launch") { + SettingsValueRow( + label = "Profile Selection", + value = state.profileLaunchBehavior.title, + onClick = { showProfileLaunchPicker = true }, + focusRequester = firstFocusRequester, + ) + SettingsFooterText(text = state.profileLaunchBehavior.description) + } + } item { // Device-local, server/profile-specific visibility and order for // the populated rows returned by Home, matching tvOS General. @@ -978,7 +999,6 @@ private fun TvGeneralSettingsPane( label = "Home Sections", value = "", onClick = { showHomeSectionsEditor = true }, - focusRequester = firstFocusRequester, ) SettingsFooterText( text = "Choose which Home rows are visible and edit the order in which they appear on this Android TV.", @@ -1109,6 +1129,21 @@ private fun TvGeneralSettingsPane( null -> Unit } + if (showProfileLaunchPicker) { + TvSettingsPickerSheet( + title = "Profile Selection", + options = ProfileLaunchBehavior.entries.map { + PickerOption(it.raw, it.title, detail = it.description) + }, + selectedId = state.profileLaunchBehavior.raw, + onSelect = { id -> + onProfileLaunchBehaviorChanged(ProfileLaunchBehavior.fromRaw(id)) + showProfileLaunchPicker = false + }, + onDismiss = { showProfileLaunchPicker = false }, + ) + } + if (showHomeSectionsEditor) { TvHomeSectionsEditor( onDismiss = { showHomeSectionsEditor = false }, @@ -2062,7 +2097,8 @@ private enum class SubtitlePicker { // Reusable picker sheet (centered modal vertical option list) // --------------------------------------------------------------------------- -data class PickerOption(val id: String, val label: String) +/** [detail] is an optional second line explaining the choice (tvOS `TVSettingsOption.detail`). */ +data class PickerOption(val id: String, val label: String, val detail: String? = null) /** * Reusable centered modal option picker. Renders a vertical list with a @@ -2228,13 +2264,21 @@ private fun TvSettingsPickerOptionRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - text = option.label, - style = SettingsRowTextStyle(), - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - color = foreground, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = option.label, + style = SettingsRowTextStyle(), + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = foreground, + ) + option.detail?.let { detail -> + Text( + text = detail, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp, lineHeight = 18.sp), + color = foreground.copy(alpha = 0.72f), + ) + } + } // tvOS TVSettingsPickerOptionRow: a filled check circle trails the // current option. if (selected) { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 98122ae775..044d596b7b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -29,8 +29,11 @@ import org.siloserver.silo.network.TokenManager import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.tv.data.preferences.LegacyTvPrefsMigration +import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior import org.siloserver.silo.tv.data.preferences.SubtitleMode import org.siloserver.silo.tv.data.preferences.SubtitleSize +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.tv.watchnext.WatchNextSeeder import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -66,6 +69,8 @@ class TvSettingsViewModel( private val tvLibraryScopeStore: org.siloserver.silo.tv.data.preferences.TvLibraryScopeStore? = null, private val seekIntervalStore: SeekIntervalStore? = null, audiobookSettingsStore: AudiobookSettingsStore? = null, + private val profileLaunchPreferences: TvProfileLaunchPreferences? = null, + private val watchNextSeeder: WatchNextSeeder? = null, ) : ViewModel() { /** @@ -123,6 +128,8 @@ class TvSettingsViewModel( val matchContentFrameRate: Boolean = false, val dolbyVisionEnabled: Boolean = true, val showAudiobooksTab: Boolean = false, + // Profile Selection (tvOS General → PROFILE AT LAUNCH), device-wide. + val profileLaunchBehavior: ProfileLaunchBehavior = ProfileLaunchBehavior.Automatic, val subtitleMatchesDevice: Boolean = false, val dvProfile7HDR10Fallback: Boolean = false, val forceHdrPassthrough: Boolean = false, @@ -152,6 +159,16 @@ class TvSettingsViewModel( loadSettings() observePlayerSettings() observeCardPresentation() + observeProfileLaunch() + } + + private fun observeProfileLaunch() { + val preferences = profileLaunchPreferences ?: return + viewModelScope.launch { + preferences.state.collect { launch -> + _uiState.update { it.copy(profileLaunchBehavior = launch.behavior) } + } + } } /** @@ -650,6 +667,31 @@ class TvSettingsViewModel( } } + /** + * Profile Selection takes effect the next time Silo starts or returns; it + * never ejects the current session (silo-apple design §4.5). Every Time + * empties the launcher's Watch Next row, and leaving it refills the row. + */ + fun onProfileLaunchBehaviorChanged(behavior: ProfileLaunchBehavior) { + val preferences = profileLaunchPreferences ?: return + val previous = preferences.state.value.behavior + if (previous == behavior) return + preferences.setBehavior(behavior) + val seeder = watchNextSeeder ?: return + if (behavior == ProfileLaunchBehavior.EveryTime) { + seeder.clear() + } else if (previous == ProfileLaunchBehavior.EveryTime) { + viewModelScope.launch { + if (tokenManager.getProfileId().isNullOrBlank()) return@launch + // A later choice may have gone back to Every Time (which + // cleared the row) while this read was in flight. + if (preferences.state.value.behavior == ProfileLaunchBehavior.EveryTime) return@launch + seeder.seedNow() + seeder.enqueuePeriodic() + } + } + } + fun onSubtitleMatchesDeviceChanged(value: Boolean) { viewModelScope.launch { playerSettingsStore.setSubtitleMatchesDevice(value) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt index fe322ca701..b75bea70fb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt @@ -13,6 +13,7 @@ import org.siloserver.silo.common.diagnostics.HostedDiagnosticsDeletionWorker import org.siloserver.silo.common.diagnostics.HostedDiagnosticsReportDeleter import org.siloserver.silo.common.diagnostics.PendingReportStore import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences import org.koin.core.context.GlobalContext /** @@ -47,6 +48,7 @@ class TvWorkerFactory : WorkerFactory() { params = workerParameters, sectionRepository = koin.get(), repository = koin.get(), + allowsWatchNext = koin.get()::allowsWatchNext, ) } SyncWorker::class.java.name -> { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt new file mode 100644 index 0000000000..bd299d72c3 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt @@ -0,0 +1,62 @@ +package org.siloserver.silo.tv.watchnext + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import org.koin.core.context.GlobalContext +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences + +/** + * Keeps the launcher's Watch Next row in line with Profile Selection while + * Silo is away, at the moments nothing in Silo is running to do it: + * + * - the wake-up alarm [WatchNextSeeder.scheduleProfileExpiryCheck] sets for + * when a timed choice runs out. JobScheduler never wakes a TV in standby + * and can defer work for an app not used lately, so a WorkManager job + * alone could leave the previous viewer's titles up well past the limit; + * - a reboot, which clears that alarm; + * - a clock change, which can end the away interval early (a clock set back + * behind its start counts as expired). + * + * Each removes the row when the setting now hides it, and otherwise re-arms + * the alarm for an interval still running. + */ +class WatchNextProfileExpiryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action !in HANDLED_ACTIONS) return + val koin = GlobalContext.get() + val preferences = koin.get() + val seeder = koin.get() + // This runs on the main thread, as choosing a profile does, so a + // choice cannot land between this check and the wipe, and the seed + // that choice starts waits for the wipe to finish. + if (preferences.allowsWatchNext()) { + preferences.state.value.awayExpiresAtMs?.let(seeder::scheduleProfileExpiryCheck) + return + } + val pending = goAsync() + // Keep the hourly refresh: a clock set back and then corrected can + // make the same profile's titles allowed again. + seeder.clear(keepRefresh = true).invokeOnCompletion { cause -> + if (cause == null) { + Log.i(TAG, "Profile Selection hides Watch Next; removed the row") + } else { + Log.w(TAG, "Watch Next wipe failed", cause) + } + pending.finish() + } + } + + companion object { + /** The expiry alarm's own action; the others come from the system. */ + const val ACTION_PROFILE_EXPIRY = "org.siloserver.silo.tv.action.WATCH_NEXT_PROFILE_EXPIRY" + + private const val TAG = "WatchNextExpiry" + private val HANDLED_ACTIONS = setOf( + ACTION_PROFILE_EXPIRY, + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_TIME_CHANGED, + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt index 02b91d3b10..631a5698e1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt @@ -1,6 +1,9 @@ package org.siloserver.silo.tv.watchnext +import android.app.AlarmManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy @@ -9,6 +12,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import androidx.work.workDataOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -43,8 +47,13 @@ class WatchNextSeeder( fun seedNow() { val pending = clearJob + // A clear() after this call supersedes it: clear() invalidates the + // write generation first, so a seed still waiting on an older wipe + // must not enqueue after that clear()'s cancellation. + val generation = repository.writeGate.capture() scope.launch { pending?.join() + if (!repository.writeGate.current(generation)) return@launch val request = OneTimeWorkRequestBuilder() .setConstraints(networkConstraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) @@ -70,7 +79,50 @@ class WatchNextSeeder( ) } - fun clear() { + /** + * Re-checks Profile Selection when a timed away interval runs out at + * [expiresAtMs] (wall clock, like the policy), so the launcher stops + * showing this profile's titles then rather than at the next hourly + * refresh. Needs no network: it only wipes the row, or does nothing if + * Silo was opened in time. + * + * A wake-up alarm does the timely check, since WorkManager's delay is + * only a minimum and JobScheduler never wakes a TV in standby. + * [WatchNextProfileExpiryReceiver] re-arms it after a reboot or a clock + * change. The WorkManager job stays as a backstop. + */ + fun scheduleProfileExpiryCheck(expiresAtMs: Long) { + context.getSystemService(AlarmManager::class.java)?.setWindow( + AlarmManager.RTC_WAKEUP, + expiresAtMs, + EXPIRY_ALARM_WINDOW_MS, + PendingIntent.getBroadcast( + context, + 0, + Intent(context, WatchNextProfileExpiryReceiver::class.java) + .setAction(WatchNextProfileExpiryReceiver.ACTION_PROFILE_EXPIRY), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + val delayMs = (expiresAtMs - System.currentTimeMillis()).coerceAtLeast(0L) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .setInputData(workDataOf(WatchNextSyncWorker.KEY_POLICY_CHECK_ONLY to true)) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + WatchNextSyncWorker.UNIQUE_NAME_PROFILE_EXPIRY, + ExistingWorkPolicy.REPLACE, + request, + ) + } + + /** + * Returns the provider wipe, which runs after this returns. [keepRefresh] + * leaves the hourly refresh in place for a wipe that only Profile + * Selection asked for: each run re-checks the setting, so the row comes + * back if the same profile may show it again. + */ + fun clear(keepRefresh: Boolean = false): Job { repository.invalidate() // Cancel FIRST, then wipe. Cancel BOTH the periodic refresh and any // in-flight one-shot seed — without cancelling before the wipe, a @@ -81,16 +133,22 @@ class WatchNextSeeder( // cancellation. The repository orders the wipe after any dispatched // write, and rejects old writers waiting behind that wipe. WorkManager.getInstance(context).apply { - cancelUniqueWork(WatchNextSyncWorker.UNIQUE_NAME_PERIODIC) + if (!keepRefresh) cancelUniqueWork(WatchNextSyncWorker.UNIQUE_NAME_PERIODIC) cancelUniqueWork(WatchNextSyncWorker.UNIQUE_NAME_ONESHOT) } // Cross-process ContentResolver deletes are binder I/O: run them off // the caller's (main) thread so clear() stays cheap for call sites. // Track the wipe so [seedNow] can join it before enqueuing a seed. - clearJob = scope.launch { repository.clearAll() } + return scope.launch { repository.clearAll() }.also { clearJob = it } } private val networkConstraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() + + private companion object { + // The shortest window an inexact alarm gets from Android 12 on; an + // exact alarm would need the SCHEDULE_EXACT_ALARM permission. + const val EXPIRY_ALARM_WINDOW_MS = 10 * 60 * 1000L + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt index e541565c9a..a1d56870f4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -21,17 +22,40 @@ class WatchNextSyncWorker( params: WorkerParameters, private val sectionRepository: SectionRepository, private val repository: WatchNextRepository, + /** Profile Selection's verdict; see [TvProfileLaunchPreferences.allowsWatchNext]. */ + private val allowsWatchNext: () -> Boolean = { true }, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val completed = syncWatchNextHome(sectionRepository, repository.writeGate, { isStopped }) { fields, run, authority -> + if (wipeIfHidden()) return@withContext Result.success() + if (inputData.getBoolean(KEY_POLICY_CHECK_ONLY, false)) return@withContext Result.success() + val stopped = { isStopped || !allowsWatchNext() } + val completed = syncWatchNextHome(sectionRepository, repository.writeGate, stopped) { fields, run, authority -> repository.diffAndApply(fields, run, authority) } + // The verdict can flip mid-run (a timed choice expiring); wipe what + // this run already wrote instead of leaving it for a retry. + if (wipeIfHidden()) return@withContext Result.success() if (completed) Result.success() else Result.retry() } + /** + * Profile Selection hides the previous viewer's titles from the launcher + * (silo-apple TopShelfProfilePolicy): wipe instead of writing. + */ + private suspend fun wipeIfHidden(): Boolean { + if (allowsWatchNext()) return false + repository.invalidate() + repository.clearAll() + return true + } + companion object { const val UNIQUE_NAME_PERIODIC = "watch_next_sync_periodic" const val UNIQUE_NAME_ONESHOT = "watch_next_sync_oneshot" + const val UNIQUE_NAME_PROFILE_EXPIRY = "watch_next_profile_expiry" + + /** Input flag: only apply Profile Selection's verdict, never sync. */ + const val KEY_POLICY_CHECK_ONLY = "policy_check_only" } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt new file mode 100644 index 0000000000..cd32ae3f27 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt @@ -0,0 +1,169 @@ +package org.siloserver.silo.tv.data.preferences + +import android.content.SharedPreferences +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Profile Selection timing rules, mirroring silo-apple + * `ProfileLaunchPolicyTests`: when Who's Watching is required, and when the + * launcher's Watch Next row may show the active profile. + */ +class TvProfileLaunchPreferencesTest { + + private val hour = 60 * 60 * 1000L + private val start = 1_000_000_000L + + private fun state(behavior: ProfileLaunchBehavior, backgroundedAt: Long? = null) = + ProfileLaunchState(behavior = behavior, backgroundedAtMs = backgroundedAt) + + @Test + fun `missing or unknown stored value means Automatic`() { + assertEquals(ProfileLaunchBehavior.Automatic, ProfileLaunchBehavior.fromRaw(null)) + assertEquals(ProfileLaunchBehavior.Automatic, ProfileLaunchBehavior.fromRaw("later")) + assertEquals(ProfileLaunchBehavior.EveryTime, ProfileLaunchBehavior.fromRaw("askEveryLaunch")) + } + + @Test + fun `Automatic never asks and never records an away interval`() { + val marked = state(ProfileLaunchBehavior.Automatic).markedBackgrounded(start) + assertNull(marked.backgroundedAtMs) + assertFalse(marked.requiresSelectionAtLaunch(start + 100 * hour)) + assertFalse(marked.requiresSelectionAfterBackground(start + 100 * hour)) + assertTrue(marked.allowsWatchNext(start + 100 * hour)) + } + + @Test + fun `Every Time asks at every launch and every real return, and hides Watch Next`() { + val everyTime = state(ProfileLaunchBehavior.EveryTime) + // A process that died without recording a background still asks. + assertTrue(everyTime.requiresSelectionAtLaunch(start)) + // A return needs a recorded background, so a brief pause never locks. + assertFalse(everyTime.requiresSelectionAfterBackground(start)) + assertTrue(everyTime.markedBackgrounded(start).requiresSelectionAfterBackground(start)) + assertFalse(everyTime.allowsWatchNext(start)) + } + + @Test + fun `the Watch Next expiry check is due when the away interval runs out`() { + assertEquals(start + hour, state(ProfileLaunchBehavior.AfterOneHour, backgroundedAt = start).awayExpiresAtMs) + assertEquals(start + 12 * hour, state(ProfileLaunchBehavior.AfterTwelveHours, backgroundedAt = start).awayExpiresAtMs) + // Nothing times out while Silo is in use, or for the untimed choices. + assertNull(state(ProfileLaunchBehavior.AfterOneHour).awayExpiresAtMs) + assertNull(state(ProfileLaunchBehavior.EveryTime, backgroundedAt = start).awayExpiresAtMs) + assertNull(state(ProfileLaunchBehavior.Automatic).markedBackgrounded(start).awayExpiresAtMs) + } + + @Test + fun `After 1 Hour asks exactly when the hour has passed`() { + val away = state(ProfileLaunchBehavior.AfterOneHour, backgroundedAt = start) + assertFalse(away.requiresSelectionAfterBackground(start + hour - 1)) + assertTrue(away.requiresSelectionAfterBackground(start + hour)) + assertFalse(away.requiresSelectionAtLaunch(start + hour - 1)) + assertTrue(away.requiresSelectionAtLaunch(start + hour)) + // Without a recorded away interval a timed choice restores. + assertFalse(state(ProfileLaunchBehavior.AfterOneHour).requiresSelectionAtLaunch(start + 100 * hour)) + } + + @Test + fun `After 12 Hours asks exactly when twelve hours have passed`() { + val away = state(ProfileLaunchBehavior.AfterTwelveHours, backgroundedAt = start) + assertFalse(away.requiresSelectionAfterBackground(start + 12 * hour - 1)) + assertTrue(away.requiresSelectionAfterBackground(start + 12 * hour)) + } + + @Test + fun `a clock behind the recorded start asks instead of trusting it`() { + val away = state(ProfileLaunchBehavior.AfterTwelveHours, backgroundedAt = start) + assertTrue(away.requiresSelectionAtLaunch(start - 1)) + assertTrue(away.requiresSelectionAfterBackground(start - 1)) + } + + @Test + fun `timed choices keep Watch Next until the interval runs out`() { + val away = state(ProfileLaunchBehavior.AfterOneHour, backgroundedAt = start) + assertTrue(away.allowsWatchNext(start + hour - 1)) + assertFalse(away.allowsWatchNext(start + hour)) + assertTrue(state(ProfileLaunchBehavior.AfterOneHour).allowsWatchNext(start + 100 * hour)) + } + + @Test + fun `switching to Automatic ends a running away interval`() { + val away = state(ProfileLaunchBehavior.EveryTime, backgroundedAt = start) + assertNull(away.withBehavior(ProfileLaunchBehavior.Automatic).backgroundedAtMs) + assertEquals(start, away.withBehavior(ProfileLaunchBehavior.AfterOneHour).backgroundedAtMs) + } + + @Test + fun `the choice and the away interval survive a process restart`() { + val prefs = InMemoryPrefs() + var now = start + val first = TvProfileLaunchPreferences(prefs) { now } + first.setBehavior(ProfileLaunchBehavior.AfterOneHour) + first.markBackgrounded() + + now = start + hour + val restarted = TvProfileLaunchPreferences(prefs) { now } + assertEquals(ProfileLaunchBehavior.AfterOneHour, restarted.state.value.behavior) + assertEquals(start, restarted.state.value.backgroundedAtMs) + assertTrue(restarted.requiresSelectionAtLaunch()) + + restarted.clearBackgroundedAt() + assertFalse(TvProfileLaunchPreferences(prefs) { now }.requiresSelectionAtLaunch()) + } +} + +/** Just enough SharedPreferences for the store: strings and longs. */ +private class InMemoryPrefs : SharedPreferences { + private val values = mutableMapOf() + + override fun getAll(): MutableMap = values.toMutableMap() + override fun getString(key: String?, defValue: String?): String? = values[key] as String? ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = defValues + override fun getInt(key: String?, defValue: Int): Int = defValue + override fun getLong(key: String?, defValue: Long): Long = values[key] as Long? ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = defValue + override fun contains(key: String?): Boolean = key in values + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private val removed = mutableSetOf() + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removed += key + return this + } + + override fun putStringSet(key: String, values: MutableSet?): SharedPreferences.Editor = this + override fun putInt(key: String, value: Int): SharedPreferences.Editor = this + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = this + override fun clear(): SharedPreferences.Editor = this + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + removed.forEach(values::remove) + values.putAll(pending) + } + } +} From cad4d0b455a5fdab804b86ea93750c5dc36d96e7 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:01:33 -0400 Subject: [PATCH 60/87] fix(libraries): leave hidden libraries out of navigation on phone and TV (#517) The phone and TV apps now read the profile's ui.disabled_library_ids and leave those libraries out of navigation and library pickers, picking up changes from other devices on foreground and reconnect. Closes #515 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../silo/common/di/PlayerInfraModule.kt | 1 + .../settings/ServerDrivenConfigRefresher.kt | 3 + .../data/sync/MembershipActivationTest.kt | 1 + .../ui/screens/libraries/LibrariesScreen.kt | 25 +- .../siloserver/silo/tv/di/AndroidTvModule.kt | 7 +- .../ui/screens/libraries/TvLibrariesScreen.kt | 6 + .../screens/libraries/TvLibrariesViewModel.kt | 80 ++++++- .../silo/tv/ui/shell/TvMainShell.kt | 35 ++- .../siloserver/silo/di/RepositoryModule.kt | 2 + .../silo/repository/HiddenLibrariesStore.kt | 116 +++++++++ .../silo/repository/PersonalDataRepository.kt | 79 ++++-- .../repository/HiddenLibrariesStoreTest.kt | 224 ++++++++++++++++++ .../PersonalDataRepositoryCacheTest.kt | 4 +- 13 files changed, 545 insertions(+), 38 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/HiddenLibrariesStore.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt index ba59f4d411..e2e1a7604a 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt @@ -227,6 +227,7 @@ val playerInfraModule = module { playerSettingsStore = get(), seekIntervalStore = get(), titleArtStore = get(), + hiddenLibrariesStore = get(), hasAuthenticatedProfile = { !get().getActiveProfileId().isNullOrBlank() }, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt index 108f5da4dd..456bbcfb85 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.common.settings import android.os.SystemClock +import org.siloserver.silo.repository.HiddenLibrariesStore /** * Best-effort refresh coordinator for server-owned client configuration. @@ -18,6 +19,7 @@ class ServerDrivenConfigRefresher( private val hasAuthenticatedProfile: suspend () -> Boolean, private val seekIntervalStore: SeekIntervalStore? = null, private val titleArtStore: TitleArtStore? = null, + private val hiddenLibrariesStore: HiddenLibrariesStore? = null, private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) { @Volatile @@ -42,6 +44,7 @@ class ServerDrivenConfigRefresher( playerSettingsStore.refreshFromServer() seekIntervalStore?.refresh() titleArtStore?.refresh() + hiddenLibrariesStore?.refresh() lastRefreshAtMs = now return true } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt index 73a0b99bb9..69a305f9d5 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt @@ -56,6 +56,7 @@ class MembershipActivationTest { val app = org.koin.dsl.koinApplication(createEagerInstances = false) { modules(org.siloserver.silo.di.repositoryModule, org.koin.dsl.module { single { PersonalDataApi(client) } + single { SettingsApi(SettingsV2Api(client, tokens, ApiV2Gate.Unrestricted)) } single { port } single { barrier } }) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index 839e6c7240..e78e580396 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -235,6 +235,10 @@ class LibrariesViewModel( private var pendingContentReload = false private val pageSize = 42 + /** Bumps when the profile hides or shows a library on another device. */ + val hiddenLibrariesRevision: StateFlow = personalDataRepository.hiddenLibrariesRevision + private var seenHiddenLibrariesRevision = hiddenLibrariesRevision.value + init { playerSettingsStore?.showAudiobooksFlow ?.onEach { show -> @@ -247,6 +251,12 @@ class LibrariesViewModel( refresh() } + fun onHiddenLibrariesRevision(revision: Int) { + if (revision == seenHiddenLibrariesRevision) return + seenHiddenLibrariesRevision = revision + refreshLibraryList() + } + private fun isHiddenAudiobookLibrary(library: UserLibrary): Boolean = !showAudiobooks && library.type.trim().lowercase() in setOf("audiobook", "audiobooks") @@ -299,8 +309,13 @@ class LibrariesViewModel( personalDataRepository.recheckUserLibraries(known.mapTo(mutableSetOf()) { it.id }) } // A transient failure re-shows the known list, re-filtered so a Show - // Audiobooks change still applies. Auth failures fall through and clear it. - val result = if (fetched.canServeCache() && known.isNotEmpty()) ApiResult.Success(known) else fetched + // Audiobooks change or a newly hidden library still applies. Auth + // failures fall through and clear it. + val result = if (fetched.canServeCache() && known.isNotEmpty()) { + ApiResult.Success(personalDataRepository.withoutHidden(known)) + } else { + fetched + } when (result) { is ApiResult.Success -> { serverLibraries = result.data @@ -882,6 +897,12 @@ fun LibrariesScreen( lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + // A library hidden or shown on another device lands on foreground, which + // can be after this resume's re-check; re-check again when it does. + val hiddenLibrariesRevision by viewModel.hiddenLibrariesRevision.collectAsState() + LaunchedEffect(viewModel, hiddenLibrariesRevision) { + viewModel.onHiddenLibrariesRevision(hiddenLibrariesRevision) + } // Recommended tab scroll state — drives the chrome scrim opacity so the // header fades in its scrim once the user scrolls the rows underneath it. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 5eaaa56a01..c805e73313 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -474,7 +474,12 @@ val androidTvModule = module { }, ) } - viewModel { TvLibrariesViewModel(get(), get(), get()) } + viewModel { + TvLibrariesViewModel( + get(), get(), get(), + reachability = get().state, + ) + } viewModel { params -> TvLibraryDetailViewModel( sectionRepository = get(), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt index 95a7503ded..0025400385 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt @@ -2,6 +2,7 @@ package org.siloserver.silo.tv.ui.screens.libraries import androidx.compose.foundation.background import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -33,6 +34,11 @@ fun TvLibrariesScreen( val state by viewModel.uiState.collectAsState() val selectedLibrary = state.libraries.firstOrNull { it.id == state.selectedLibraryId } ?: state.libraries.firstOrNull() + // A library hidden or shown on another device, picked up on foreground. + val hiddenLibrariesRevision by viewModel.hiddenLibrariesRevision.collectAsState() + LaunchedEffect(viewModel, hiddenLibrariesRevision) { + viewModel.onHiddenLibrariesRevision(hiddenLibrariesRevision) + } when { state.isLoading && state.libraries.isEmpty() -> TvLoadingScreen( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt index 40444b08e5..0e3e56272c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt @@ -3,14 +3,21 @@ package org.siloserver.silo.tv.ui.screens.libraries import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.siloserver.silo.model.personal.UserLibrary +import org.siloserver.silo.common.network.ServerReachabilityState +import org.siloserver.silo.common.network.ServerReachabilityStatus import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.PersonalDataRepository import org.siloserver.silo.tv.data.preferences.LegacyTvPrefsMigration import org.siloserver.silo.tv.data.preferences.TvLibrarySelectionStore import org.siloserver.silo.tv.ui.util.visibleOnTv +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -23,6 +30,8 @@ class TvLibrariesViewModel( private val personalDataRepository: PersonalDataRepository, private val librarySelectionStore: TvLibrarySelectionStore, private val legacyTvPrefsMigration: LegacyTvPrefsMigration, + /** Server reachability; each reachable probe retries a failed load. */ + reachability: Flow = emptyFlow(), ) : ViewModel() { data class UiState( @@ -35,8 +44,41 @@ class TvLibrariesViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Bumps when the profile hides or shows a library on another device. */ + val hiddenLibrariesRevision: StateFlow = personalDataRepository.hiddenLibrariesRevision + private var seenHiddenLibrariesRevision = hiddenLibrariesRevision.value + + // The latest load replaces any still running, so an older answer can't + // land last. Declared before init, which starts the first load. + private var loadJob: Job? = null + // The last load failed and none is running. A revision that asked for a + // re-load won't come again, so the next reachable probe retries it. + private var reloadPending = false + // Only the first load may fall back to the offline cache; later ones + // re-check, so a failure stays pending instead of showing a stale list. + private var firstLoadAttempted = false + init { load() + reachability + .onEach { if (reloadPending && it.status == ServerReachabilityStatus.Reachable) load() } + .launchIn(viewModelScope) + } + + fun onHiddenLibrariesRevision(revision: Int) { + if (revision == seenHiddenLibrariesRevision) return + seenHiddenLibrariesRevision = revision + // Drop newly hidden libraries now, so a failed re-load can't keep them. + _uiState.update { state -> + val libraries = personalDataRepository.withoutHidden(state.libraries) + state.copy( + libraries = libraries, + selectedLibraryId = state.selectedLibraryId + ?.takeIf { id -> libraries.any { it.id == id } } + ?: libraries.firstOrNull()?.id, + ) + } + load() } fun onLibrarySelected(libraryId: Int) { @@ -48,7 +90,9 @@ class TvLibrariesViewModel( } fun load() { - viewModelScope.launch { + loadJob?.cancel() + reloadPending = false + loadJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } // Seed the per-profile selection from the legacy global // `tv_prefs` key BEFORE the first read — the resolve below @@ -56,7 +100,13 @@ class TvLibrariesViewModel( // Sentinel-gated no-op after the first run. legacyTvPrefsMigration.migrateIfNeeded() val storedLibraryId = librarySelectionStore.getSelectedLibraryId() - when (val result = personalDataRepository.listUserLibraries()) { + val result = if (firstLoadAttempted) { + personalDataRepository.recheckUserLibraries(_uiState.value.libraries.mapTo(mutableSetOf()) { it.id }) + } else { + personalDataRepository.listUserLibraries() + } + firstLoadAttempted = true + when (result) { is ApiResult.Success -> { val libraries = result.data .visibleOnTv() @@ -77,17 +127,23 @@ class TvLibrariesViewModel( ) } } - is ApiResult.Error -> _uiState.update { - it.copy( - isLoading = false, - error = result.message.ifBlank { "Failed to load libraries" }, - ) + is ApiResult.Error -> { + reloadPending = true + _uiState.update { + it.copy( + isLoading = false, + error = result.message.ifBlank { "Failed to load libraries" }, + ) + } } - is ApiResult.NetworkError -> _uiState.update { - it.copy( - isLoading = false, - error = "Network error: ${result.exception.message ?: "unknown"}", - ) + is ApiResult.NetworkError -> { + reloadPending = true + _uiState.update { + it.copy( + isLoading = false, + error = "Network error: ${result.exception.message ?: "unknown"}", + ) + } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt index b90459c0f2..c41f3f73b3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt @@ -244,15 +244,44 @@ fun TvMainShell( // `visibleRoots` is only Home + Calendar, so a restored/deep-linked // `main/movies` route must NOT be treated as "type has no libraries" yet. var librariesLoaded by remember { mutableStateOf(false) } + // The list leaves out libraries the profile hid; hiding or showing one + // on another device re-loads it. + val hiddenLibrariesRevision by personalDataRepository.hiddenLibrariesRevision.collectAsState() + // A failed load is retried on the next reachable probe: the revision that + // asked for it won't come again, and a library shown again on another + // device can only come back from the server. + var librariesReloadPending by remember { mutableStateOf(false) } + var librariesRetry by remember { mutableIntStateOf(0) } + LaunchedEffect(reachabilityState.status, reachabilityState.lastCheckedAtMs) { + if (librariesReloadPending && reachabilityState.status == ServerReachabilityStatus.Reachable) librariesRetry++ + } val libraries by produceState( initialValue = emptyList(), personalDataRepository, + hiddenLibrariesRevision, + librariesRetry, ) { - when (val result = personalDataRepository.listUserLibraries()) { - is ApiResult.Success -> + // Pending means the last load failed and none is running, so a probe + // never cancels a load still in flight. + librariesReloadPending = false + // Only the first load may fall back to the offline cache. A later one + // re-checks, so a failure stays a failure and stays pending, rather + // than a cached list that misses a library shown again since. + val result = if (librariesLoaded) { + personalDataRepository.recheckUserLibraries(value.mapTo(mutableSetOf()) { it.id }) + } else { + personalDataRepository.listUserLibraries() + } + when (result) { + is ApiResult.Success -> { value = result.data.visibleOnTv().sortedBy { it.sortOrder } + } + // Keep what's shown, minus a library hidden since. is ApiResult.Error, - is ApiResult.NetworkError -> Unit + is ApiResult.NetworkError -> { + value = personalDataRepository.withoutHidden(value) + librariesReloadPending = true + } } // Mark loaded even on error (we've attempted) so the redirect can run; // an empty list then legitimately means "no libraries for this profile". diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index 05642ada86..ac80338c1b 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -84,8 +84,10 @@ val repositoryModule = module { ?: org.siloserver.silo.repository.port.NoOpUserItemStatePort, catalogCache = getOrNull() ?: org.siloserver.silo.repository.port.NoOpCatalogCachePort, + hiddenLibraries = get(), ) } + single { org.siloserver.silo.repository.HiddenLibrariesStore(get(), get()) } single { ProfileRepository(get(), get(), getOrNull(), get(), get(), get()) } single { CollectionRepository(get()) } single { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HiddenLibrariesStore.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HiddenLibrariesStore.kt new file mode 100644 index 0000000000..58a6bb8fdf --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HiddenLibrariesStore.kt @@ -0,0 +1,116 @@ +package org.siloserver.silo.repository + +import kotlin.concurrent.Volatile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import org.siloserver.silo.model.personal.UserLibrary +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.SiloJson + +/** + * The libraries the active profile hid from its own navigation + * (`ui.disabled_library_ids`, the web's Settings → Libraries → "Visible in + * navigation"). A preference, not access control: for a profile without a + * library limit the server still lists these libraries in + * `GET /user/libraries`, so the client leaves them out the way the web does. + * + * The set is bound to the identity generation it was read under: after a + * profile or server switch nothing reads as hidden until the new profile's + * value arrives, and a read that straddles a switch is dropped. Reads run one + * at a time, so an older answer never lands over a newer one. + */ +class HiddenLibrariesStore( + private val settingsRepository: SettingsRepository, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), +) { + private data class Snapshot(val generation: Long, val ids: Set) + + @Volatile + private var snapshot = Snapshot(generation = -1, ids = emptySet()) + private val readMutex = Mutex() + private var failedReadGeneration = -1L + private val _revision = MutableStateFlow(0) + + /** + * Bumps when the set changes, so screens that loaded the library list + * re-load it. A first read made by a list load ([ensureLoaded]) doesn't + * bump it, since that load applies the result, unless an earlier read for + * this profile failed and lists went out unfiltered. + */ + val revision: StateFlow = _revision.asStateFlow() + + fun current(): Set = snapshot.idsFor(identityTransitions.generation.value) + + /** Reads the setting unless this profile's value is already known. */ + suspend fun ensureLoaded() { + if (snapshot.generation != identityTransitions.generation.value) read(announce = false) + } + + /** Re-reads the setting. A failed read keeps the last value read for this profile. */ + suspend fun refresh() = read(announce = true) + + private suspend fun read(announce: Boolean) = readMutex.withLock { + val generation = identityTransitions.generation.value + // A read that finished while this one waited already answered it. + if (!announce && snapshot.generation == generation) return@withLock + // A switch advances the generation before it installs the new + // identity; wait it out so this read can't answer for the old one. + identityTransitions.withCurrentGeneration(generation) { } ?: return@withLock + val result = settingsRepository.getEffectiveValues(listOf(KEY)) + if (generation != identityTransitions.generation.value) return@withLock + if (result !is ApiResult.Success) { + failedReadGeneration = generation + return@withLock + } + val previous = snapshot.idsFor(generation) + val ids = parseLibraryIds(result.data[KEY]?.value) + snapshot = Snapshot(generation, ids) + val listsWentOutUnfiltered = failedReadGeneration == generation + if ((announce || listsWentOutUnfiltered) && ids != previous) _revision.update { it + 1 } + } + + private fun Snapshot.idsFor(generation: Long): Set = + if (this.generation == generation) ids else emptySet() + + companion object { + const val KEY = SettingKeys.UI_DISABLED_LIBRARY_IDS + + /** + * Same rules as the web's `parseLibraryIDList`: the canonical array, + * the legacy JSON-string encoding of one, or null. Entries that are not + * positive whole numbers are dropped. + */ + fun parseLibraryIds(value: JsonElement?): Set { + val array = when (value) { + is JsonArray -> value + is JsonPrimitive -> if (value.isString && value.content.isNotBlank()) { + runCatching { SiloJson.parseToJsonElement(value.content) }.getOrNull() as? JsonArray + } else { + null + } + else -> null + } ?: return emptySet() + return array.mapNotNullTo(linkedSetOf()) { entry -> + (entry as? JsonPrimitive) + ?.takeUnless { it.isString } + ?.doubleOrNull + ?.takeIf { it >= 1 && it <= Int.MAX_VALUE && it % 1.0 == 0.0 } + ?.toInt() + } + } + } +} + +fun List.withoutHidden(hiddenIds: Set): List = + if (hiddenIds.isEmpty()) this else filterNot { it.id in hiddenIds } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt index b1c2368624..0b85172fd2 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt @@ -1,5 +1,9 @@ package org.siloserver.silo.repository +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.siloserver.silo.model.personal.SyncProgressItem @@ -31,23 +35,30 @@ open class PersonalDataRepository( private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), membershipPort: org.siloserver.silo.repository.port.MembershipPort? = null, + /** The profile's hidden libraries, left out of every library list below. Null lists all. */ + private val hiddenLibraries: HiddenLibrariesStore? = null, ) { val memberships = MembershipActions(membershipPort, identityTransitions) // -- Libraries -- - /** Lists the libraries visible to the current user (offline: last cached list). */ + /** + * Changes when the profile hides or shows a library on another device + * (picked up on foreground and reconnect). Screens holding a library list + * re-load it then. + */ + val hiddenLibrariesRevision: StateFlow = hiddenLibraries?.revision ?: MutableStateFlow(0) + + /** + * Lists the libraries visible to the current user, minus the ones the + * profile hid (offline: last cached list). + */ suspend fun listUserLibraries(): ApiResult> { val requestIdentityGeneration = identityTransitions.generation.value - val result = personalDataApi.listUserLibraries() - if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> - catalogCache.cacheLibraries(result.data, cacheWriteLease) - } - return result - } + val result = fetchUserLibraries(requestIdentityGeneration) { personalDataApi.listUserLibraries() } + if (result is ApiResult.Success) return publishLibraries(result.data, requestIdentityGeneration) if (result.canServeCache()) { - catalogCache.getCachedLibraries()?.let { return ApiResult.Success(it) } + catalogCache.getCachedLibraries()?.let { return ApiResult.Success(withoutHidden(it)) } } return result } @@ -63,20 +74,52 @@ open class PersonalDataRepository( */ suspend fun recheckUserLibraries(knownIds: Set): ApiResult> { val requestIdentityGeneration = identityTransitions.generation.value - val first = personalDataApi.listUserLibraries() - val result = if (first is ApiResult.Success && !first.data.map { it.id }.containsAll(knownIds)) { - personalDataApi.listUserLibraries() - } else { - first - } - if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> - catalogCache.cacheLibraries(result.data, cacheWriteLease) + // Known ids are checked against the full list: a library the profile + // just hid is a deliberate removal, not a short response. + val result = fetchUserLibraries(requestIdentityGeneration) { + val first = personalDataApi.listUserLibraries() + if (first is ApiResult.Success && !first.data.map { it.id }.containsAll(knownIds)) { + personalDataApi.listUserLibraries() + } else { + first } } + if (result is ApiResult.Success) return publishLibraries(result.data, requestIdentityGeneration) return result } + /** + * Runs [fetch] while the profile's hidden libraries load beside it, the + * first time only. A switch while either is in flight answers + * `identity_changed`, so the old profile's list never comes back. + */ + private suspend fun fetchUserLibraries( + requestIdentityGeneration: Long, + fetch: suspend () -> ApiResult>, + ): ApiResult> = coroutineScope { + val hiddenRead = launch { hiddenLibraries?.ensureLoaded() } + val result = fetch() + hiddenRead.join() + if (requestIdentityGeneration != identityTransitions.generation.value) identityChanged() else result + } + + /** Drops hidden libraries and caches what is shown, unless the profile changed meanwhile. */ + private suspend fun publishLibraries( + libraries: List, + requestIdentityGeneration: Long, + ): ApiResult> { + val visible = withoutHidden(libraries) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheLibraries(visible, cacheWriteLease) + } + if (requestIdentityGeneration != identityTransitions.generation.value) return identityChanged() + return ApiResult.Success(visible) + } + + /** [libraries] minus the ones the profile hid, for a list a screen already holds. */ + fun withoutHidden(libraries: List): List = + libraries.withoutHidden(hiddenLibraries?.current().orEmpty()) + // -- Favorites -- suspend fun isFavorite(itemId: String): ApiResult = diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt new file mode 100644 index 0000000000..d6d1b8eda4 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt @@ -0,0 +1,224 @@ +package org.siloserver.silo.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 kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.add +import org.siloserver.silo.model.personal.UserLibrary +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.EffectiveSettingValuesResponse +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionKind +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.api.PersonalDataApi +import org.siloserver.silo.network.api.SettingsApi +import org.siloserver.silo.repository.port.CatalogCachePort +import kotlin.test.Test +import kotlin.test.assertEquals + +class HiddenLibrariesStoreTest { + + private class FakeSettings(var hidden: JsonElement = JsonNull) : SettingsApi( + org.siloserver.silo.network.apiv2.SettingsV2Api( + HttpClient(), + org.siloserver.silo.network.TokenManagerImpl(), + org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted, + ), + ) { + var beforeAnswer: suspend () -> Unit = {} + var fails = false + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + authority: AuthScopeSnapshot?, + ): ApiResult { + beforeAnswer() + if (fails) return ApiResult.Error(503, "unavailable", "Unavailable") + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf(EffectiveSettingValue(key = HiddenLibrariesStore.KEY, value = hidden)), + ), + ) + } + } + + private class FakeCache : CatalogCachePort { + var cachedLibraries: List? = null + + override suspend fun cacheLibraries(libraries: List) { + cachedLibraries = libraries + } + } + + private var libraryRequests = 0 + + private fun librariesClient() = HttpClient( + MockEngine { + libraryRequests++ + respond( + """{"items":[{"id":"1","name":"Movies","type":"movies","sort_order":1},""" + + """{"id":"2","name":"TV Shows","type":"series","sort_order":2}]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { install(ContentNegotiation) { json(SiloJson) } } + + private fun ids(vararg values: Int) = buildJsonArray { values.forEach { add(it) } } + + @Test + fun parsesTheWebsAcceptedEncodings() { + assertEquals(setOf(2, 5), HiddenLibrariesStore.parseLibraryIds(ids(2, 5, 2))) + assertEquals(setOf(3), HiddenLibrariesStore.parseLibraryIds(JsonPrimitive("[3]"))) + assertEquals(emptySet(), HiddenLibrariesStore.parseLibraryIds(JsonNull)) + assertEquals(emptySet(), HiddenLibrariesStore.parseLibraryIds(null)) + assertEquals(emptySet(), HiddenLibrariesStore.parseLibraryIds(JsonPrimitive("not json"))) + val mixed = buildJsonArray { add("4"); add(0); add(-1); add(1.5); add(7); add(8.0) } + assertEquals(setOf(7, 8), HiddenLibrariesStore.parseLibraryIds(mixed)) + } + + @Test + fun libraryListLeavesOutHiddenLibrariesAndCachesWhatIsShown() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val cache = FakeCache() + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + catalogCache = cache, + identityTransitions = barrier, + hiddenLibraries = HiddenLibrariesStore(SettingsRepository(FakeSettings(ids(2))), barrier), + ) + + val result = repository.listUserLibraries() + + assertEquals(listOf(1), (result as ApiResult.Success).data.map { it.id }) + assertEquals(listOf(1), cache.cachedLibraries?.map { it.id }) + } + + @Test + fun recheckTreatsANewlyHiddenLibraryAsRemovedWithoutReadingTwice() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings() + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + identityTransitions = barrier, + hiddenLibraries = store, + ) + assertEquals(listOf(1, 2), (repository.listUserLibraries() as ApiResult.Success).data.map { it.id }) + + settings.hidden = ids(2) + store.refresh() + libraryRequests = 0 + val rechecked = repository.recheckUserLibraries(knownIds = setOf(1, 2)) + + assertEquals(listOf(1), (rechecked as ApiResult.Success).data.map { it.id }) + assertEquals(1, libraryRequests) + } + + @Test + fun aSwitchWhileTheHiddenSetLoadsAnswersIdentityChanged() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + settings.beforeAnswer = { barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { } } + val cache = FakeCache() + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + catalogCache = cache, + identityTransitions = barrier, + hiddenLibraries = HiddenLibrariesStore(SettingsRepository(settings), barrier), + ) + + val result = repository.listUserLibraries() + + assertEquals("identity_changed", (result as ApiResult.Error).error) + assertEquals(null, cache.cachedLibraries) + } + + @Test + fun onlyAChangeFoundByARefreshBumpsTheRevision() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + + store.ensureLoaded() + store.refresh() + assertEquals(0, store.revision.value) + + settings.hidden = ids() + store.ensureLoaded() + assertEquals(setOf(2), store.current()) + store.refresh() + assertEquals(emptySet(), store.current()) + assertEquals(1, store.revision.value) + } + + @Test + fun aSuccessfulReadAfterAFailedOneBumpsTheRevision() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)).apply { fails = true } + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + + store.ensureLoaded() + assertEquals(emptySet(), store.current()) + + settings.fails = false + store.ensureLoaded() + assertEquals(setOf(2), store.current()) + assertEquals(1, store.revision.value) + } + + @Test + fun aReadStartedMidSwitchAnswersForTheNewIdentity() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + + lateinit var read: kotlinx.coroutines.Deferred + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + // The generation has advanced; the new identity isn't installed yet. + read = async { store.ensureLoaded() } + testScheduler.runCurrent() + settings.hidden = ids(1) + } + read.await() + + assertEquals(setOf(1), store.current()) + } + + @Test + fun aReadThatStraddlesAProfileSwitchIsDropped() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val settings = FakeSettings(ids(2)) + val store = HiddenLibrariesStore(SettingsRepository(settings), barrier) + store.refresh() + assertEquals(setOf(2), store.current()) + + val entered = CompletableDeferred() + val release = CompletableDeferred() + settings.beforeAnswer = { entered.complete(Unit); release.await() } + settings.hidden = ids(1) + val read = async { store.refresh() } + entered.await() + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + release.complete(Unit) + read.await() + + assertEquals(emptySet(), store.current()) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt index c06214e881..54dd6c6645 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt @@ -20,7 +20,6 @@ import org.siloserver.silo.network.api.PersonalDataApi import org.siloserver.silo.repository.port.CatalogCachePort import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue class PersonalDataRepositoryCacheTest { @@ -62,7 +61,8 @@ class PersonalDataRepositoryCacheTest { identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } releaseResponse.complete(Unit) - assertTrue(oldProfileRequest.await() is ApiResult.Success) + // The old profile's list doesn't come back for the new profile either. + assertEquals("identity_changed", (oldProfileRequest.await() as ApiResult.Error).error) assertEquals(null, cache.cachedLibraries) } } From ab86a85baa2c02c9c556c622fa648cc57497c154 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:16:17 -0400 Subject: [PATCH 61/87] chore(issues): apply the feature-request label from the feature request form (#519) The community bot gives open issues labeled feature-request and triaged a post in its Discord forum. The form only applied enhancement, which maintainers also use for their own work, so requests filed through it never qualified. Co-authored-by: Claude Opus 5.5 (1M context) --- .github/ISSUE_TEMPLATE/feature-request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 0780feb8f3..869f51e3fb 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,7 +1,7 @@ name: Feature request description: Suggest an improvement to the Android phone or TV app title: "[feature] " -labels: ["enhancement"] +labels: ["enhancement", "feature-request"] body: - type: markdown attributes: From 88e07006bfa21b007cf89b0249731fb625b8a7b7 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:42:17 -0400 Subject: [PATCH 62/87] fix(subtitles): prefer embedded tracks over external sidecars (#529) Auto started on an external sidecar whenever the file also carried an embedded track in the same language, and external sidecars are the ones that drift out of sync. The TV detail preview and the TV player fallback could also disagree, because each ranked its own inventory in its own order. Rank by the same tiers as the server and web player (silo-server #1849): a track the device renders itself beats one that needs a burn-in, full dialogue beats forced and plain beats SDH, then embedded beats external beats downloaded. Burn-in comes from the playback identity (the server's burn_in_only delivery) for session rows and from the codec and source for catalog rows, so an embedded PGS track no longer loses to an external SRT just for being a bitmap. The phone ranks with the same order and keeps its own show-forced step. Co-authored-by: Claude Opus 5.5 --- .../player/MobileSubtitleAutoSelection.kt | 61 ++++--- .../player/MobileSubtitleAutoSelectionTest.kt | 65 ++++++++ .../tv/ui/screens/player/TvPlayerViewModel.kt | 5 + .../screens/player/PlayerTrackEntriesTest.kt | 8 +- .../player/TvAutoSubtitleFallbackTest.kt | 61 ++++++- .../model/playback/AutoSubtitleResolver.kt | 117 +++++++++---- .../playback/AutoSubtitleResolverTest.kt | 157 +++++++++++++++++- 7 files changed, 416 insertions(+), 58 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelection.kt index 8b3fa06780..e4843f94ec 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelection.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelection.kt @@ -2,8 +2,11 @@ package org.siloserver.silo.android.ui.screens.player import org.siloserver.silo.model.catalog.AudioTrack import org.siloserver.silo.model.catalog.SubtitleTrack +import org.siloserver.silo.model.playback.AutoSubtitleCandidate import org.siloserver.silo.model.playback.PlayerSubtitleInfo import org.siloserver.silo.model.playback.SubtitleIdentity +import org.siloserver.silo.model.playback.autoSubtitlePreferenceOrder +import org.siloserver.silo.model.playback.toAutoSubtitleCandidate import org.siloserver.silo.playback.canonicalSubtitleLanguage import org.siloserver.silo.playback.hasPositiveSubtitleDiscriminator import org.siloserver.silo.playback.isBitmapSubtitleCodecFamily @@ -131,7 +134,7 @@ internal fun resolveMobileAutoSubtitleSelection( targetLanguage = targetLanguage, preferForced = showForcedSubtitles, ) ?: if (showForcedSubtitles) { - subtitles.indexOfFirst { it.forced == true }.takeIf { it >= 0 } + bestRankedOrdinal(subtitles.withIndex().filter { (_, subtitle) -> subtitle.forced == true }) } else { null } @@ -215,6 +218,11 @@ private fun normalizedSubtitleCodec(codecOrMime: String?): String? { } } +/** + * Ranks with the shared [autoSubtitlePreferenceOrder] — burn-in, then + * forced/SDH, then embedded over external over downloaded — so the mounted + * order (externals first) no longer decides a tie. + */ private fun bestAutoSubtitleOrdinal( subtitles: List, targetLanguage: String?, @@ -226,37 +234,44 @@ private fun bestAutoSubtitleOrdinal( if (pool.isEmpty()) return null if (preferForced) { - pool.firstOrNull { (_, subtitle) -> - subtitle.forced == true && !subtitle.isEffectivelyHearingImpaired() && !subtitle.isBitmap() - }?.let { return it.index } + // Only a plain TEXT forced track jumps the queue here, as before: this + // step predates the shared "forced never outranks full dialogue" rule, + // and widening it to every renderable bitmap would let a forced PGS + // track displace the full one. + bestRankedOrdinal( + pool.filter { (_, subtitle) -> + subtitle.forced == true && + !subtitle.isEffectivelyHearingImpaired() && + !subtitle.isBitmap() + }, + )?.let { return it } } - pool.firstOrNull { (_, subtitle) -> - subtitle.forced != true && !subtitle.isEffectivelyHearingImpaired() && !subtitle.isBitmap() - }?.let { return it.index } - pool.firstOrNull { (_, subtitle) -> - subtitle.forced != true && !subtitle.isBitmap() - }?.let { return it.index } - pool.firstOrNull { (_, subtitle) -> !subtitle.isBitmap() } - ?.let { return it.index } - return pool.first().index + return bestRankedOrdinal(pool) } private fun bestForcedAutoSubtitleOrdinal( subtitles: List, targetLanguage: String?, -): Int? { - val pool = subtitles.withIndex().filter { (_, subtitle) -> +): Int? = bestRankedOrdinal( + subtitles.withIndex().filter { (_, subtitle) -> subtitle.forced == true && (targetLanguage == null || canonicalSubtitleLanguage(subtitle.language) == targetLanguage) - } - if (pool.isEmpty()) return null + }, +) - pool.firstOrNull { (_, subtitle) -> !subtitle.isEffectivelyHearingImpaired() && !subtitle.isBitmap() } - ?.let { return it.index } - pool.firstOrNull { (_, subtitle) -> !subtitle.isEffectivelyHearingImpaired() } - ?.let { return it.index } - return pool.first().index -} +/** The ordinal of the best-ranked row, or null for an empty pool. */ +private fun bestRankedOrdinal(pool: List>): Int? = + pool + .map { (ordinal, subtitle) -> ordinal to subtitle.toMobileAutoSubtitleCandidate() } + .minWithOrNull(compareBy(autoSubtitlePreferenceOrder) { it.second }) + ?.first + +/** + * The phone's SDH predicate reads the runtime label, source and URL rather + * than the catalog title, so it replaces the shared title check. + */ +private fun PlayerSubtitleInfo.toMobileAutoSubtitleCandidate(): AutoSubtitleCandidate = + toAutoSubtitleCandidate().copy(title = label, hearingImpaired = isEffectivelyHearingImpaired()) private fun PlayerSubtitleInfo.isEffectivelyHearingImpaired(): Boolean = subtitleLabelIndicatesHearingImpaired(label) || diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt index 501e9f3b3e..9fc8f6e6a7 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt @@ -518,6 +518,71 @@ class MobileSubtitleAutoSelectionTest { ) } + @Test + fun autoSubtitlePreferenceTakesTheEmbeddedTrackOverAnExternalOneInTheSameLanguage() { + // The session lists externals first; the file's own track still wins + // the tie, because external sidecars are the ones that drift. + val subtitles = listOf( + subtitle(index = 0, label = "English", language = "en").copy(source = "external", catalogSource = "external"), + subtitle(index = 3, label = "English", language = "en", codec = "subrip") + .copy(source = "embedded", catalogSource = "embedded"), + ) + + assertEquals( + MobileSubtitleAutoSelection.Select(1), + resolveMobileAutoSubtitleSelection( + audioTracks = listOf(audio(language = "ja")), + selectedAudioIndex = 0, + subtitles = subtitles, + preferredLanguage = "en", + subtitleMode = "auto", + showForcedSubtitles = false, + ), + ) + } + + @Test + fun autoSubtitlePreferenceTakesARenderableEmbeddedPgsTrackOverAnExternalSrt() { + val subtitles = listOf( + subtitle(index = 0, label = "English", language = "en").copy(source = "external", catalogSource = "external"), + subtitle(index = 3, label = "English", language = "en", codec = "hdmv_pgs_subtitle") + .copy(source = "embedded", catalogSource = "embedded", url = ""), + ) + + assertEquals( + MobileSubtitleAutoSelection.Select(1), + resolveMobileAutoSubtitleSelection( + audioTracks = listOf(audio(language = "ja")), + selectedAudioIndex = 0, + subtitles = subtitles, + preferredLanguage = "en", + subtitleMode = "always", + showForcedSubtitles = false, + ), + ) + } + + @Test + fun autoSubtitlePreferenceAvoidsAnEmbeddedTrackThatNeedsABurnIn() { + val subtitles = listOf( + subtitle(index = 0, label = "English", language = "en").copy(source = "external", catalogSource = "external"), + subtitle(index = 3, label = "English", language = "en", codec = "dvd_subtitle") + .copy(source = "embedded", catalogSource = "embedded", url = ""), + ) + + assertEquals( + MobileSubtitleAutoSelection.Select(0), + resolveMobileAutoSubtitleSelection( + audioTracks = listOf(audio(language = "ja")), + selectedAudioIndex = 0, + subtitles = subtitles, + preferredLanguage = "en", + subtitleMode = "always", + showForcedSubtitles = false, + ), + ) + } + @Test fun autoSubtitleResolverDisablesWhenAudioAlreadyMatchesPreferredLanguage() { assertEquals( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 0defc219d4..d5d844802d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -520,6 +520,10 @@ internal fun resolveTvAutoSubtitleIdentity( * * Hearing-impaired travels as an explicit signal (role flags and both labels), * which the catalog cannot supply and the shared predicate ORs with the title. + * Nothing here needs a burn-in: every track is already in the player, Media3 + * decodes the bitmap families itself, and with no server inventory there is + * no burn-in route anyway. Media3 does not say where a track came from, so + * source stays unknown and ties keep the mounted order. */ internal fun playerTrackAutoSubtitleCandidates( subtitleTracks: List, @@ -530,6 +534,7 @@ internal fun playerTrackAutoSubtitleCandidates( codec = track.codecOrMime, forced = track.isForced, hearingImpaired = track.isEffectivelyHearingImpaired(), + needsBurnIn = false, ) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt index 2518e37042..cc74771f7b 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt @@ -88,7 +88,11 @@ class PlayerTrackEntriesTest { } @Test - fun autoSubtitlePreferenceMovesSelectedPgsToMatchingTextSidecar() { + fun autoSubtitlePreferenceKeepsTheMountedPgsTrackOverAnEquallySdhTextSidecar() { + // Both tracks are SDH (the sidecar's file name says so). This used to + // move to the sidecar only because PGS was demoted as a bitmap, but + // Media3 renders the mounted PGS track itself and no burn-in is + // involved, so the tie stays on the file's own track. val tracks = listOf( PlayerTrackEntry( index = 2, @@ -106,7 +110,7 @@ class PlayerTrackEntriesTest { ), ) - assertEquals(6, autoPick(tracks)) + assertEquals(2, autoPick(tracks)) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt index 6d84a5cafd..6785ddae88 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt @@ -52,7 +52,9 @@ class TvAutoSubtitleFallbackTest { ) @Test - fun theFallbackPrefersAnUnmountedExternalTextTrackOverTheMountedBitmapOne() { + fun theFallbackPrefersAnUnmountedPlainExternalTrackOverTheMountedSdhOne() { + // The SRT wins because the mounted PGS track is SDH. Being a bitmap + // alone no longer demotes it: see the plain-PGS case below. val identity = resolveTvAutoSubtitleIdentity( audioTracks = emptyList(), subtitleTracks = mountedPgsOnly, @@ -65,6 +67,63 @@ class TvAutoSubtitleFallbackTest { assertEquals(tvSubtitleIdentity(sidecarRow), identity) } + @Test + fun theFallbackPrefersAPlainEmbeddedPgsTrackOverAnExternalSrt() { + // Android renders embedded PGS itself, so with nothing else to tell + // them apart the file's own track beats the sidecar. + val plainPgs = pgsRow.copy(label = "English", catalogLabel = "English") + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = mountedPgsOnly, + subtitleRows = listOf(sidecarRow, plainPgs), + preferredLanguage = "en", + subtitleMode = "always", + showForced = true, + ) + + assertEquals(tvSubtitleIdentity(plainPgs), identity) + } + + @Test + fun theFallbackPrefersAnEmbeddedTextTrackOverAnExternalOne() { + // The inventory lists externals first; source, not that order, decides. + val embeddedText = PlayerSubtitleInfo( + index = 1, + language = "eng", + codec = "subrip", + label = "English", + source = "embedded", + url = "https://silo.example/stream/s1/subtitles/1.vtt", + catalogLabel = "English", + catalogSource = "embedded", + ) + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = emptyList(), + subtitleRows = listOf(sidecarRow, embeddedText), + preferredLanguage = "en", + subtitleMode = "always", + showForced = false, + ) + + assertEquals(tvSubtitleIdentity(embeddedText), identity) + } + + @Test + fun theFallbackPrefersAnExternalSrtOverAnEmbeddedTrackThatNeedsABurnIn() { + val vobSub = pgsRow.copy(codec = "dvd_subtitle", label = "English", catalogLabel = "English") + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = emptyList(), + subtitleRows = listOf(sidecarRow, vobSub), + preferredLanguage = "en", + subtitleMode = "always", + showForced = false, + ) + + assertEquals(tvSubtitleIdentity(sidecarRow), identity) + } + @Test fun theFallbackStillTakesTheBitmapTrackWhenItIsTheOnlyCandidate() { val identity = resolveTvAutoSubtitleIdentity( diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolver.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolver.kt index 331f9ca335..5dee2c3962 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolver.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolver.kt @@ -2,6 +2,8 @@ package org.siloserver.silo.model.playback import org.siloserver.silo.model.catalog.SubtitleTrack import org.siloserver.silo.playback.isBitmapSubtitleCodecFamily +import org.siloserver.silo.playback.isClientMountableBitmapCodecFamily +import org.siloserver.silo.playback.playbackSubtitleIdentity import org.siloserver.silo.playback.subtitleLabelIndicatesHearingImpaired /** @@ -16,10 +18,11 @@ import org.siloserver.silo.playback.subtitleLabelIndicatesHearingImpaired * * The DETAIL PAGE's semantics are the reference behaviour — its ordering and * cascade are what the viewer sees and what QA signed off (tvOS parity, QA - * 2026-07-09). Candidates are supplied in the caller's own iteration order and - * carry a [AutoSubtitleCandidate.selectionIndex] in the server's COMBINED - * selection space, so the winner can be handed straight to a playback start - * request. + * 2026-07-09). Candidates carry a [AutoSubtitleCandidate.selectionIndex] in the + * server's COMBINED selection space, so the winner can be handed straight to a + * playback start request, and their own [AutoSubtitleCandidate.source], so the + * caller's iteration order (catalog: embedded first; inventory: externals + * first) never decides between two otherwise equal tracks. */ data class AutoSubtitleCandidate( /** @@ -40,8 +43,19 @@ data class AutoSubtitleCandidate( * it, because the catalog only ever says SDH in the title. */ val hearingImpaired: Boolean = false, + /** Where the track lives. `null` (unknown) ranks with embedded, as on the web player. */ + val source: AutoSubtitleSource? = null, + /** + * Whether showing this track makes the server burn it into the picture for + * THIS device. `null` when the caller cannot tell, which falls back to + * treating every bitmap track as a burn-in. + */ + val needsBurnIn: Boolean? = null, ) +/** Track provenance, in auto-selection preference order. */ +enum class AutoSubtitleSource { EMBEDDED, EXTERNAL, DOWNLOADED } + /** Cascaded preference inputs. Same shape on every surface. */ data class AutoSubtitleContext( /** Cascaded `subtitle_language`. `null` = no preference; empty = "no subs". */ @@ -78,9 +92,9 @@ fun AutoSubtitleResolution.selectedCandidate(): AutoSubtitleCandidate? = * subs are enabled; otherwise the best track in the preferred language, falling * back to any forced track when forced subs are enabled. * - * Within a pool: full-dialogue text → non-forced text → any text → first. - * Bitmap tracks stay DEPRIORITISED, never excluded: a bitmap track that is the - * only candidate still wins. + * Within a pool, [autoSubtitlePreferenceOrder] ranks the tracks; a track that + * needs a burn-in is DEPRIORITISED, never excluded, so it still wins when it is + * the only candidate. * * "Show forced subtitles" is a SEPARATE setting and never outranks the * viewer's full-subtitle preference: when subtitles are wanted (mode `always`, @@ -125,10 +139,32 @@ fun resolveAutoSubtitle( } val target = bestAutoSubtitleCandidate(candidates, targetLanguage) - ?: if (context.showForced) candidates.firstOrNull { it.forced } else null + ?: if (context.showForced) { + candidates.filter { it.forced }.minWithOrNull(autoSubtitlePreferenceOrder) + } else { + null + } return target?.let(AutoSubtitleResolution::Select) ?: AutoSubtitleResolution.NoChange } +/** + * Preference order between tracks that already match the wanted language. + * Each tier only breaks ties in the one before it (server/web parity, + * silo-server #1849): + * 1. a track this device renders itself beats one the server must burn in; + * 2. full dialogue beats forced, and plain beats SDH, so a file's own forced + * or SDH track never displaces the full track the viewer asked for; + * 3. embedded beats external beats downloaded. External sidecars are the ones + * that drift out of sync. + * Use with [minWithOrNull], which keeps the caller's order for full ties. + */ +val autoSubtitlePreferenceOrder: Comparator = compareBy( + { it.requiresBurnIn() }, + { it.forced }, + { it.isHearingImpaired() }, + { it.source?.ordinal ?: 0 }, +) + private fun bestAutoSubtitleCandidate( candidates: List, targetLanguage: String?, @@ -138,34 +174,29 @@ private fun bestAutoSubtitleCandidate( } else { candidates.filter { autoSubtitleLanguageKey(it.language) == targetLanguage } } - if (pool.isEmpty()) return null - - pool.firstOrNull { !it.forced && !it.isHearingImpaired() && !it.isBitmap() }?.let { return it } - pool.firstOrNull { !it.forced && !it.isBitmap() }?.let { return it } - pool.firstOrNull { !it.isBitmap() }?.let { return it } - return pool.first() + return pool.minWithOrNull(autoSubtitlePreferenceOrder) } private fun bestForcedAutoSubtitleCandidate( candidates: List, targetLanguage: String?, -): AutoSubtitleCandidate? { - val pool = candidates +): AutoSubtitleCandidate? = + candidates .filter { targetLanguage == null || autoSubtitleLanguageKey(it.language) == targetLanguage } .filter { it.forced } - if (pool.isEmpty()) return null - - pool.firstOrNull { !it.isHearingImpaired() && !it.isBitmap() }?.let { return it } - pool.firstOrNull { !it.isHearingImpaired() }?.let { return it } - return pool.first() -} + .minWithOrNull(autoSubtitlePreferenceOrder) /** The ONE SDH predicate: an explicit signal, or the track's own title. */ fun AutoSubtitleCandidate.isHearingImpaired(): Boolean = hearingImpaired || subtitleLabelIndicatesHearingImpaired(title) -/** The ONE bitmap predicate (PGS / VobSub / DVB / HDMV aliases). */ -private fun AutoSubtitleCandidate.isBitmap(): Boolean = isBitmapSubtitleCodecFamily(codec) +/** + * Whether this track ranks as a burn-in: the caller's answer for this device, + * or, when it has none, the bitmap predicate (PGS / VobSub / DVB / HDMV + * aliases). + */ +private fun AutoSubtitleCandidate.requiresBurnIn(): Boolean = + needsBurnIn ?: isBitmapSubtitleCodecFamily(codec) /** * The ONE ISO-639 folding table for auto-selection language comparison. @@ -198,6 +229,11 @@ fun autoSubtitleLanguageKey(language: String?): String? { * Candidates over the CATALOG subtitle list, in catalog order, addressed in * combined selection space — the inventory the detail page previews and the * one a playback start request can act on. + * + * Burn-in follows the server's delivery rule for the capabilities Android + * declares on every delivery (embedded and sidecar bitmap): text and an + * embedded PGS track reach the device as-is, while VobSub, DVB and any + * external bitmap file have no client route and are burned in. */ fun catalogAutoSubtitleCandidates( catalogTracks: List, @@ -210,6 +246,9 @@ fun catalogAutoSubtitleCandidates( codec = track.codec, title = track.title, forced = track.forced, + source = if (track.external) AutoSubtitleSource.EXTERNAL else AutoSubtitleSource.EMBEDDED, + needsBurnIn = isBitmapSubtitleCodecFamily(track.codec) && + (track.external || !isClientMountableBitmapCodecFamily(track.codec)), ) } } @@ -222,12 +261,30 @@ fun catalogAutoSubtitleCandidates( */ fun inventoryAutoSubtitleCandidates( rows: List, -): List = rows.map { row -> +): List = rows.map(PlayerSubtitleInfo::toAutoSubtitleCandidate) + +/** + * One session subtitle row as a resolver candidate. Burn-in comes from the + * row's own playback identity, which reads the server's `burn_in_only` + * delivery and otherwise applies the same rule as [catalogAutoSubtitleCandidates]. + */ +fun PlayerSubtitleInfo.toAutoSubtitleCandidate(): AutoSubtitleCandidate = AutoSubtitleCandidate( - selectionIndex = row.index, - language = row.language, - codec = row.codec, - title = row.catalogLabel ?: row.label, - forced = row.forced == true, + selectionIndex = index, + language = language, + codec = codec, + title = catalogLabel ?: label, + forced = forced == true, + source = autoSubtitleSource(), + needsBurnIn = playbackSubtitleIdentity(this) is SubtitleIdentity.ServerBurnIn, ) + +private fun PlayerSubtitleInfo.autoSubtitleSource(): AutoSubtitleSource? { + if (isLocalDownloadedSubtitle()) return AutoSubtitleSource.DOWNLOADED + return when ((catalogSource ?: source)?.trim()?.lowercase()) { + "embedded" -> AutoSubtitleSource.EMBEDDED + "external" -> AutoSubtitleSource.EXTERNAL + SUBTITLE_SOURCE_DOWNLOADED -> AutoSubtitleSource.DOWNLOADED + else -> null + } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolverTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolverTest.kt index abe961dba2..1ecd0babdd 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolverTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/playback/AutoSubtitleResolverTest.kt @@ -16,12 +16,13 @@ class AutoSubtitleResolverTest { // --- the regression ------------------------------------------------- @Test - fun preferredLanguageAlwaysPicksTheExternalTextTrackOverAnEmbeddedBitmapOne() { + fun aPlainExternalTextTrackBeatsAnSdhEmbeddedBitmapOne() { // Shield, direct-play MKV: embedded PGS "English (SDH)" + an external // English SRT, preference English/Always. The detail row previewed the // SRT; the player, ranking only Media3's mounted tracks, started the // PGS. Over the full catalog the SRT wins — and its combined index is - // what the start request can carry. + // what the start request can carry. It wins because the PGS track is + // SDH, not because it is a bitmap: Android renders embedded PGS itself. val tracks = listOf( SubtitleTrack(index = 2, codec = "hdmv_pgs_subtitle", language = "eng", title = "English (SDH)"), SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), @@ -105,6 +106,158 @@ class AutoSubtitleResolverTest { assertEquals(1, selected?.selectionIndex) } + // --- embedded over external (silo-server #1849 parity) ------------- + + @Test + fun anEmbeddedTextTrackBeatsAnExternalOneInTheSameLanguage() { + // External sidecars are the ones that drift out of sync. The catalog + // lists the sidecar first here, so caller order alone would pick it. + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "English", external = true), + SubtitleTrack(index = 3, codec = "subrip", language = "eng", title = "English"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + // Embedded tracks follow every external in combined space. + assertEquals(1, selected?.selectionIndex) + } + + @Test + fun theDetailPreviewAndThePlayerInventoryPickTheSameEmbeddedTrack() { + // The detail page ranks the catalog (embedded first); the player + // fallback ranks the session inventory (externals first). Source now + // decides the tie, so the two agree. + val catalog = listOf( + SubtitleTrack(index = 3, codec = "subrip", language = "eng", title = "English"), + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "English", external = true), + ) + val inventory = listOf( + PlayerSubtitleInfo(index = 0, language = "eng", codec = "srt", url = "/s/0.vtt", catalogLabel = "English", catalogSource = "external", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + PlayerSubtitleInfo(index = 1, language = "eng", codec = "subrip", url = "/s/1.vtt", catalogLabel = "English", catalogSource = "embedded", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + ) + val context = AutoSubtitleContext(preferredLanguage = "en", mode = "always") + + val fromCatalog = resolveAutoSubtitle(catalogAutoSubtitleCandidates(catalog), context).selectedCandidate() + val fromInventory = resolveAutoSubtitle(inventoryAutoSubtitleCandidates(inventory), context).selectedCandidate() + + assertEquals(1, fromCatalog?.selectionIndex) + assertEquals(1, fromInventory?.selectionIndex) + } + + @Test + fun anEmbeddedPgsTrackTheDeviceRendersBeatsAnExternalTextTrack() { + // Android plays embedded PGS without a transcode (in-stream on direct + // play, a raw .sup sidecar over HLS), so being a bitmap is no reason to + // fall back to the sidecar. + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), + SubtitleTrack(index = 2, codec = "hdmv_pgs_subtitle", language = "eng", title = "English"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(1, selected?.selectionIndex) + } + + @Test + fun anEmbeddedBitmapTrackThatNeedsABurnInLosesToAnExternalTextTrack() { + // VobSub has no client route, so the server would transcode to show it. + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), + SubtitleTrack(index = 2, codec = "dvd_subtitle", language = "eng", title = "English"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun aServerBurnInOnlyEmbeddedPgsRowLosesToAnExternalTextRow() { + // The inventory's own delivery wins over the codec rule. + val rows = listOf( + PlayerSubtitleInfo(index = 0, language = "eng", codec = "srt", url = "/s/0.vtt", catalogSource = "external", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + PlayerSubtitleInfo(index = 1, language = "eng", codec = "pgs", url = "", catalogSource = "embedded", serverDelivery = SUBTITLE_DELIVERY_BURN_IN_ONLY), + ) + + val selected = resolveAutoSubtitle( + candidates = inventoryAutoSubtitleCandidates(rows), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun aFullExternalTrackBeatsAForcedEmbeddedOne() { + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), + SubtitleTrack(index = 2, codec = "subrip", language = "eng", title = "Forced", forced = true), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always", showForced = true), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun anExternalTrackBeatsADownloadedOne() { + val rows = listOf( + PlayerSubtitleInfo(index = 1, language = "eng", codec = "srt", url = "/s/1.vtt", catalogSource = "downloaded", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + PlayerSubtitleInfo(index = 0, language = "eng", codec = "srt", url = "/s/0.vtt", catalogSource = "external", serverDelivery = SUBTITLE_DELIVERY_SIDECAR), + ) + + val selected = resolveAutoSubtitle( + candidates = inventoryAutoSubtitleCandidates(rows), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun aBurnInTrackStillWinsWhenItIsTheOnlyMatch() { + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "fre", external = true), + SubtitleTrack(index = 2, codec = "dvd_subtitle", language = "eng"), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(1, selected?.selectionIndex) + } + + @Test + fun theForcedTrackForMatchingAudioPrefersTheEmbeddedOne() { + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "Forced", forced = true, external = true), + SubtitleTrack(index = 2, codec = "subrip", language = "eng", title = "Forced", forced = true), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "auto", showForced = true, audioLanguage = "eng"), + ).selectedCandidate() + + assertEquals(1, selected?.selectionIndex) + } + // --- ported detail-preview cases ------------------------------------- @Test From def0532c0e5f9fcb401d275637cd4632ce1948d0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:42:26 -0400 Subject: [PATCH 63/87] fix(subtitles): bound how far a PGS sidecar reads ahead (#528) A PGS subtitle sent as a .sup sidecar (the remux and transcode route) was read from start to finish as soon as it was mounted, and every caption image it decoded stayed in the sample queue until playback reached it. For a feature film that is tens of MB of Java heap; a phone with a 192 MB heap ran out of memory, or stalled at startup, as soon as the track was selected. The sidecar wrapper keeps re-continuing its loader so a sidecar never gates the video. It now leaves the loader parked once its queued cues are more than two minutes past the playhead, and continues it as playback catches up (from the playback ticks) or after a seek. Only the PGS sidecar sets a limit; text sidecars still load in one go. Co-authored-by: Claude Opus 5.5 --- .../silo/common/player/SiloPlayerFactory.kt | 20 +++- .../subtitle/SidecarSubtitleMediaSource.kt | 65 ++++++++++- .../SidecarSubtitleMediaSourceTest.kt | 103 +++++++++++++++++- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt index eeff9e5cbb..67cd2c9d94 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt @@ -725,11 +725,29 @@ class SiloPlayerFactory( // A sidecar must not decide when playback starts or what loads // next — left as a plain merged child it starves the video until // its own download reaches the resume point. See the wrapper. - return SidecarSubtitleMediaSource(progressive, playbackFloor) + // A PGS sidecar also reads only a bounded stretch ahead: each of + // its cues carries a caption image, and a whole film of them does + // not fit in a phone's heap. + return SidecarSubtitleMediaSource( + progressive, + playbackFloor, + maxLookaheadUs = if (configuration.mimeType == MimeTypes.APPLICATION_PGS) { + PGS_SIDECAR_LOOKAHEAD_US + } else { + C.TIME_UNSET + }, + ) } } private companion object { + /** + * Two minutes of captions ahead of the playhead. The loader can read + * one check interval (1 MiB of the file) past that before it parks, + * so a PGS sidecar holds a few MB instead of the whole film. + */ + const val PGS_SIDECAR_LOOKAHEAD_US = 120_000_000L + val replayableTextSubtitleMimeTypes = setOf( MimeTypes.TEXT_SSA, MimeTypes.APPLICATION_SUBRIP, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt index 83ed3148d9..145c8c0278 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSource.kt @@ -56,11 +56,19 @@ class SidecarPlaybackFloor { * Captions that arrive after the playhead has passed them are the extractor's * problem, not this class's: it publishes the live position through * [SidecarPlaybackFloor] so a REPLACE-behaviour extractor can drop them. + * + * [maxLookaheadUs] bounds how far past the playhead the sidecar may read. + * Without it the whole file is read up front, and everything read stays in the + * sample queue until playback reaches it. That is nothing for a text sidecar, + * but a feature-length `.sup` holds tens of MB of caption images, enough to + * run a phone with a 192 MB heap out of memory. [C.TIME_UNSET] keeps the + * unbounded read. */ @UnstableApi class SidecarSubtitleMediaSource( child: MediaSource, private val floor: SidecarPlaybackFloor, + private val maxLookaheadUs: Long = C.TIME_UNSET, ) : WrappingMediaSource(child) { override fun createPeriod( @@ -70,6 +78,7 @@ class SidecarSubtitleMediaSource( ): MediaPeriod = NonGatingSidecarPeriod( mediaSource.createPeriod(id, allocator, startPositionUs), floor, + maxLookaheadUs, ) override fun releasePeriod(mediaPeriod: MediaPeriod) { @@ -81,10 +90,17 @@ class SidecarSubtitleMediaSource( internal class NonGatingSidecarPeriod( val delegate: MediaPeriod, private val floor: SidecarPlaybackFloor, + private val maxLookaheadUs: Long = C.TIME_UNSET, ) : MediaPeriod, MediaPeriod.Callback { private var callback: MediaPeriod.Callback? = null + /** The delegate parked far enough ahead that it was left parked; see [continueUnlessAhead]. */ + private var heldAhead = false + + /** A delegate's buffered position is only defined once it has prepared. */ + private var prepared = false + override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) { this.callback = callback floor.set(positionUs) @@ -92,6 +108,7 @@ internal class NonGatingSidecarPeriod( } override fun onPrepared(mediaPeriod: MediaPeriod) { + prepared = true callback?.onPrepared(this) } @@ -99,10 +116,42 @@ internal class NonGatingSidecarPeriod( // The delegate parked its loader (interval reached, or a cancelled load // finished unwinding). Nobody upstream will continue a child that // reports nothing to load, so do it here. - kickDelegate("requested") + continueUnlessAhead("requested") callback?.onContinueLoadingRequested(this) } + /** + * Continues the delegate unless its queued cues already reach more than + * [maxLookaheadUs] past the playhead. A held delegate stays parked, and + * [resumeIfCaughtUp] continues it from the playback ticks once the + * playhead closes the gap. + */ + private fun continueUnlessAhead(reason: String) { + if (isAheadOfPlayhead()) { + if (!heldAhead) { + org.siloserver.silo.common.player.SubDiag.log( + "sidecar held($reason) buffered=${delegate.bufferedPositionUs / 1000}ms " + + "floor=${floor.get() / 1000}ms", + ) + } + heldAhead = true + return + } + heldAhead = false + kickDelegate(reason) + } + + private fun resumeIfCaughtUp() { + if (heldAhead && !isAheadOfPlayhead()) continueUnlessAhead("caught-up") + } + + private fun isAheadOfPlayhead(): Boolean { + if (maxLookaheadUs == C.TIME_UNSET || !prepared) return false + val bufferedUs = delegate.bufferedPositionUs + if (bufferedUs == C.TIME_END_OF_SOURCE || bufferedUs == C.TIME_UNSET) return false + return bufferedUs - floor.get() > maxLookaheadUs + } + /** * `ProgressiveMediaPeriod` never restarts itself: after `prepare()` it * reads until its check interval and parks; `selectTracks` that enables a @@ -153,17 +202,26 @@ internal class NonGatingSidecarPeriod( val result = delegate.selectTracks(selections, mayRetainStreamFlags, streams, streamResetFlags, positionUs) // Enabling the text track (a subtitle pick after start, or the first // selection once prepared) is what makes the delegate willing to load. - if (selections.any { it != null }) kickDelegate("select") + if (selections.any { it != null }) continueUnlessAhead("select") return result } - override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) = + /** + * Called on the playing period every playback tick, unlike + * [reevaluateBuffer], which stops once the next item starts loading. So + * this is what keeps a held sidecar reading through the end of an item. + */ + override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) { delegate.discardBuffer(positionUs, toKeyframe) + floor.set(maxOf(floor.get(), positionUs)) + resumeIfCaughtUp() + } override fun readDiscontinuity(): Long = delegate.readDiscontinuity() override fun seekToUs(positionUs: Long): Long { floor.set(positionUs) + heldAhead = false val result = delegate.seekToUs(positionUs) // An idle delegate is left reset-but-parked by a seek; a loading one is // cancelled and comes back through onContinueLoadingRequested. @@ -192,5 +250,6 @@ internal class NonGatingSidecarPeriod( // period position — the live floor for "this cue is already history". floor.set(positionUs) delegate.reevaluateBuffer(positionUs) + resumeIfCaughtUp() } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt index ba34420eeb..a2f38acac1 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt @@ -137,6 +137,106 @@ class SidecarSubtitleMediaSourceTest { assertEquals(1_000_000L, delegate.lastSeekUs) } + /** + * A feature-length `.sup` read in one go kept every caption image in the + * sample queue and ran a phone with a 192 MB heap out of memory. With a + * lookahead, a delegate that parks far enough ahead stays parked until + * the playback ticks show the playhead has closed the gap. + */ + @Test + fun leavesADelegateParkedWhileItIsFurtherAheadThanTheLookahead() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val floor = SidecarPlaybackFloor() + val period = NonGatingSidecarPeriod(delegate, floor, maxLookaheadUs = 120_000_000L) + val upstream = RecordingCallback() + period.prepare(upstream, 0L) + delegate.callback!!.onPrepared(delegate) + + delegate.callback!!.onContinueLoadingRequested(delegate) + assertTrue(delegate.continueLoadingCalls.isEmpty()) + // The merge is still told, as before. + assertSame(period, upstream.continueLoadingRequestedFrom.single()) + + period.discardBuffer(150_000_000L, false) // 150 s still ahead: keep holding + assertTrue(delegate.continueLoadingCalls.isEmpty()) + + period.discardBuffer(190_000_000L, false) // 110 s ahead: read on + assertEquals(1, delegate.continueLoadingCalls.size) + assertEquals(190_000_000L, delegate.continueLoadingCalls.single().playbackPositionUs) + + period.discardBuffer(191_000_000L, false) // no longer held: no second kick + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun reevaluateBufferAlsoResumesAHeldDelegate() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + delegate.callback!!.onPrepared(delegate) + delegate.callback!!.onContinueLoadingRequested(delegate) + + period.reevaluateBuffer(200_000_000L) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun aSeekAlwaysRestartsAHeldDelegateAndTheHoldStartsOver() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + delegate.callback!!.onPrepared(delegate) + delegate.callback!!.onContinueLoadingRequested(delegate) + assertTrue(delegate.continueLoadingCalls.isEmpty()) + + period.seekToUs(10_000_000L) + assertEquals(1, delegate.continueLoadingCalls.size) + + // The restarted read parks again within the lookahead of the new + // position: read on. Once it is too far ahead again, it holds again. + delegate.bufferedUs = 60_000_000L + delegate.callback!!.onContinueLoadingRequested(delegate) + assertEquals(2, delegate.continueLoadingCalls.size) + delegate.bufferedUs = 200_000_000L + delegate.callback!!.onContinueLoadingRequested(delegate) + assertEquals(2, delegate.continueLoadingCalls.size) + } + + @Test + fun aDelegateThatHasNotPreparedIsNeverHeld() { + val delegate = FakePeriod().apply { bufferedUs = 300_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun aFinishedDelegateIsNeverHeld() { + val delegate = FakePeriod().apply { bufferedUs = C.TIME_END_OF_SOURCE } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor(), maxLookaheadUs = 120_000_000L) + period.prepare(RecordingCallback(), 0L) + delegate.callback!!.onPrepared(delegate) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + + @Test + fun withoutALookaheadTheDelegateReadsTheWholeFile() { + val delegate = FakePeriod().apply { bufferedUs = 3_000_000_000L } + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor()) + period.prepare(RecordingCallback(), 0L) + + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(1, delegate.continueLoadingCalls.size) + } + @Test fun forwardsPreparedAsItself() { val delegate = FakePeriod() @@ -167,6 +267,7 @@ class SidecarSubtitleMediaSourceTest { var callback: MediaPeriod.Callback? = null var loading = false var lastSeekUs = C.TIME_UNSET + var bufferedUs = 0L val continueLoadingCalls = mutableListOf() /** Models a prepared ProgressiveMediaPeriod: a new load needs a pending reset. */ var requirePendingReset = false @@ -197,7 +298,7 @@ class SidecarSubtitleMediaSourceTest { override fun getAdjustedSeekPositionUs(positionUs: Long, seekParameters: SeekParameters): Long = positionUs - override fun getBufferedPositionUs(): Long = 0L + override fun getBufferedPositionUs(): Long = bufferedUs override fun getNextLoadPositionUs(): Long = 0L override fun continueLoading(loadingInfo: LoadingInfo): Boolean { continueLoadingCalls += loadingInfo From cfac75be664d9df2c4e0cc6e20994f09458cffec Mon Sep 17 00:00:00 2001 From: Suspense Date: Fri, 9 Oct 2026 19:34:02 +0200 Subject: [PATCH 64/87] fix(tv): keep library browse sort and filters across app restarts (#468) * fix(tv): keep library browse sort and filters across app restarts Android TV held a library's Browse sort and filters only in the screen's ViewModel, so closing the app reset them to Title A-Z with no filters. Phone, web, iOS, tvOS, and macOS all bring them back. TV now saves them with the phone's BrowsePrefsStore, moved to android-shared and keyed under its own `androidtv` prefix (phone keys are unchanged). They are saved per server, profile, and library, and restored when Browse opens. The A-Z jump and the preset sections are not saved, matching tvOS. The TV filter panel gains the "Preserve sort & filters" toggle the other clients have. * fix(tv): offer Clear filters when a filtered Browse request fails The Browse error screen replaces the Sort and Filter row, and Retry sends the same query. A saved filter the server rejects would keep Browse failing after every restart. When filters are active, the error screen now offers Clear filters, which reloads Browse and overwrites the saved state while keeping the sort. * refactor(tv): simplify the Browse preserve toggle and save check Pass the preserve value and its handler to the filter panel as one nullable parameter, so a caller can't show the row with a value that disagrees with the store, and drive the row from that value instead of a local copy. Decide whether to save Browse state by comparing sort, order and facets directly rather than converting both filters to the saved form. Return early from onTabSelected before reading saved state for a section that is already selected, and say which saved values the restore actually drops. --- .../silo/common/settings}/BrowsePrefsStore.kt | 17 +- androidApp/lint-baseline.xml | 22 -- .../silo/android/di/AndroidModule.kt | 4 +- .../ui/screens/browse/BrowseViewModel.kt | 1 + .../ui/screens/libraries/LibrariesScreen.kt | 2 +- .../libraries/LibrariesViewModelTest.kt | 2 +- .../siloserver/silo/tv/di/AndroidTvModule.kt | 7 + .../library/TvLibraryBrowseControls.kt | 31 +++ .../screens/library/TvLibraryBrowseFacets.kt | 46 ++++ .../screens/library/TvLibraryDetailScreen.kt | 10 + .../library/TvLibraryDetailViewModel.kt | 39 +++- .../library/TvLibraryBrowsePrefsTest.kt | 204 ++++++++++++++++++ 12 files changed, 347 insertions(+), 38 deletions(-) rename {androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse => android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings}/BrowsePrefsStore.kt (79%) create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowsePrefsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/BrowsePrefsStore.kt similarity index 79% rename from androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowsePrefsStore.kt rename to android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/BrowsePrefsStore.kt index ce0abec15f..dcfe6a091d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowsePrefsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/BrowsePrefsStore.kt @@ -1,6 +1,7 @@ -package org.siloserver.silo.android.ui.screens.browse +package org.siloserver.silo.common.settings import android.content.Context +import androidx.core.content.edit import kotlinx.serialization.json.Json import org.siloserver.silo.catalog.filter.CatalogFilterState import org.siloserver.silo.network.ServerRegistry @@ -8,15 +9,19 @@ import org.siloserver.silo.network.ServerRegistry /** * Persists browse filter + sort state per server, per profile, per library — * mirrors iOS `BrowsePrefsStore` (`ios.browsePrefs...`, - * here `android.browsePrefs.…`). Nothing is persisted without an active + * here `.browsePrefs.…`). Nothing is persisted without an active * profile, so anonymous browsing never leaks into a profile's saved state. * + * The phone and TV apps each pass their own [keyPrefix] (`android`, + * `androidtv`), as Apple keeps `ios.` / `tv.` / `mac.` apart. + * * Gated by a user-facing "Preserve sort & filters" toggle (default ON); * turning it off clears the saved state. */ class BrowsePrefsStore( context: Context, private val serverRegistry: ServerRegistry, + private val keyPrefix: String = "android", ) { private val prefs = context.getSharedPreferences("browse_prefs", Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } @@ -26,7 +31,7 @@ class BrowsePrefsStore( val profileId = serverRegistry.activeEntry.value?.profileId ?.takeIf { it.isNotBlank() } ?: return null val lib = libraryId?.toString() ?: "all" - return "android.browsePrefs.$serverId.$profileId.$lib" + return "$keyPrefix.browsePrefs.$serverId.$profileId.$lib" } fun savedState(libraryId: Int?): CatalogFilterState? { @@ -39,7 +44,7 @@ class BrowsePrefsStore( fun saveState(libraryId: Int?, state: CatalogFilterState) { if (!preserveEnabled(libraryId)) return val key = base(libraryId) ?: return - prefs.edit().putString("$key.state", json.encodeToString(state)).apply() + prefs.edit { putString("$key.state", json.encodeToString(state)) } } /** Default ON when the key is absent (iOS parity). */ @@ -50,9 +55,9 @@ class BrowsePrefsStore( fun setPreserveEnabled(libraryId: Int?, enabled: Boolean) { val key = base(libraryId) ?: return - prefs.edit().apply { + prefs.edit { putBoolean("$key.preserve", enabled) if (!enabled) remove("$key.state") - }.apply() + } } } diff --git a/androidApp/lint-baseline.xml b/androidApp/lint-baseline.xml index c0d9fda6e7..6ee1b8b86e 100644 --- a/androidApp/lint-baseline.xml +++ b/androidApp/lint-baseline.xml @@ -4093,28 +4093,6 @@ column="25"/> - - - - - - - - () ?: org.siloserver.silo.repository.port.NoOpUserItemStatePort, get(), - get(), + get(), ) } viewModel { RecommendationsViewModel(get()) } viewModel { SearchViewModel(get()) } single { - org.siloserver.silo.android.ui.screens.browse.BrowsePrefsStore( + org.siloserver.silo.common.settings.BrowsePrefsStore( context = get(), serverRegistry = get(), ) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt index 6b9a29a0a2..0dacd4a5af 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import org.siloserver.silo.catalog.filter.BrowseFacetMediaType import org.siloserver.silo.catalog.filter.CatalogFilterQueryBuilder import org.siloserver.silo.catalog.filter.CatalogFilterState +import org.siloserver.silo.common.settings.BrowsePrefsStore import org.siloserver.silo.model.catalog.BrowseItem import org.siloserver.silo.model.catalog.CatalogFiltersResponse import org.siloserver.silo.model.catalog.MediaItemUserState diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index e78e580396..0728b8a889 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -96,7 +96,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.runtime.mutableIntStateOf import org.siloserver.silo.android.ui.components.rememberShimmerProgress import androidx.compose.material.icons.filled.Cancel -import org.siloserver.silo.android.ui.screens.browse.BrowsePrefsStore +import org.siloserver.silo.common.settings.BrowsePrefsStore import org.siloserver.silo.android.ui.screens.browse.CatalogGrid import org.siloserver.silo.android.ui.screens.browse.CatalogViewDensity import org.siloserver.silo.android.ui.screens.browse.FilterSheet diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt index 6727bf5523..fa0dc033cc 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -39,7 +39,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import androidx.test.core.app.ApplicationProvider -import org.siloserver.silo.android.ui.screens.browse.BrowsePrefsStore +import org.siloserver.silo.common.settings.BrowsePrefsStore import org.siloserver.silo.catalog.filter.CatalogFacet import org.siloserver.silo.catalog.filter.CatalogFilterState import org.siloserver.silo.model.server.ServerEntry diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index c805e73313..6bd2f4ef75 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -293,6 +293,12 @@ val androidTvModule = module { org.siloserver.silo.tv.data.preferences.TvLibraryScopeStore(androidContext(), get()) } + // Library Browse sort + filters, per server·profile·library (the phone + // store under its own key prefix). + single { + org.siloserver.silo.common.settings.BrowsePrefsStore(androidContext(), get(), keyPrefix = "androidtv") + } + // tvOS-parity Home row visibility/order, local to this TV and partitioned // by active server + profile. single { TvHomeSectionPreferences(androidContext(), get()) } @@ -488,6 +494,7 @@ val androidTvModule = module { libraryTitle = params.get(), libraryType = params.get(), mediaScope = params.values.getOrNull(3) as? String, + browsePrefs = get(), ) } viewModel { params -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt index a208a474be..524babacd7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseControls.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronLeft @@ -382,6 +384,12 @@ fun TvBrowseSortPanel( // Filter panel (list → values) // ============================================================================ +/** The library Browse "Preserve sort & filters" value and its change handler. */ +data class TvBrowsePreserveToggle( + val enabled: Boolean, + val onChange: (Boolean) -> Unit, +) + @Composable fun TvBrowseFilterPanel( libraryType: String, @@ -389,6 +397,8 @@ fun TvBrowseFilterPanel( initial: TvCatalogFacetSelection, onApply: (TvCatalogFacetSelection) -> Unit, onClose: () -> Unit, + /** Shows the "Preserve sort & filters" row; null hides it (non-library grids). */ + preserve: TvBrowsePreserveToggle? = null, ) { var draft by remember { mutableStateOf(initial) } // null = top-level facet list; non-null = that facet's value list. @@ -482,6 +492,7 @@ fun TvBrowseFilterPanel( firstRowFocusRequester = screenFocusRequester, onOpenFacet = { openFacet = it }, onDraftChanged = { draft = it }, + preserve = preserve, onDone = commitAndClose, ) else -> FacetValuesScreen( @@ -506,6 +517,7 @@ private fun FilterListScreen( firstRowFocusRequester: FocusRequester, onOpenFacet: (TvCatalogFacet) -> Unit, onDraftChanged: (TvCatalogFacetSelection) -> Unit, + preserve: TvBrowsePreserveToggle?, onDone: () -> Unit, ) { if (availableFacets.isEmpty()) { @@ -564,6 +576,25 @@ private fun FilterListScreen( text = "OPTIONS", modifier = Modifier.padding(start = 12.dp, top = 14.dp, bottom = 4.dp), ) + if (preserve != null) { + // The toggle takes effect at once rather than on Done (tvOS `preserveRow`). + BrowsePanelRow( + onClick = { preserve.onChange(!preserve.enabled) }, + ) { foreground -> + Icon( + imageVector = if (preserve.enabled) Icons.Filled.CheckBox else Icons.Filled.CheckBoxOutlineBlank, + contentDescription = null, + tint = foreground, + modifier = Modifier.size(15.dp), + ) + Text( + text = "Preserve sort & filters", + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 17.sp), + color = foreground, + maxLines = 1, + ) + } + } BrowsePanelRow( onClick = { if (draft.canReset) onDraftChanged(TvCatalogFacetSelection()) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseFacets.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseFacets.kt index 2c54eccb0f..5c7829c9d4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseFacets.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowseFacets.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.tv.ui.screens.library +import org.siloserver.silo.catalog.filter.CatalogFacet +import org.siloserver.silo.catalog.filter.CatalogFilterState import org.siloserver.silo.model.catalog.CatalogFiltersResponse import org.siloserver.silo.model.catalog.CatalogQueryGroup import org.siloserver.silo.model.catalog.CatalogQueryRule @@ -272,3 +274,47 @@ data class TvCatalogFacetSelection( private fun Set.toggle(value: String): Set = if (value in this) this - value else this + value } + +/** + * The persisted half of a Browse filter — sort, order, facets, and match — in + * the shared [CatalogFilterState] shape `BrowsePrefsStore` saves on phone and + * Apple. The A–Z prefix, the legacy genre chip, and preset query groups are + * per-visit and never saved (tvOS strips `namePrefix` the same way). + */ +internal fun TvLibraryBrowseFilter.toSavedState(): CatalogFilterState = CatalogFilterState( + selections = TvCatalogFacet.entries.associate { facet -> + CatalogFacet.valueOf(facet.name) to facetSelection.selectedValues(facet) + .map { if (facet == TvCatalogFacet.WatchStatus) it.toSharedWatchStatus() else it } + .toSet() + }.filterValues { it.isNotEmpty() }, + matchAll = facetSelection.matchAll, + sort = sort, + order = order, +) + +/** + * Rebuilds the Browse filter from a saved state. Unknown watch-status and + * dynamic-range values are dropped; other facet values are restored as saved. + */ +internal fun CatalogFilterState.toTvBrowseFilter(): TvLibraryBrowseFilter { + var selection = TvCatalogFacetSelection(matchAll = matchAll) + for ((facet, values) in selections) { + val tvFacet = TvCatalogFacet.entries.firstOrNull { it.name == facet.name } ?: continue + for (value in values) { + val tvValue = if (tvFacet == TvCatalogFacet.WatchStatus) value.toTvWatchStatus() else value + if (tvValue != null && !selection.isSelected(tvFacet, tvValue)) { + selection = selection.toggled(tvFacet, tvValue) + } + } + } + return TvLibraryBrowseFilter(sort = sort, order = order, facetSelection = selection) +} + +// The shared model spells In Progress `inProgress` (Apple's raw value); the TV +// panel uses the `in_progress` wire name. Every other status matches. +private fun String.toSharedWatchStatus(): String = + if (this == TvWatchStatusFilter.InProgress.wireValue) "inProgress" else this + +private fun String.toTvWatchStatus(): String? = + if (this == "inProgress") TvWatchStatusFilter.InProgress.wireValue + else TvWatchStatusFilter.entries.firstOrNull { it.wireValue == this }?.wireValue diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt index ca99a4457f..7cd742d0dc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -180,6 +180,7 @@ fun TvLibraryDetailScreen( showBrowseControls = true, onSortKeySelected = viewModel::onSortKeySelected, onFacetSelectionApplied = viewModel::onFacetSelectionApplied, + onPreserveFiltersChanged = viewModel::onPreserveFiltersChanged, onContentUpFallbackChanged = onContentUpFallbackChanged, onShuffle = onShuffleLibrary, ) @@ -365,6 +366,7 @@ private fun LibraryTab( showBrowseControls: Boolean = false, onSortKeySelected: (TvLibrarySortOption) -> Unit = {}, onFacetSelectionApplied: (TvCatalogFacetSelection) -> Unit = {}, + onPreserveFiltersChanged: ((Boolean) -> Unit)? = null, /** Shell hook for overriding D-pad Up while the A–Z rail holds focus. */ onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, onClearAudiobookGroup: (() -> Unit)? = null, @@ -398,9 +400,16 @@ private fun LibraryTab( ) if (state.browseError != null) { + // The error replaces the Sort/Filter row, and Retry resends the same + // query. A saved filter the server rejects would otherwise keep + // Browse failing across restarts, so offer to clear it here; that + // also overwrites the saved state. + val canClearFilters = showBrowseControls && state.browseFilter.facetSelection.canReset TvErrorScreen( message = state.browseError, onRetry = onRetry, + secondaryActionLabel = "Clear filters".takeIf { canClearFilters }, + onSecondaryAction = { onFacetSelectionApplied(TvCatalogFacetSelection()) }.takeIf { canClearFilters }, modifier = Modifier.padding( start = Spacing.safeArea, top = TvTopMenuLayout.contentTopInset, @@ -477,6 +486,7 @@ private fun LibraryTab( initial = state.browseFilter.facetSelection, onApply = onFacetSelectionApplied, onClose = { openPanel = null }, + preserve = onPreserveFiltersChanged?.let { TvBrowsePreserveToggle(state.preserveFilters, it) }, ) null -> Unit } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt index bd6c2059fc..b24af0b3e7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -2,6 +2,7 @@ package org.siloserver.silo.tv.ui.screens.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.siloserver.silo.common.settings.BrowsePrefsStore import org.siloserver.silo.model.catalog.AudiobookGroup import org.siloserver.silo.model.catalog.BrowseItem import org.siloserver.silo.model.catalog.CatalogQueryGroup @@ -163,6 +164,7 @@ class TvLibraryDetailViewModel( private val libraryTitle: String, private val libraryType: String, private val mediaScope: String? = null, + private val browsePrefs: BrowsePrefsStore? = null, ) : ViewModel() { data class UiState( @@ -192,12 +194,15 @@ class TvLibraryDetailViewModel( val collectionSections: List = emptyList(), val collectionsLoading: Boolean = false, val collectionsError: String? = null, + /** "Preserve sort & filters" for this library's Browse grid (default ON). */ + val preserveFilters: Boolean = true, ) private val _uiState = MutableStateFlow( UiState( title = libraryTitle, libraryType = libraryType, + preserveFilters = browsePrefs?.preserveEnabled(libraryId) ?: true, ), ) val uiState: StateFlow = _uiState.asStateFlow() @@ -231,9 +236,6 @@ class TvLibraryDetailViewModel( fun onTabSelected(tab: TvLibraryTab) { val state = _uiState.value - val nextFilter = state.browseFilter.forTab(tab) - val filterChanged = nextFilter != state.browseFilter - val audiobookGroupBy = tab.audiobookGroupBy // Re-selecting the section that is already active is a no-op. The // screen re-issues the committed section every time it re-enters // composition — backing out of item detail / the player returns to a @@ -242,6 +244,9 @@ class TvLibraryDetailViewModel( // and facets back to the tab's defaults (Title A–Z). Only a genuine // tab CHANGE applies the new tab's defaults. if (state.selectedTab == tab) return + val nextFilter = state.browseFilter.forTab(tab) + val filterChanged = nextFilter != state.browseFilter + val audiobookGroupBy = tab.audiobookGroupBy _uiState.update { it.copy( selectedTab = tab, @@ -395,14 +400,35 @@ class TvLibraryDetailViewModel( loadAudiobookGroups(groupBy = groupBy, reset = true) } + /** + * Turning preserve off clears the saved state; turning it on saves the + * current Browse sort and filters at once (tvOS `setPreserveEnabled`). + */ + fun onPreserveFiltersChanged(enabled: Boolean) { + browsePrefs?.setPreserveEnabled(libraryId, enabled) + _uiState.update { it.copy(preserveFilters = enabled) } + if (enabled) saveBrowseFilter(_uiState.value) + } + private fun updateBrowseFilter(filter: TvLibraryBrowseFilter) { - if (_uiState.value.browseFilter == filter) return + val previous = _uiState.value.browseFilter + if (previous == filter) return _uiState.update { it.copy(browseFilter = filter) } + if (previous.sort != filter.sort || previous.order != filter.order || previous.facetSelection != filter.facetSelection) { + saveBrowseFilter(_uiState.value) + } if (_uiState.value.selectedTab == TvLibraryTab.Browse || loadedBrowse) { loadBrowse(reset = true) } } + // Only the Browse grid's own sort and filters persist; the other grid + // sections are fixed presets. + private fun saveBrowseFilter(state: UiState) { + if (state.selectedTab != TvLibraryTab.Browse) return + browsePrefs?.saveState(libraryId, state.browseFilter.toSavedState()) + } + private var recommendedGeneration = 0L private var recommendedJob: Job? = null @@ -804,8 +830,9 @@ class TvLibraryDetailViewModel( when (tab) { TvLibraryTab.Recommended, TvLibraryTab.Collections -> this - // Browse lands on the tvOS default view: Title A–Z, no facets. - TvLibraryTab.Browse -> copy( + // Browse restores the saved sort and filters, else lands on the + // tvOS default view: Title A–Z, no facets. + TvLibraryTab.Browse -> browsePrefs?.savedState(libraryId)?.toTvBrowseFilter() ?: copy( genre = null, namePrefix = null, sort = TvLibrarySortOption.Title.wireValue, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt new file mode 100644 index 0000000000..d4a362dac0 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt @@ -0,0 +1,204 @@ +package org.siloserver.silo.tv.ui.screens.library + +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +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.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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 org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.siloserver.silo.catalog.filter.CatalogFacet +import org.siloserver.silo.common.settings.BrowsePrefsStore +import org.siloserver.silo.model.server.ServerEntry +import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.api.CatalogApi +import org.siloserver.silo.network.api.SectionApi +import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.repository.SectionRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +// BrowsePrefsStore writes through real SharedPreferences. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class TvLibraryBrowsePrefsTest { + + @Test + fun savedStateRoundTripsEveryFacetAndDropsPerVisitFields() { + var selection = TvCatalogFacetSelection(matchAll = false) + for (facet in TvCatalogFacet.entries) { + val value = when (facet) { + TvCatalogFacet.Decade -> "1990" + TvCatalogFacet.DynamicRange -> "dolby_vision" + TvCatalogFacet.WatchStatus -> TvWatchStatusFilter.InProgress.wireValue + else -> "${facet.name}-value" + } + selection = selection.toggled(facet, value) + } + val filter = TvLibraryBrowseFilter( + genre = "Drama", + namePrefix = "K", + sort = TvLibrarySortOption.Rating.wireValue, + order = "asc", + facetSelection = selection, + ) + + val saved = filter.toSavedState() + // The shared model (phone + Apple) spells In Progress `inProgress`. + assertEquals(setOf("inProgress"), saved.valuesFor(CatalogFacet.WatchStatus)) + assertEquals(CatalogFacet.entries.size, saved.selections.size) + + assertEquals( + TvLibraryBrowseFilter(sort = "rating_imdb", order = "asc", facetSelection = selection), + saved.toTvBrowseFilter(), + ) + } + + @Test + fun browseSortAndFiltersSurviveANewViewModel() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val first = viewModel(prefs) + first.onTabSelected(TvLibraryTab.Browse) + first.onSortKeySelected(TvLibrarySortOption.ReleaseDate) + first.onFacetSelectionApplied( + TvCatalogFacetSelection().toggled(TvCatalogFacet.Genre, "Drama"), + ) + first.onNamePrefixChanged("K") + + // A cold start lands on Recommended; opening Browse restores the + // saved sort and filters but not the A–Z jump. + val second = viewModel(prefs) + assertEquals(TvLibraryBrowseFilter(), second.uiState.value.browseFilter) + second.onTabSelected(TvLibraryTab.Browse) + val restored = second.uiState.value.browseFilter + assertEquals("release_date", restored.sort) + assertEquals("desc", restored.order) + assertEquals(setOf("Drama"), restored.facetSelection.genres) + assertNull(restored.namePrefix) + + // Turning preserve off forgets them: Browse opens on Title A–Z again. + second.onPreserveFiltersChanged(false) + val third = viewModel(prefs) + assertEquals(false, third.uiState.value.preserveFilters) + third.onTabSelected(TvLibraryTab.Browse) + assertEquals("title", third.uiState.value.browseFilter.sort) + assertEquals(TvCatalogFacetSelection(), third.uiState.value.browseFilter.facetSelection) + } + + @Test + fun presetSectionsDoNotOverwriteTheSavedBrowseState() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val first = viewModel(prefs) + first.onTabSelected(TvLibraryTab.Browse) + first.onSortKeySelected(TvLibrarySortOption.Year) + first.onTabSelected(TvLibraryTab.RecentlyAdded) + first.onTabSelected(TvLibraryTab.Alphabet) + + assertEquals("year", prefs.savedState(LIBRARY_ID)?.sort) + } + + @Test + fun clearingFiltersReplacesARestoredFilterButKeepsTheSort() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val first = viewModel(prefs) + first.onTabSelected(TvLibraryTab.Browse) + first.onSortKeySelected(TvLibrarySortOption.Year) + first.onFacetSelectionApplied( + TvCatalogFacetSelection().toggled(TvCatalogFacet.WatchStatus, TvWatchStatusFilter.Unwatched.wireValue), + ) + + // The Browse error screen's Clear filters action, after a restart. + val second = viewModel(prefs) + second.onTabSelected(TvLibraryTab.Browse) + second.onFacetSelectionApplied(TvCatalogFacetSelection()) + + val saved = prefs.savedState(LIBRARY_ID) + assertEquals(emptyMap(), saved?.selections) + assertEquals("year", saved?.sort) + } + + private val createdViewModels = mutableListOf() + + private fun runPrefsTest(block: suspend () -> Unit) = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + createdViewModels.forEach { it.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } + createdViewModels.clear() + Dispatchers.resetMain() + } + } + + private suspend fun viewModel(prefs: BrowsePrefsStore): TvLibraryDetailViewModel { + val client = HttpClient( + MockEngine { request -> + val body = when (request.url.encodedPath) { + "/api/v1/library/$LIBRARY_ID/sections" -> """{"sections":[]}""" + "/api/v2/catalog/filters" -> """{"genres":["Drama"]}""" + else -> """{"total":0,"total_exact":true,"page":{"has_more":false},"items":[]}""" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + } + val viewModel = TvLibraryDetailViewModel( + sectionRepository = SectionRepository(SectionApi(client)), + catalogRepository = CatalogRepository(CatalogApi(client)), + libraryId = LIBRARY_ID, + libraryTitle = "Movies", + libraryType = "movies", + browsePrefs = prefs, + ).also { createdViewModels += it } + // Let the eager Recommended load finish before the test drives tabs. + withContext(Dispatchers.IO) { + withTimeout(30_000) { + while (viewModel.uiState.value.recommendedLoading) delay(10) + } + } + return viewModel + } + + /** BrowsePrefsStore persists nothing without an active server + profile. */ + private class FakeServerRegistry : ServerRegistry { + private val entry = ServerEntry(id = "server-1", url = "https://silo.test", profileId = "profile-1") + override val entries: StateFlow> = MutableStateFlow(listOf(entry)) + override val activeServerId: StateFlow = MutableStateFlow(entry.id) + override val activeEntry: StateFlow = MutableStateFlow(entry) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = entry.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + private companion object { + const val LIBRARY_ID = 7 + } +} From 1b9e181d0f8c7bdccc31ab13ad1a38fc6009e338 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:43:56 -0400 Subject: [PATCH 65/87] feat(artwork): show a faint brand glow with a type mark for missing artwork (#530) * feat(artwork): show a default brand glow for missing artwork A title with no artwork showed a flat dark tile on phone and tablet, and a movie icon on TV whatever its type. It read as a loading bug. Posters, covers and stills now show one default artwork when the image is missing or fails to load: the Silo blue, pink and orange as a soft glow, the same for every title, matching the Apple and web clients. A failed load with a ThumbHash keeps the ThumbHash. ThumbhashImage gets an opt-in defaultArtwork flag, so backdrops, logos, avatars and person photos are unchanged. The TV episode rail and TvPoster drop their own empty-URL branches and go through ThumbhashImage. The default artwork carries the image's content description, so a card with its caption hidden keeps an accessible name. Co-Authored-By: Claude Opus 5.5 * feat(artwork): quieter default artwork with a type mark Missing posters, covers and stills now show the brand glow at about a third of its strength on a lighter base, with a small film, TV, headphones or book mark for the item's type, matching the Apple and web clients. Cards that centre their own play or book glyph over the artwork show the glow without a mark. Co-Authored-By: Claude Opus 5.5 * fix(artwork): fall back after a missing series poster or a bad ThumbHash On an episode page where neither the season nor the series has a poster, the tablet poster box now shows the default artwork once the series lookup finishes instead of staying a flat tile. A ThumbHash that fails to decode no longer blocks the default artwork. Co-Authored-By: Claude Opus 5.5 * fix(artwork): resolve the tablet poster when an episode has no parent An episode without a series ID or season number never runs the series poster lookup, so mark the poster resolved and let the default artwork show. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- android-shared/build.gradle.kts | 2 + android-shared/gradle.lockfile | 6 + .../common/ui/components/DefaultArtwork.kt | 134 ++++++++++++++++++ .../common/ui/components/ThumbhashImage.kt | 35 ++++- .../ui/components/DefaultArtworkKindTest.kt | 30 ++++ .../android/ui/components/BackdropCard.kt | 3 + .../silo/android/ui/components/MediaCard.kt | 2 + .../audiobook/AudiobookDetailContent.kt | 2 + .../ui/screens/book/BookDetailContent.kt | 2 + .../ui/screens/calendar/CalendarScreen.kt | 2 + .../screens/detail/DetailSharedComponents.kt | 9 +- .../android/ui/screens/detail/EpisodeList.kt | 3 + .../ui/screens/detail/ItemDetailScreen.kt | 1 + .../ui/screens/detail/ItemDetailViewModel.kt | 9 +- .../silo/tv/ui/components/TvEpisodeCard.kt | 4 + .../silo/tv/ui/components/TvMediaCard.kt | 2 + .../silo/tv/ui/components/TvMediaRow.kt | 2 + .../silo/tv/ui/components/TvPoster.kt | 41 ++---- .../tv/ui/components/TvReferenceShelfCard.kt | 4 + .../audiobook/TvAudiobookPlayerScreen.kt | 2 + .../ui/screens/calendar/TvCalendarScreen.kt | 2 + .../screens/detail/TvAudiobookDetailHero.kt | 2 + .../ui/screens/detail/TvDetailEpisodeRail.kt | 28 ++-- .../screens/watchparty/TvWatchPartyPicker.kt | 2 + .../ui/screens/watchparty/TvWatchPartyRoom.kt | 2 + .../org/siloserver/silo/tv/ui/theme/Theme.kt | 9 +- .../silo/model/catalog/ItemTypes.kt | 30 ++++ 27 files changed, 317 insertions(+), 53 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/DefaultArtwork.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/components/DefaultArtworkKindTest.kt diff --git a/android-shared/build.gradle.kts b/android-shared/build.gradle.kts index f499ec0691..08d00f119c 100644 --- a/android-shared/build.gradle.kts +++ b/android-shared/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { implementation(compose.ui) implementation(compose.foundation) implementation(compose.runtime) + // DefaultArtwork's marks; both apps already ship this library. + implementation(compose.materialIconsExtended) // Image loading implementation(libs.coil.compose) diff --git a/android-shared/gradle.lockfile b/android-shared/gradle.lockfile index bbc9558157..74f4b16a8f 100644 --- a/android-shared/gradle.lockfile +++ b/android-shared/gradle.lockfile @@ -24,6 +24,10 @@ androidx.compose.foundation:foundation-android:1.7.6=androidDebugAndroidTestComp androidx.compose.foundation:foundation-layout-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.foundation:foundation-layout:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.foundation:foundation:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-core:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-extended-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.material:material-icons-extended:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.runtime:runtime-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.runtime:runtime-annotation-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.runtime:runtime-annotation:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -384,6 +388,8 @@ org.jetbrains.compose.annotation-internal:annotation:1.8.0=androidDebugAndroidTe org.jetbrains.compose.collection-internal:collection:1.7.3=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.foundation:foundation-layout:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.foundation:foundation:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.material:material-icons-core:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.compose.material:material-icons-extended:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.runtime:runtime-saveable:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.runtime:runtime:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.compose.ui:ui-geometry:1.7.3=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/DefaultArtwork.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/DefaultArtwork.kt new file mode 100644 index 0000000000..7bfe21de7d --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/DefaultArtwork.kt @@ -0,0 +1,134 @@ +package org.siloserver.silo.common.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.outlined.Headphones +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.siloserver.silo.common.ui.marquee.MarqueeColors +import org.siloserver.silo.model.catalog.isAudiobookItemType +import org.siloserver.silo.model.catalog.isBookDisplayType +import org.siloserver.silo.model.catalog.isPodcastItemType +import org.siloserver.silo.model.catalog.isTvItemType +import kotlin.math.max + +/** Which mark [DefaultArtwork] centres on its glow. */ +enum class DefaultArtworkKind(internal val mark: ImageVector?) { + Video(Icons.Outlined.Movie), + Tv(Icons.Outlined.Tv), + /** Audiobooks and podcasts. */ + Audiobook(Icons.Outlined.Headphones), + Book(Icons.AutoMirrored.Outlined.MenuBook), + + /** The glow alone, for cards that centre their own play button over + * the artwork; the mark's edges would show around it. */ + GlowOnly(null), + ; + + companion object { + /** Series, seasons and episodes get the TV mark, audiobooks and + * podcasts the headphones, ebooks, comics and manga the book; + * anything else, including an unknown type, the film. */ + fun forItemType(type: String?): DefaultArtworkKind = when { + isTvItemType(type) -> Tv + isAudiobookItemType(type) || isPodcastItemType(type) -> Audiobook + isBookDisplayType(type) -> Book + else -> Video + } + } +} + +/** + * Largest the centred mark grows. Phone and tablet keep the default; the TV + * theme raises it so the mark holds up on a big poster across the room. + */ +val LocalDefaultArtworkMarkMaxSize: ProvidableCompositionLocal = + staticCompositionLocalOf { 40.dp } + +private val DefaultArtworkBase = Color(0xFF16171C) +private val DefaultArtworkMarkTint = ColorFilter.tint(Color.White.copy(alpha = 0.13f)) +private val DefaultArtworkMarkMinSize = 14.dp +private const val DefaultArtworkMarkWidthFraction = 0.24f + +/** + * What a poster, cover or still shows when the title has no artwork: a faint + * glow in the Silo brand colours with a small mark for the kind of title. The + * Apple and web clients draw the same artwork, so keep these values in step + * with them. + * + * [contentDescription] stands in for the missing image's own, so a card whose + * caption is hidden keeps an accessible name. The mark is decoration only. + */ +@Composable +fun DefaultArtwork( + kind: DefaultArtworkKind, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val described = if (contentDescription != null) { + Modifier.semantics { + this.contentDescription = contentDescription + role = Role.Image + } + } else { + Modifier + } + val mark = kind.mark?.let { rememberVectorPainter(it) } + val markMaxSize = LocalDefaultArtworkMarkMaxSize.current + Box( + // Drawn in one pass, no subcomposition: these sit in lazy rows. + modifier = modifier.then(described).drawWithCache { + // Sized from the longer side so a poster and a still get the same + // glow, only cropped differently. + val side = max(size.width, size.height) + val glows = if (side > 0f) { + listOf( + glow(MarqueeColors.BrandBlue, alpha = 0.176f, center = Offset.Zero, radius = side * 0.75f), + glow(MarqueeColors.BrandOrange, alpha = 0.144f, center = Offset(size.width, size.height), radius = side * 0.6f), + glow(MarqueeColors.BrandRed, alpha = 0.112f, center = Offset(size.width * 0.7f, size.height * 0.3f), radius = side * 0.5f), + ) + } else { + emptyList() + } + val minMark = DefaultArtworkMarkMinSize.toPx() + val markSide = (size.width * DefaultArtworkMarkWidthFraction) + .coerceIn(minMark, max(minMark, markMaxSize.toPx())) + onDrawBehind { + drawRect(DefaultArtworkBase) + glows.forEach { drawRect(it) } + if (mark != null && size.width > 0f && size.height > 0f) { + translate((size.width - markSide) / 2f, (size.height - markSide) / 2f) { + with(mark) { draw(Size(markSide, markSide), colorFilter = DefaultArtworkMarkTint) } + } + } + } + }, + ) +} + +private fun glow(color: Color, alpha: Float, center: Offset, radius: Float): Brush = + Brush.radialGradient( + colors = listOf(color.copy(alpha = alpha), color.copy(alpha = 0f)), + center = center, + radius = radius, + ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt index 2a744dbf20..5c118a347b 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/ThumbhashImage.kt @@ -112,6 +112,10 @@ private fun decodeThumbhashPainter(hash: String): BitmapPainter? = * by the URL would miss the cache every time. Must still be unique per image. * @param onError Optional signal that the fetch or decode failed, so the caller * can retire the URL and fall back rather than leave an empty box. + * @param defaultArtwork For posters, covers and stills: when the image is + * missing or fails to load and there is no ThumbHash, show [DefaultArtwork] + * with this kind's mark instead of the flat placeholder. Null keeps the + * flat placeholder. */ @Composable fun ThumbhashImage( @@ -130,6 +134,7 @@ fun ThumbhashImage( onError: (() -> Unit)? = null, /** Applied to the loaded image only — used by the blurred page backdrop. */ colorFilter: ColorFilter? = null, + defaultArtwork: DefaultArtworkKind? = null, ) { val context = LocalContext.current val deferPresentationWhile = LocalImagePresentationDeferral.current @@ -140,6 +145,9 @@ fun ThumbhashImage( val cachedPlaceholder = remember(thumbhash) { thumbhash?.takeIf { it.isNotBlank() }?.let { ThumbhashPainterCache.get(it) } } + // Set when a nonblank ThumbHash fails to decode, so the image can fall back + // to the default artwork rather than wait on a placeholder that never comes. + var thumbhashUnusable by remember(thumbhash) { mutableStateOf(false) } // Plain `val` (not `by`) so the null-checked `placeholder` smart-casts below. val placeholder = produceState(initialValue = cachedPlaceholder, thumbhash, cachedPlaceholder) { if (cachedPlaceholder != null) return@produceState @@ -148,9 +156,10 @@ fun ThumbhashImage( return@produceState } val decoded = withContext(Dispatchers.Default) { decodeThumbhashPainter(hash) } - if (decoded != null) ThumbhashPainterCache.put(hash, decoded) + if (decoded != null) ThumbhashPainterCache.put(hash, decoded) else thumbhashUnusable = true value = decoded }.value + val hasUsableThumbhash = !thumbhash.isNullOrBlank() && !thumbhashUnusable if (url.isNullOrBlank()) { when { @@ -160,11 +169,20 @@ fun ThumbhashImage( contentScale = contentScale, modifier = modifier, ) + // Waits on a ThumbHash still decoding, so it doesn't flash the + // default artwork first. + defaultArtwork != null && !hasUsableThumbhash -> + DefaultArtwork(defaultArtwork, modifier = modifier, contentDescription = contentDescription) !transparent -> Box(modifier = modifier.background(DefaultPlaceholderColor)) } return } + // A failed load keeps the ThumbHash; only without one does a poster fall + // back to the default artwork. + var failed by remember(url) { mutableStateOf(false) } + val failedArtwork = defaultArtwork?.takeIf { failed && !hasUsableThumbhash } + val model = remember(url, decodeSizePx, crossfadeMillis, cacheKey) { ImageRequest.Builder(context) .data(url) @@ -182,6 +200,10 @@ fun ThumbhashImage( } if (deferPresentationWhile == null) { + if (failedArtwork != null) { + DefaultArtwork(failedArtwork, modifier = modifier, contentDescription = contentDescription) + return + } AsyncImage( model = model, contentDescription = contentDescription, @@ -193,7 +215,10 @@ fun ThumbhashImage( error = placeholder, colorFilter = colorFilter, onSuccess = { onSuccess?.invoke() }, - onError = { onError?.invoke() }, + onError = { + failed = true + onError?.invoke() + }, modifier = when { transparent || placeholder != null -> modifier else -> modifier.background(DefaultPlaceholderColor) @@ -243,6 +268,7 @@ fun ThumbhashImage( contentScale = contentScale, modifier = Modifier.fillMaxSize(), ) + failedArtwork != null -> DefaultArtwork(failedArtwork, modifier = Modifier.fillMaxSize()) !transparent -> Box( modifier = Modifier .fillMaxSize() @@ -270,7 +296,10 @@ fun ThumbhashImage( presentFullImage() } }, - onError = { onError?.invoke() }, + onError = { + failed = true + onError?.invoke() + }, modifier = Modifier .fillMaxSize() .drawWithContent { diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/components/DefaultArtworkKindTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/components/DefaultArtworkKindTest.kt new file mode 100644 index 0000000000..97fb60e7df --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/ui/components/DefaultArtworkKindTest.kt @@ -0,0 +1,30 @@ +package org.siloserver.silo.common.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals + +// Apple and web map the same types to the same marks; a change here should +// land on all three clients. +class DefaultArtworkKindTest { + private fun assertKind(expected: DefaultArtworkKind, vararg types: String?) { + for (type in types) { + assertEquals(expected, DefaultArtworkKind.forItemType(type), "type=$type") + } + } + + @Test + fun seriesSeasonsAndEpisodesGetTheTvMark() = + assertKind(DefaultArtworkKind.Tv, "series", "season", "episode", " Episode ") + + @Test + fun audiobooksAndPodcastsGetTheHeadphonesMark() = + assertKind(DefaultArtworkKind.Audiobook, "audiobook", "podcast", "podcasts", "PODCAST") + + @Test + fun readingTypesGetTheBookMark() = + assertKind(DefaultArtworkKind.Book, "book", "ebook", "ebooks", "comic", "comics", "manga", "EBook") + + @Test + fun moviesAndUnknownTypesGetTheFilmMark() = + assertKind(DefaultArtworkKind.Video, "movie", "books", "audiobooks", "", null, "something-new") +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt index 71b06eec75..1fab437480 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.components +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -121,6 +122,8 @@ fun BackdropCard( contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + // The centre glyph below always covers where the mark would sit. + defaultArtwork = DefaultArtworkKind.GlowOnly, ) // Bottom scrim gradient diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt index 1de31d89b4..90a600cebd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.components +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -141,6 +142,7 @@ fun MediaCard( contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(type), ) // Card-overlay badge layer (resolution / audio / ratings, etc.). diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/audiobook/AudiobookDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/audiobook/AudiobookDetailContent.kt index e151e5a026..1c0d547169 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/audiobook/AudiobookDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/audiobook/AudiobookDetailContent.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.siloserver.silo.android.ui.screens.detail.CircleActionButton import org.siloserver.silo.audiobook.buildAudiobookTimeline +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.audiobook.AudiobookNarration import org.siloserver.silo.model.catalog.FileVersion @@ -190,6 +191,7 @@ fun AudiobookDetailContent( modifier = Modifier .size(AudiobookCoverSizeDp.dp) .clip(RoundedCornerShape(12.dp)), + defaultArtwork = DefaultArtworkKind.Audiobook, ) Text( text = detail.title, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/book/BookDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/book/BookDetailContent.kt index 6fbf617537..ce5a8c524e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/book/BookDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/book/BookDetailContent.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.siloserver.silo.android.ui.screens.detail.CircleActionButton +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.book.BookFormat import org.siloserver.silo.model.catalog.FileVersion @@ -187,6 +188,7 @@ fun BookDetailContent( .width(BookCoverWidthDp.dp) .aspectRatio(2f / 3f) .clip(RoundedCornerShape(12.dp)), + defaultArtwork = DefaultArtworkKind.forItemType(detail.type), ) Text( text = detail.title, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt index 41ad2f6a49..e965b7838c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt @@ -83,6 +83,7 @@ import org.siloserver.silo.android.ui.navigation.LocalBottomChromeInset import org.siloserver.silo.common.calendar.localDisplayAirTime import org.siloserver.silo.common.cards.LocalCardPresentation import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.calendar.CalendarBadge import org.siloserver.silo.model.calendar.CalendarFilter @@ -707,6 +708,7 @@ private fun CalendarEventCard( thumbhash = item.posterThumbhash, contentDescription = item.title, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(item.type), ) // Badge pills, top-leading. overlayPadding (phone) = 6. diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt index 26c18b7c5d..c0af99d6bf 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt @@ -86,6 +86,7 @@ import org.siloserver.silo.android.ui.theme.PillShape import org.siloserver.silo.common.settings.titleLogoUrl import org.siloserver.silo.common.ui.RatingEntry import org.siloserver.silo.common.ui.WholeTokenRow +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.DisplayRating import org.siloserver.silo.model.catalog.ExternalRatings @@ -115,6 +116,9 @@ data class DetailPortraitArtwork( val url: String?, val thumbhash: String?, val reserveSpace: Boolean = false, + /** False while a fallback poster may still arrive, so a blank [url] keeps + * the box empty rather than showing the default artwork. */ + val isResolved: Boolean = true, ) /** @@ -254,13 +258,16 @@ private fun ExpandedDetailHero( shape = RoundedCornerShape(12.dp), ), ) { - if (!portraitArtwork.url.isNullOrBlank()) { + // A blank URL can mean the series poster is still loading, + // so the default artwork waits until the poster is resolved. + if (!portraitArtwork.url.isNullOrBlank() || portraitArtwork.isResolved) { ThumbhashImage( url = portraitArtwork.url, thumbhash = portraitArtwork.thumbhash, contentDescription = detail.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(detail.type), ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt index 7373aeefbd..29dfc16b0a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.distinctUntilChanged import org.siloserver.silo.android.ui.util.formatCardDate import org.siloserver.silo.android.ui.util.playbackResumePosition +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.EpisodeListItem import kotlin.math.abs @@ -202,6 +203,8 @@ private fun EpisodeRailCard( thumbhash = episode.stillThumbhash, contentDescription = episode.title, modifier = Modifier.fillMaxSize(), + // The play button covers the centre, so no mark under it. + defaultArtwork = if (onPlayClick != null) DefaultArtworkKind.GlowOnly else DefaultArtworkKind.Tv, ) if (episode.userData?.played == true) { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index aa4a54a40a..f3941f2bf6 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -856,6 +856,7 @@ fun ItemDetailScreen( state.episodeSeriesPosterThumbhash }, reserveSpace = true, + isResolved = seasonPosterUrl != null || state.episodeSeriesPosterResolved, ) } else { DetailPortraitArtwork( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt index b7c7d25d3a..e4643cc816 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt @@ -67,6 +67,8 @@ data class ItemDetailUiState( /** Parent series title on a standalone episode page, for download grouping. */ val episodeSeriesTitle: String? = null, val episodeSeriesPosterThumbhash: String? = null, + /** The parent-series poster lookup has finished, with or without a poster. */ + val episodeSeriesPosterResolved: Boolean = false, /** * Route-scoped episode lists keyed by season. Unlike the repository's * durable network-fallback cache, this map is UI-first: once a season has @@ -425,6 +427,9 @@ class ItemDetailViewModel( val seasonNumber = detail.seasonNumber if (seriesId != null && seasonNumber != null) { loadEpisodeSiblings(seriesId, seasonNumber) + } else { + // No parent to look up, so no series poster is coming. + _uiState.update { it.copy(episodeSeriesPosterResolved = true) } } } // For books, learn whether the server converts Kindle formats to @@ -702,10 +707,12 @@ class ItemDetailViewModel( episodeSeriesPosterUrl = result.data.posterUrl, episodeSeriesPosterThumbhash = result.data.posterThumbhash, episodeSeriesTitle = result.data.title, + episodeSeriesPosterResolved = true, ) } } - else -> { /* Series poster fallback is optional. */ } + // Series poster fallback is optional. + else -> _uiState.update { it.copy(episodeSeriesPosterResolved = true) } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt index a103ef4f4d..701767ee3f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.components +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -68,6 +69,8 @@ fun TvEpisodeCard( year: Int? = null, seasonNumber: Int? = null, episodeNumber: Int? = null, + /** Catalog item type; picks the mark on the default artwork. */ + mediaType: String? = null, progress: Float? = null, width: Dp = tvEpisodeCardWidth(), focusRequester: FocusRequester? = null, @@ -119,6 +122,7 @@ fun TvEpisodeCard( contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(mediaType), ) Box( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt index c5d6aa0816..b10fdd5aad 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.components +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -147,6 +148,7 @@ fun TvMediaCard( contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(mediaType), ) // Card-overlay badge layer. Over the poster, under the diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt index 9a42b2317d..0b1c337236 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt @@ -347,6 +347,7 @@ fun TvMediaRow( imageThumbhash = rowItem.backdropThumbhash, subtitle = rowItem.shelfSubtitle, detail = if (showProgress) rowItem.remainingMinutes?.let { "${it}m left" } else null, + mediaType = item.type, progress = rowItem.progress, onClick = { onItemClick(item.contentId) }, focusRequester = itemFocusRequester, @@ -363,6 +364,7 @@ fun TvMediaRow( seriesTitle = item.seriesTitle, seasonNumber = item.seasonNumber, episodeNumber = item.episodeNumber, + mediaType = item.type, progress = rowItem.progress, year = item.year.takeIf { it > 0 }, onClick = { onItemClick(item.contentId) }, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt index 83cfb576b4..cba2e355a3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt @@ -3,14 +3,8 @@ package org.siloserver.silo.tv.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Movie import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -18,10 +12,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme -import coil3.compose.AsyncImage +import org.siloserver.silo.common.ui.components.DefaultArtworkKind +import org.siloserver.silo.common.ui.components.ThumbhashImage /** - * Poster image with a solid fallback + movie icon when the URL is missing or fails to load. + * Poster image that shows the default artwork when the URL is missing or fails to load. * * Sized according to the parent Modifier; the caller owns the aspect ratio. For 2:3 posters, * pass `Modifier.size(width, width * 3f / 2f)` (or use BoxWithConstraints). @@ -33,30 +28,20 @@ fun TvPoster( contentDescription: String?, modifier: Modifier = Modifier, cornerRadius: Dp = 8.dp, + defaultArtwork: DefaultArtworkKind = DefaultArtworkKind.Video, ) { - val shape = RoundedCornerShape(cornerRadius) Box( modifier = modifier - .clip(shape) + .clip(RoundedCornerShape(cornerRadius)) .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, ) { - if (imageUrl.isNullOrBlank()) { - Icon( - imageVector = Icons.Filled.Movie, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(48.dp) - .padding(8.dp), - ) - } else { - AsyncImage( - model = imageUrl, - contentDescription = contentDescription, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } + ThumbhashImage( + url = imageUrl, + thumbhash = null, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + defaultArtwork = defaultArtwork, + ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt index a85917ea1a..403560fffc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.tv.ui.theme.ProgressFill import org.siloserver.silo.tv.ui.theme.ProgressTrack @@ -47,6 +48,8 @@ fun TvReferenceShelfCard( modifier: Modifier = Modifier, subtitle: String? = null, detail: String? = null, + /** Catalog item type; picks the mark on the default artwork. */ + mediaType: String? = null, progress: Float? = null, width: Dp = 268.dp, focusRequester: FocusRequester? = null, @@ -83,6 +86,7 @@ fun TvReferenceShelfCard( contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(mediaType), ) Box( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt index 4f3d26af1b..1afd033cf7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt @@ -73,6 +73,7 @@ import androidx.media3.session.SessionToken import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.player.AudiobookPlayerViewModel import org.siloserver.silo.common.player.SiloPlaybackService import org.siloserver.silo.model.catalog.VersionChapter @@ -363,6 +364,7 @@ fun TvAudiobookPlayerScreen( .size(metrics.coverSizeDp.dp) .shadow(metrics.coverShadowDp.dp, RoundedCornerShape(metrics.coverCornerRadiusDp.dp)), cornerRadius = metrics.coverCornerRadiusDp.dp, + defaultArtwork = DefaultArtworkKind.Audiobook, ) Spacer(Modifier.width(metrics.coverGapDp.dp)) Column( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt index e818e1b954..90080865ef 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt @@ -89,6 +89,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.common.calendar.localDisplayAirTime import org.siloserver.silo.common.cards.LocalCardPresentation +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.calendar.CalendarBadge import org.siloserver.silo.model.calendar.CalendarFilter @@ -1453,6 +1454,7 @@ private fun CalendarEventCard( contentDescription = item.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(item.type), ) // Badge pills (top-leading) — tvOS CalendarBadgePill. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvAudiobookDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvAudiobookDetailHero.kt index 0aafa8a8b4..1021507354 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvAudiobookDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvAudiobookDetailHero.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.tv.ui.navigation.TvSubtitleLaunchSelection @@ -109,6 +110,7 @@ internal fun TvAudiobookDetailHero( .size(180.dp) .shadow(20.dp, RoundedCornerShape(14.dp)), cornerRadius = 14.dp, + defaultArtwork = DefaultArtworkKind.Audiobook, ) Column( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt index a98811ec5e..bb316043f7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Movie import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -66,6 +65,7 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text import org.siloserver.silo.common.cards.LocalCardPresentation +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.tv.ui.components.TvMediaCardActions @@ -429,24 +429,14 @@ private fun TvDetailEpisodeCard( }, ), ) { - if (!episode.stillUrl.isNullOrBlank()) { - ThumbhashImage( - url = episode.stillUrl, - thumbhash = episode.stillThumbhash, - contentDescription = episode.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else { - Icon( - imageVector = Icons.Filled.Movie, - contentDescription = null, - tint = SiloSecondaryText, - modifier = Modifier - .size(24.dp) - .align(Alignment.Center), - ) - } + ThumbhashImage( + url = episode.stillUrl, + thumbhash = episode.stillThumbhash, + contentDescription = episode.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.Tv, + ) if (episode.userData?.played == true) { Box( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt index dfc9ae7322..eb21d24902 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt @@ -54,6 +54,7 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import kotlinx.coroutines.delay import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.model.catalog.BrowseItem import org.siloserver.silo.model.section.SectionItem import org.siloserver.silo.model.watchtogether.ItemMemberState @@ -432,6 +433,7 @@ private fun TvPickerConfirmation( imageUrl = choice.posterUrl, contentDescription = null, modifier = Modifier.size(width = 110.dp, height = 165.dp), + defaultArtwork = DefaultArtworkKind.forItemType(choice.item.contentType), ) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { TvPartyEyebrow(if (stages) "Watch together" else "Suggest to the party") diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt index c1e2f52d2a..8780a01b1d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt @@ -63,6 +63,7 @@ import androidx.tv.material3.Glow import androidx.tv.material3.Icon import androidx.tv.material3.Surface import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.model.watchtogether.GuestControlPolicy import org.siloserver.silo.model.watchtogether.MemberRole import org.siloserver.silo.model.watchtogether.RoomPhase @@ -714,6 +715,7 @@ private fun TvPartySuggestionCard( contentDescription = suggestion.title, cornerRadius = 6.dp, modifier = Modifier.fillMaxSize(), + defaultArtwork = DefaultArtworkKind.forItemType(suggestion.contentType), ) if (badge != null) { Box(modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) { badge() } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/theme/Theme.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/theme/Theme.kt index bd7ba2d55f..8da4492e41 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/theme/Theme.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/theme/Theme.kt @@ -10,6 +10,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Shapes import androidx.tv.material3.darkColorScheme +import org.siloserver.silo.common.ui.components.LocalDefaultArtworkMarkMaxSize private val SiloTvDarkColorScheme = darkColorScheme( primary = SiloPrimary, @@ -58,6 +59,9 @@ private val SiloTvShapes = Shapes( private const val TvUiFontScale = 0.86f +/** Missing-artwork marks may grow larger on TV posters than on phone. */ +private val TvDefaultArtworkMarkMaxSize = 64.dp + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun SiloTvTheme( @@ -77,7 +81,10 @@ fun SiloTvTheme( typography = SiloTvTypography, shapes = SiloTvShapes, ) { - CompositionLocalProvider(LocalDensity provides tvDensity) { + CompositionLocalProvider( + LocalDensity provides tvDensity, + LocalDefaultArtworkMarkMaxSize provides TvDefaultArtworkMarkMaxSize, + ) { content() } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ItemTypes.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ItemTypes.kt index 5674dd04f6..5417cf379c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ItemTypes.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/ItemTypes.kt @@ -22,5 +22,35 @@ fun isAudiobookItemType(type: String?): Boolean = fun isEpisodeItemType(type: String?): Boolean = normalizedItemType(type) == "episode" +private val tvItemTypes = setOf( + "series", + "season", + "episode", +) + +fun isTvItemType(type: String?): Boolean = + normalizedItemType(type) in tvItemTypes + fun isBookLikeItemType(type: String?): Boolean = normalizedItemType(type) in bookLikeItemTypes + +/** + * Reading types for display only (the missing-artwork mark), which also + * accepts the plurals some servers send. Routing stays on + * [isBookLikeItemType], which takes the singular catalog types. + */ +private val bookDisplayTypes = bookLikeItemTypes + setOf( + "ebooks", + "comics", +) + +fun isBookDisplayType(type: String?): Boolean = + normalizedItemType(type) in bookDisplayTypes + +private val podcastItemTypes = setOf( + "podcast", + "podcasts", +) + +fun isPodcastItemType(type: String?): Boolean = + normalizedItemType(type) in podcastItemTypes From 021c5eae81d2d47155946535b233838f57157910 Mon Sep 17 00:00:00 2001 From: d3v1l1989 <64855033+d3v1l1989@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:26:04 +0200 Subject: [PATCH 66/87] fix(playback): decode DTS with FFmpeg when the HDMI sink has no DTS (#479) On Amlogic boxes connected to TVs without DTS, the platform DTS decoder played silent. When the active HDMI sink lists compressed codecs but no DTS, DTS now goes to the bundled FFmpeg decoder, and DTS passthrough is no longer advertised to the server for that sink. Co-Authored-By: Claude Opus 5.5 --- .../common/player/AudioCapabilityManager.kt | 9 +- .../silo/common/player/SiloPlayerFactory.kt | 38 +++-- .../common/player/audio/HdmiSinkDtsSupport.kt | 140 ++++++++++++++++++ .../audio/PassthroughSuppressionRegistry.kt | 3 +- .../player/audio/HdmiSinkDtsSupportTest.kt | 62 ++++++++ .../PassthroughSuppressionRegistryTest.kt | 23 +++ 6 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupport.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupportTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AudioCapabilityManager.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AudioCapabilityManager.kt index eccab897f5..67be0da07f 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AudioCapabilityManager.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/AudioCapabilityManager.kt @@ -15,6 +15,7 @@ import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.audio.AudioCapabilities import androidx.media3.exoplayer.audio.AudioCapabilitiesReceiver +import org.siloserver.silo.common.player.audio.HdmiSinkDtsSupport import org.siloserver.silo.model.playback.AudioPassthroughCapabilities import org.siloserver.silo.model.playback.AudioPassthroughEntry import kotlinx.coroutines.flow.MutableStateFlow @@ -224,8 +225,13 @@ class AudioCapabilityManager( } private fun mapCapabilities(caps: AudioCapabilities): AudioPassthroughCapabilities { + // The platform can accept DTS for a sink that does not decode it (an + // Amlogic box in front of an LG 2025 TV); never claim passthrough there. + val sinkDecodesDts by lazy { HdmiSinkDtsSupport.hdmiSinkDecodesDts(appContext) } val supportedEncodings = encodingSupport.filter { support -> - Build.VERSION.SDK_INT >= support.minSdk && caps.supportsEncoding(support.encoding) + Build.VERSION.SDK_INT >= support.minSdk && + caps.supportsEncoding(support.encoding) && + (support.codec !in DTS_CODECS || sinkDecodesDts) } val codecs = supportedEncodings.map(EncodingSupport::codec) @@ -428,6 +434,7 @@ class AudioCapabilityManager( private companion object { const val TAG = "AudioCapabilityMgr" const val ROUTE_HASH_BYTES = 16 + val DTS_CODECS = setOf("dts", "dts_hd") val encodingSupport = listOf( EncodingSupport("ac3", AudioFormat.ENCODING_AC3), diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt index 67cd2c9d94..c6db320a3b 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SiloPlayerFactory.kt @@ -40,6 +40,7 @@ import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory import androidx.media3.extractor.ts.TsExtractor import org.siloserver.silo.common.player.audio.DelayAudioProcessor +import org.siloserver.silo.common.player.audio.HdmiSinkDtsSupport import org.siloserver.silo.common.player.audio.PassthroughSuppressingAudioSink import org.siloserver.silo.common.player.subtitle.OffsetSubtitleParserFactory import org.siloserver.silo.common.player.subtitle.PgsSupExtractor @@ -180,9 +181,10 @@ class SiloPlayerFactory( ): ExoPlayer { // When the build flag is on and the current ABI's JNI library loads, // extension renderers (FFmpeg audio) follow the platform renderers and - // fill only codec gaps. This keeps native passthrough/MediaCodec paths + // fill codec gaps. This keeps native passthrough/MediaCodec paths // preferred while retaining a last-resort local decoder for - // forced-original and recovery cases. + // forced-original and recovery cases. The one exception is DTS on an + // HDMI sink without DTS, which goes to FFmpeg (see [HdmiSinkDtsSupport]). // // When the flag is off (compile-time bisect), we set _MODE_OFF // rather than _MODE_ON so extension renderers are *not even @@ -201,11 +203,17 @@ class SiloPlayerFactory( // across players because Koin gives us a single DelayAudioProcessor // instance; in practice SiloPlaybackService creates exactly // one ExoPlayer per process so there's no contention. - val audioSink: AudioSink = PassthroughSuppressingAudioSink(DefaultAudioSink.Builder(context) - .setAudioProcessorChain( - DefaultAudioSink.DefaultAudioProcessorChain(delayProcessor), - ) - .build()) + val audioSink: AudioSink = PassthroughSuppressingAudioSink( + DefaultAudioSink.Builder(context) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain(delayProcessor), + ) + .build(), + forceDecode = { format -> + preferFfmpegAudio && + HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(context, format.sampleMimeType) + }, + ) val media3RenderersFactory = object : DefaultRenderersFactory(context) { override fun buildAudioSink( @@ -274,10 +282,18 @@ class SiloPlayerFactory( setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> val infos = MediaCodecSelector.DEFAULT .getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder) - if (mimeType == MimeTypes.VIDEO_H265) { - infos.filter { it.hardwareAccelerated }.ifEmpty { infos } - } else { - infos + when { + mimeType == MimeTypes.VIDEO_H265 -> + infos.filter { it.hardwareAccelerated }.ifEmpty { infos } + // Amlogic boxes expose a platform DTS decoder that played + // silent on a sink without DTS (LG 2025 TVs). Hiding it + // hands the track to FFmpeg, which outputs multichannel PCM. + // A DTS:X track also queries plain DTS as its alternative + // MIME, so on such a sink it is left without a local + // decoder and goes back to the server, which beats silence. + preferFfmpegAudio && !requiresSecureDecoder && + HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(context, mimeType) -> emptyList() + else -> infos } } } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupport.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupport.kt new file mode 100644 index 0000000000..d37f1fb67f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupport.kt @@ -0,0 +1,140 @@ +package org.siloserver.silo.common.player.audio + +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager +import android.os.Build +import android.util.Log +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import org.siloserver.silo.common.player.FfmpegAudioSupport + +/** + * Whether the HDMI sink itself decodes DTS, read from the encodings the sink's + * output port lists (`AudioDeviceInfo.getEncodings()`) rather than from the + * platform's passthrough answer (Media3 `AudioCapabilities`). + * + * The two disagree on Amlogic boxes (onn 4K Pro, Mi Box S): the platform + * accepts DTS encodings and exposes a platform DTS decoder because the SoC + * handles DTS itself, while the port of a sink with no DTS descriptor (every + * LG 2025 TV) lists only Dolby codecs. On an onn 4K Pro in front of an LG C5, + * DTS through that platform decoder played silent; the bundled FFmpeg decoder + * plays it as multichannel PCM. + */ +@UnstableApi +object HdmiSinkDtsSupport { + private const val TAG = "HdmiSinkDtsSupport" + + // AudioFormat constants newer than minSdk, as literals so they are read on + // every platform level (a HAL can report them before the SDK names them). + private const val ENCODING_PCM_24BIT_PACKED = 21 + private const val ENCODING_PCM_32BIT = 22 + private const val ENCODING_DTS_UHD_P1 = 27 + private const val ENCODING_DTS_HD_MA = 29 + private const val ENCODING_DTS_UHD_P2 = 30 + + private val dtsMimeTypes = setOf( + MimeTypes.AUDIO_DTS, + MimeTypes.AUDIO_DTS_EXPRESS, + MimeTypes.AUDIO_DTS_HD, + ) + + private val dtsSinkEncodings = setOf( + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_DTS_HD, + ENCODING_DTS_UHD_P1, + ENCODING_DTS_HD_MA, + ENCODING_DTS_UHD_P2, + ) + + // Neither PCM nor the IEC 61937 transport says which codecs a sink decodes. + private val nonCodecEncodings = setOf( + AudioFormat.ENCODING_DEFAULT, + AudioFormat.ENCODING_PCM_8BIT, + AudioFormat.ENCODING_PCM_16BIT, + AudioFormat.ENCODING_PCM_FLOAT, + ENCODING_PCM_24BIT_PACKED, + ENCODING_PCM_32BIT, + AudioFormat.ENCODING_IEC61937, + ) + + @Volatile private var lastLoggedVerdict: String? = null + + /** + * Whether the sinks in [hdmiSinkEncodings] (one `AudioDeviceInfo.getEncodings()` + * array each) decode DTS. A sink counts as described only when it lists a + * compressed codec; with no sink, or any undescribed one, the answer is + * true so a HAL that reports nothing keeps today's behavior. + */ + internal fun sinkAdvertisesDts(hdmiSinkEncodings: List): Boolean { + val undescribed = { encodings: IntArray -> encodings.all { it in nonCodecEncodings } } + if (hdmiSinkEncodings.isEmpty() || hdmiSinkEncodings.any(undescribed)) return true + return hdmiSinkEncodings.any { encodings -> encodings.any { it in dtsSinkEncodings } } + } + + /** + * True when a DTS track of [mimeType] must be decoded by FFmpeg: the sink + * does not decode DTS and the packaged FFmpeg build decodes this MIME. + * Without FFmpeg the platform path stays, since it is then the only + * decoder there is. + */ + internal fun shouldDecodeWithFfmpeg( + mimeType: String?, + ffmpegSupportsMimeType: (String) -> Boolean, + sinkDecodesDts: () -> Boolean, + ): Boolean = mimeType != null && + mimeType in dtsMimeTypes && + ffmpegSupportsMimeType(mimeType) && + !sinkDecodesDts() + + /** [shouldDecodeWithFfmpeg] against the HDMI sink on the active media route. */ + fun shouldDecodeWithFfmpeg(context: Context, mimeType: String?): Boolean = + shouldDecodeWithFfmpeg( + mimeType = mimeType, + ffmpegSupportsMimeType = FfmpegAudioSupport::supportsMimeType, + sinkDecodesDts = { hdmiSinkDecodesDts(context) }, + ) + + /** + * [sinkAdvertisesDts] against the HDMI sink on the active media route. + * Inspection failures answer true, keeping the platform's own handling. + */ + fun hdmiSinkDecodesDts(context: Context): Boolean = runCatching { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val encodings = activeHdmiSinkEncodings(audioManager) + sinkAdvertisesDts(encodings).also { decodes -> + val verdict = "$decodes ${encodings.joinToString { it.contentToString() }}" + if (verdict != lastLoggedVerdict) { + lastLoggedVerdict = verdict + Log.i(TAG, "HDMI sink DTS support: $verdict") + } + } + }.getOrElse { error -> + Log.w(TAG, "HDMI sink inspection failed; keeping platform DTS handling", error) + true + } + + /** + * Encodings of the HDMI outputs a movie plays through. API 33+ reads the + * active media route; a route with no HDMI device (USB, Bluetooth, the + * phone speaker) yields none, which [sinkAdvertisesDts] treats as + * undescribed. Older releases can only list every connected HDMI output. + */ + private fun activeHdmiSinkEncodings(audioManager: AudioManager): List { + val devices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val attrs = android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) + .build() + audioManager.getAudioDevicesForAttributes(attrs) + } else { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList() + } + return devices.filter { isHdmi(it.type) }.map(AudioDeviceInfo::getEncodings) + } + + private fun isHdmi(type: Int): Boolean = type == AudioDeviceInfo.TYPE_HDMI || + type == AudioDeviceInfo.TYPE_HDMI_ARC || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && type == AudioDeviceInfo.TYPE_HDMI_EARC) +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistry.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistry.kt index 0575e0ab60..0fc55af49e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistry.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistry.kt @@ -85,12 +85,13 @@ object PassthroughSuppressionRegistry : PassthroughSuppressionScope { @UnstableApi class PassthroughSuppressingAudioSink( private val delegate: AudioSink, + private val forceDecode: (Format) -> Boolean = { false }, ) : ForwardingAudioSink(delegate) { override fun supportsFormat(format: Format): Boolean = getFormatSupport(format) != AudioSink.SINK_FORMAT_UNSUPPORTED override fun getFormatSupport(format: Format): Int = - if (PassthroughSuppressionRegistry.isSuppressed(format)) { + if (PassthroughSuppressionRegistry.isSuppressed(format) || forceDecode(format)) { // Returning SUPPORTED_WITH_TRANSCODING still makes // MediaCodecAudioRenderer treat the encoded format as sink- // playable and select bypass/passthrough again. Mark only the diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupportTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupportTest.kt new file mode 100644 index 0000000000..51a5aae37d --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/HdmiSinkDtsSupportTest.kt @@ -0,0 +1,62 @@ +package org.siloserver.silo.common.player.audio + +import android.media.AudioFormat +import androidx.media3.common.MimeTypes +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HdmiSinkDtsSupportTest { + private val pcm = AudioFormat.ENCODING_PCM_16BIT + private val iec = AudioFormat.ENCODING_IEC61937 + + /** LG 2025 TV: Dolby codecs listed, no DTS — the onn 4K Pro silent-DTS case. */ + @Test + fun dolbyOnlySinkDoesNotDecodeDts() { + val lgC5 = intArrayOf(pcm, AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_E_AC3, AudioFormat.ENCODING_E_AC3_JOC, iec) + assertFalse(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(lgC5))) + } + + @Test + fun sinkListingDtsDecodesDts() { + val avr = intArrayOf(pcm, AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_DTS, AudioFormat.ENCODING_DTS_HD) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(avr))) + } + + /** API 34 DTS-HD MA (29) arrives as a literal on older platforms too. */ + @Test + fun dtsHdMaLiteralCountsAsDts() { + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(intArrayOf(pcm, AudioFormat.ENCODING_AC3, 29)))) + } + + /** PCM and IEC 61937 name no codec, so the platform keeps the decision. */ + @Test + fun undescribedSinkKeepsPlatformBehavior() { + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(intArrayOf(pcm, iec)))) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(intArrayOf()))) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(emptyList())) + } + + @Test + fun anyUndescribedSinkKeepsPlatformBehavior() { + val dolbyOnly = intArrayOf(pcm, AudioFormat.ENCODING_AC3) + assertTrue(HdmiSinkDtsSupport.sinkAdvertisesDts(listOf(dolbyOnly, intArrayOf(pcm)))) + } + + @Test + fun dtsGoesToFfmpegOnlyWhenTheSinkLacksDtsAndFfmpegDecodesIt() { + val ffmpegDecodes = { _: String -> true } + assertTrue(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS_HD, ffmpegDecodes) { false }) + assertTrue(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS, ffmpegDecodes) { false }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS_HD, ffmpegDecodes) { true }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_DTS_HD, { false }) { false }) + } + + @Test + fun nonDtsCodecsNeverGoToFfmpeg() { + val ffmpegDecodes = { _: String -> true } + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_TRUEHD, ffmpegDecodes) { false }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(MimeTypes.AUDIO_E_AC3, ffmpegDecodes) { false }) + assertFalse(HdmiSinkDtsSupport.shouldDecodeWithFfmpeg(null, ffmpegDecodes) { false }) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistryTest.kt index 69f7610808..c37c0e8f3f 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/audio/PassthroughSuppressionRegistryTest.kt @@ -90,4 +90,27 @@ class PassthroughSuppressionRegistryTest { assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, sink.getFormatSupport(trueHdEightChannel)) assertFalse(sink.supportsFormat(trueHdEightChannel)) } + + @Test + fun forceDecodeMarksOnlyMatchingEncodedFormatUnsupported() { + val delegate = Proxy.newProxyInstance( + AudioSink::class.java.classLoader, + arrayOf(AudioSink::class.java), + ) { _, method, _ -> + when (method.name) { + "getFormatSupport" -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY + else -> error("Unexpected delegate call: ${method.name}") + } + } as AudioSink + val sink = PassthroughSuppressingAudioSink(delegate) { format -> + format.sampleMimeType == MimeTypes.AUDIO_DTS_HD + } + val dtsHd = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_DTS_HD).setChannelCount(8).build() + val pcm = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_RAW).setChannelCount(8).build() + + PassthroughSuppressionRegistry.beginAttempt("force-decode-attempt") + assertEquals(AudioSink.SINK_FORMAT_UNSUPPORTED, sink.getFormatSupport(dtsHd)) + assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, sink.getFormatSupport(pcm)) + assertTrue(sink.supportsFormat(pcm)) + } } From 67c58fc0369b94a64649594719c0f3b3bb036189 Mon Sep 17 00:00:00 2001 From: RXWatcher Date: Fri, 9 Oct 2026 22:18:45 +0200 Subject: [PATCH 67/87] fix(tv): keep a mixed library's Movies and Series browse filters apart (#532) Android TV saved Browse sort and filters per library, so a mixed library's Movies and Series tabs shared one saved state and one "Preserve sort & filters" toggle. A genre or sort chosen under Movies reopened under Series. Save them per media scope: a scoped view of a mixed library uses its own `.type-` key in BrowsePrefsStore. Unscoped libraries keep their existing keys, so saved state from before this change still restores. Co-authored-by: rxwatcher Co-authored-by: Claude Opus 5.5 --- .../silo/common/settings/BrowsePrefsStore.kt | 27 +++++++------- .../library/TvLibraryDetailViewModel.kt | 8 ++--- .../library/TvLibraryBrowsePrefsTest.kt | 35 +++++++++++++++++-- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/BrowsePrefsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/BrowsePrefsStore.kt index dcfe6a091d..290c623100 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/BrowsePrefsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/BrowsePrefsStore.kt @@ -15,6 +15,9 @@ import org.siloserver.silo.network.ServerRegistry * The phone and TV apps each pass their own [keyPrefix] (`android`, * `androidtv`), as Apple keeps `ios.` / `tv.` / `mac.` apart. * + * A Movies- or Series-scoped view of a mixed library saves under its own + * `.type-` key, so the two views keep separate filters. + * * Gated by a user-facing "Preserve sort & filters" toggle (default ON); * turning it off clears the saved state. */ @@ -26,35 +29,35 @@ class BrowsePrefsStore( private val prefs = context.getSharedPreferences("browse_prefs", Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } - private fun base(libraryId: Int?): String? { + private fun base(libraryId: Int?, mediaScope: String? = null): String? { val serverId = serverRegistry.activeServerId.value ?: "default" val profileId = serverRegistry.activeEntry.value?.profileId ?.takeIf { it.isNotBlank() } ?: return null val lib = libraryId?.toString() ?: "all" - return "$keyPrefix.browsePrefs.$serverId.$profileId.$lib" + return "$keyPrefix.browsePrefs.$serverId.$profileId.$lib" + (mediaScope?.let { ".type-$it" } ?: "") } - fun savedState(libraryId: Int?): CatalogFilterState? { - if (!preserveEnabled(libraryId)) return null - val key = base(libraryId) ?: return null + fun savedState(libraryId: Int?, mediaScope: String? = null): CatalogFilterState? { + if (!preserveEnabled(libraryId, mediaScope)) return null + val key = base(libraryId, mediaScope) ?: return null val raw = prefs.getString("$key.state", null) ?: return null return runCatching { json.decodeFromString(raw) }.getOrNull() } - fun saveState(libraryId: Int?, state: CatalogFilterState) { - if (!preserveEnabled(libraryId)) return - val key = base(libraryId) ?: return + fun saveState(libraryId: Int?, state: CatalogFilterState, mediaScope: String? = null) { + if (!preserveEnabled(libraryId, mediaScope)) return + val key = base(libraryId, mediaScope) ?: return prefs.edit { putString("$key.state", json.encodeToString(state)) } } /** Default ON when the key is absent (iOS parity). */ - fun preserveEnabled(libraryId: Int?): Boolean { - val key = base(libraryId) ?: return false + fun preserveEnabled(libraryId: Int?, mediaScope: String? = null): Boolean { + val key = base(libraryId, mediaScope) ?: return false return prefs.getBoolean("$key.preserve", true) } - fun setPreserveEnabled(libraryId: Int?, enabled: Boolean) { - val key = base(libraryId) ?: return + fun setPreserveEnabled(libraryId: Int?, enabled: Boolean, mediaScope: String? = null) { + val key = base(libraryId, mediaScope) ?: return prefs.edit { putBoolean("$key.preserve", enabled) if (!enabled) remove("$key.state") diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt index b24af0b3e7..e2cbbddf5d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -202,7 +202,7 @@ class TvLibraryDetailViewModel( UiState( title = libraryTitle, libraryType = libraryType, - preserveFilters = browsePrefs?.preserveEnabled(libraryId) ?: true, + preserveFilters = browsePrefs?.preserveEnabled(libraryId, mediaScope) ?: true, ), ) val uiState: StateFlow = _uiState.asStateFlow() @@ -405,7 +405,7 @@ class TvLibraryDetailViewModel( * current Browse sort and filters at once (tvOS `setPreserveEnabled`). */ fun onPreserveFiltersChanged(enabled: Boolean) { - browsePrefs?.setPreserveEnabled(libraryId, enabled) + browsePrefs?.setPreserveEnabled(libraryId, enabled, mediaScope) _uiState.update { it.copy(preserveFilters = enabled) } if (enabled) saveBrowseFilter(_uiState.value) } @@ -426,7 +426,7 @@ class TvLibraryDetailViewModel( // sections are fixed presets. private fun saveBrowseFilter(state: UiState) { if (state.selectedTab != TvLibraryTab.Browse) return - browsePrefs?.saveState(libraryId, state.browseFilter.toSavedState()) + browsePrefs?.saveState(libraryId, state.browseFilter.toSavedState(), mediaScope) } private var recommendedGeneration = 0L @@ -832,7 +832,7 @@ class TvLibraryDetailViewModel( TvLibraryTab.Collections -> this // Browse restores the saved sort and filters, else lands on the // tvOS default view: Title A–Z, no facets. - TvLibraryTab.Browse -> browsePrefs?.savedState(libraryId)?.toTvBrowseFilter() ?: copy( + TvLibraryTab.Browse -> browsePrefs?.savedState(libraryId, mediaScope)?.toTvBrowseFilter() ?: copy( genre = null, namePrefix = null, sort = TvLibrarySortOption.Title.wireValue, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt index d4a362dac0..a4b3a9b106 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryBrowsePrefsTest.kt @@ -139,6 +139,32 @@ class TvLibraryBrowsePrefsTest { assertEquals("year", saved?.sort) } + @Test + fun mixedLibraryMoviesAndSeriesKeepSeparateBrowseState() = runPrefsTest { + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), FakeServerRegistry(), "androidtv") + val movies = viewModel(prefs, libraryType = "mixed", mediaScope = "movie") + movies.onTabSelected(TvLibraryTab.Browse) + movies.onSortKeySelected(TvLibrarySortOption.Year) + movies.onFacetSelectionApplied( + TvCatalogFacetSelection().toggled(TvCatalogFacet.Genre, "Drama"), + ) + movies.onPreserveFiltersChanged(false) + + // The Series tab of the same library opens on its own defaults and + // keeps its own preserve toggle. + val series = viewModel(prefs, libraryType = "mixed", mediaScope = "series") + assertEquals(true, series.uiState.value.preserveFilters) + series.onTabSelected(TvLibraryTab.Browse) + assertEquals("title", series.uiState.value.browseFilter.sort) + assertEquals(TvCatalogFacetSelection(), series.uiState.value.browseFilter.facetSelection) + series.onSortKeySelected(TvLibrarySortOption.Rating) + + assertEquals("rating_imdb", prefs.savedState(LIBRARY_ID, "series")?.sort) + assertNull(prefs.savedState(LIBRARY_ID, "movie")) + assertNull(prefs.savedState(LIBRARY_ID)) + assertEquals(false, viewModel(prefs, libraryType = "mixed", mediaScope = "movie").uiState.value.preserveFilters) + } + private val createdViewModels = mutableListOf() private fun runPrefsTest(block: suspend () -> Unit) = runTest { @@ -152,7 +178,11 @@ class TvLibraryBrowsePrefsTest { } } - private suspend fun viewModel(prefs: BrowsePrefsStore): TvLibraryDetailViewModel { + private suspend fun viewModel( + prefs: BrowsePrefsStore, + libraryType: String = "movies", + mediaScope: String? = null, + ): TvLibraryDetailViewModel { val client = HttpClient( MockEngine { request -> val body = when (request.url.encodedPath) { @@ -170,7 +200,8 @@ class TvLibraryBrowsePrefsTest { catalogRepository = CatalogRepository(CatalogApi(client)), libraryId = LIBRARY_ID, libraryTitle = "Movies", - libraryType = "movies", + libraryType = libraryType, + mediaScope = mediaScope, browsePrefs = prefs, ).also { createdViewModels += it } // Let the eager Recommended load finish before the test drives tabs. From 7401e55bb7062ec262a8a3eaf555a70f1b7cc350 Mon Sep 17 00:00:00 2001 From: RXWatcher Date: Fri, 9 Oct 2026 22:21:46 +0200 Subject: [PATCH 68/87] fix(mobile): separate movies and series in mixed libraries (#447) * fix(mobile): separate movies and series in mixed libraries * fix(android): cancel superseded library recommendation loads * fix(mobile): keep refilling full scoped shelves and preserve profile overrides A full inline slice is not proof of exhaustion: Recently Added and Recently Released report total_count = len(items) after LIMIT item_limit, so a movie-only slice of 20 hid older series without a refill or notice. Only an under-filled slice (items < item_limit, total_count <= items) now skips the refill. The catalog section source pages the stored page_sections definition, not the profile-resolved one, so refilling a customized or profile-added shelf replaced its override (e.g. a movie-only Random row filled with series). Those shelves now keep their scoped inline slice and report it as incomplete instead of paging the admin definition. Co-Authored-By: Claude Opus 5.5 * fix(mobile): offer no shuffle in Movies- or Series-scoped collection views The phone counterpart of the TV change: a shuffle request carries only the collection kind and id, so a scoped view of a mixed library's collection would shuffle both types. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(mobile): offer no library shuffle in a Movies- or Series-scoped view A shuffle request carries no media type, so shuffling a mixed library with the Series chip selected would also play movies. Match the scoped collection views and hide it until the request can carry the scope. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(catalog): keep matching inline items when a scoped refill completes A completed refill returned only what the catalog pages yielded, so a changing shelf such as Random could drop a series it already showed inline, or vanish. Merge matching inline items into the result with the same content-id deduplication the incomplete paths use. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(catalog): put matching inline items first when merging a scoped refill Appending them after the refilled items let the trim to the shelf limit evict them whenever a fresh Random sample alone filled the shelf. They are the start of an ordered shelf anyway, so leading with them keeps order and guarantees they survive. Co-Authored-By: Claude Opus 5.5 (1M context) * perf(catalog): stop refilling a scoped shelf once inline items complete it The refill loop compared only the newly paged items with the shelf target, although the result also keeps the matching inline items. A Random shelf whose inline items nearly filled it kept paging, up to eight requests, after one new item had already completed it. Count the merged, deduplicated items instead. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: rxwatcher Co-authored-by: Claude Opus 5.5 --- .../android/ui/navigation/AppNavigation.kt | 5 + .../silo/android/ui/navigation/Routes.kt | 6 +- .../silo/android/ui/screens/MainScreen.kt | 4 +- .../collections/CollectionDetailScreen.kt | 2 +- .../collections/CollectionDetailViewModel.kt | 23 ++- .../ui/screens/libraries/LibrariesScreen.kt | 128 ++++++++++++--- .../ui/navigation/CollectionRouteTest.kt | 12 ++ .../CollectionDetailViewModelTest.kt | 19 ++- .../libraries/LibrariesViewModelTest.kt | 154 +++++++++++++++++- .../libraries/LibraryChromeInsetSourceTest.kt | 6 + .../ui/screens/library/TvLibraryMediaScope.kt | 48 +----- .../library/TvMixedLibraryScopeTest.kt | 2 +- .../silo/catalog/LibraryMediaScope.kt | 67 ++++++++ .../siloserver/silo/network/api/SectionApi.kt | 4 + .../silo/catalog/LibraryMediaScopeTest.kt | 98 +++++++++++ 15 files changed, 495 insertions(+), 83 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/catalog/LibraryMediaScope.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/catalog/LibraryMediaScopeTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index ab651d9a68..10b6f614ef 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -1163,6 +1163,11 @@ fun AppNavigation( nullable = true defaultValue = null }, + navArgument("mediaScope") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStackEntry -> CollectionDetailScreen( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt index 1acd53fc22..0663f4c6a4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt @@ -189,18 +189,20 @@ sealed class Route(val route: String) { val collectionId: String, val libraryId: Int? = null, val source: String? = null, + val mediaScope: String? = null, ) : Route( buildString { append("collection/${collectionId.routeEncode()}") val parameters = buildList { libraryId?.let { add("libraryId=$it") } source?.takeIf { it.isNotBlank() }?.let { add("source=${it.routeEncode()}") } + mediaScope?.let { add("mediaScope=${it.routeEncode()}") } } if (parameters.isNotEmpty()) append("?${parameters.joinToString("&")}") }, ) { companion object { - const val ROUTE = "collection/{collectionId}?libraryId={libraryId}&source={source}" + const val ROUTE = "collection/{collectionId}?libraryId={libraryId}&source={source}&mediaScope={mediaScope}" } } @@ -343,9 +345,11 @@ sealed class Route(val route: String) { fun libraryCollectionDetailRoute( collection: LibraryCollection, libraryId: Int, + mediaScope: String? = null, ): String = Route.CollectionDetail( collectionId = collection.id, libraryId = libraryId, + mediaScope = mediaScope, source = if (collection.kind == "user_collections") { "user_collection" } else { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt index f5579ed143..e8927917bc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt @@ -441,9 +441,9 @@ fun MainScreen( onItemClick = { contentId, libraryId -> navController.navigate(Route.ItemDetail(contentId, libraryId = libraryId).route) }, - onCollectionClick = { collection, libraryId -> + onCollectionClick = { collection, libraryId, mediaScope -> navController.navigate( - libraryCollectionDetailRoute(collection, libraryId), + libraryCollectionDetailRoute(collection, libraryId, mediaScope), ) }, viewModel = requireNotNull(librariesViewModel), diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt index f8aeb66947..cb701b16ee 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailScreen.kt @@ -78,7 +78,7 @@ fun CollectionDetailScreen( title = state.title, onBackClick = onBackClick, actions = { - if (state.items.isNotEmpty() && shuffleLauncher.supports(shuffleKind)) { + if (shuffleKind != null && state.items.isNotEmpty() && shuffleLauncher.supports(shuffleKind)) { IconButton( onClick = { shuffleLauncher.start(shuffleKind, collectionId) }, enabled = !shuffleLauncher.isStarting, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt index 22040c31dd..472835f702 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt @@ -51,12 +51,19 @@ class CollectionDetailViewModel( private var collectionId: String = "" private val libraryId: Int? = savedStateHandle.get("libraryId")?.toIntOrNull() private val collectionSource: String? = savedStateHandle.get("source") + private val mediaScope = savedStateHandle.get("mediaScope")?.takeIf { it in setOf("movie", "series") } private val isLibraryUserCollection: Boolean get() = libraryId != null && collectionSource == "user_collection" - /** The shuffle scope this collection is: a library's server collection or a user collection. */ - val shuffleScopeKind: org.siloserver.silo.model.shuffle.ShuffleScopeKind - get() = if (libraryId != null && !isLibraryUserCollection) { + /** + * The shuffle scope this collection is: a library's server collection or a + * user collection. Null in a Movies- or Series-scoped view: a shuffle + * request carries no media type or library, so it would draw from both. + */ + val shuffleScopeKind: org.siloserver.silo.model.shuffle.ShuffleScopeKind? + get() = if (mediaScope != null) { + null + } else if (libraryId != null && !isLibraryUserCollection) { org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY_COLLECTION } else { org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION @@ -150,7 +157,7 @@ class CollectionDetailViewModel( } } - val itemsResult = if (isLibraryUserCollection) { + val itemsResult = if (isLibraryUserCollection && mediaScope == null) { collectionRepository.getItems( collectionId, limit = pageSize, @@ -163,6 +170,9 @@ class CollectionDetailViewModel( sectionRepository.getLibraryCollectionItems( collectionId, limit = pageSize, + mediaType = mediaScope, + libraryId = libraryId, + source = if (isLibraryUserCollection) "user_collection" else "library_collection", ).map { libraryContinuation = it.continuation it @@ -208,11 +218,14 @@ class CollectionDetailViewModel( pagingJob = viewModelScope.launch { _uiState.update { it.copy(isLoadingMore = true) } // Library paging is migrated separately; personal collections use opaque cursors. - val result = if (libraryId != null && !isLibraryUserCollection) { + val result = if (libraryId != null && (!isLibraryUserCollection || mediaScope != null)) { sectionRepository.getLibraryCollectionItems( collectionId, continuation = libraryContinuation, limit = pageSize, + mediaType = mediaScope, + libraryId = libraryId, + source = if (isLibraryUserCollection) "user_collection" else "library_collection", ).map { libraryContinuation = it.continuation; it } } else { collectionRepository.getItems( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index 0728b8a889..8d22a57c48 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -43,11 +43,13 @@ import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -72,6 +74,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewModelScope @@ -103,6 +111,7 @@ import org.siloserver.silo.android.ui.screens.browse.FilterSheet import org.siloserver.silo.android.ui.screens.browse.facetValueLabel import org.siloserver.silo.android.ui.screens.browse.normalizeCatalogNamePrefix import org.siloserver.silo.catalog.filter.BrowseFacetMediaType +import org.siloserver.silo.catalog.scopeLibrarySection import org.siloserver.silo.catalog.filter.CatalogFacet import org.siloserver.silo.catalog.filter.CatalogFilterQueryBuilder import org.siloserver.silo.catalog.filter.CatalogFilterState @@ -173,6 +182,7 @@ data class LibrariesUiState( val isLoadingLibraries: Boolean = true, val libraries: List = emptyList(), val selectedLibraryId: Int? = null, + val mediaScope: String? = null, val selectedTab: LibrariesSubtab = LibrariesSubtab.Recommended, val isLoadingSections: Boolean = false, val sections: List = emptyList(), @@ -217,6 +227,7 @@ class LibrariesViewModel( private var recommendedLoadedLibraryId: Int? = null private var browseLoadedLibraryId: Int? = null private var collectionsLoadedLibraryId: Int? = null + private var recommendedJob: Job? = null private var recommendedRequestGeneration = 0L private var catalogContinuation: CatalogContinuationV2? = null private var catalogRequestGeneration = 0L @@ -336,8 +347,14 @@ class LibrariesViewModel( // A *new* selection (first load, or the prior library // vanished) gets the same reset as selectLibrary. An // unchanged selection keeps its rows and active filters. - val selectionChanged = selectedLibraryId != previousLibraryId - if (selectionChanged) resetForLibrary(selectedLibraryId) + // A mixed library keeps its Movies/Series scope across a list + // refresh; a library that stops being mixed drops it. + val previousScope = _uiState.value.mediaScope + val mediaScope = libraries.firstOrNull { it.id == selectedLibraryId } + ?.let(::defaultMediaScope) + ?.let { default -> if (selectedLibraryId == previousLibraryId) previousScope ?: default else default } + val selectionChanged = selectedLibraryId != previousLibraryId || mediaScope != previousScope + if (selectionChanged) resetForLibrary(selectedLibraryId, mediaScope) _uiState.update { it.copy( isLoadingLibraries = false, @@ -378,38 +395,63 @@ class LibrariesViewModel( fun selectLibrary(libraryId: Int) { if (_uiState.value.selectedLibraryId == libraryId) return - resetForLibrary(libraryId) + val library = _uiState.value.libraries.firstOrNull { it.id == libraryId } + resetForLibrary(libraryId, library?.let(::defaultMediaScope)) loadCurrentTab(libraryId, force = true) } + fun selectMediaScope(mediaScope: String) { + val state = _uiState.value + if (mediaScope !in setOf("movie", "series") || state.mediaScope == null || state.mediaScope == mediaScope) return + val libraryId = state.selectedLibraryId ?: return + resetForLibrary(libraryId, mediaScope) + loadCurrentTab(libraryId, force = true) + } + + /** A mixed library opens on Movies; other libraries have no media scope. */ + private fun defaultMediaScope(library: UserLibrary): String? = + if (library.type.trim().lowercase() == "mixed") "movie" else null + /** - * Point the state at [libraryId], dropping the previous library's rows, - * filter vocabulary and letter. Restores this library's persisted + * Point the state at [libraryId] and [mediaScope], dropping the previous + * selection's rows, filter vocabulary and letter, and invalidating its + * in-flight requests. Restores this library and scope's persisted * filter/sort state (iOS parity) so a preserved selection doesn't flash the * unfiltered grid; defaults to a clean filter — and therefore * RecentlyAdded — when nothing is saved. */ - private fun resetForLibrary(libraryId: Int?) { + private fun resetForLibrary(libraryId: Int?, mediaScope: String?) { + recommendedJob?.cancel() recommendedLoadedLibraryId = null browseLoadedLibraryId = null collectionsLoadedLibraryId = null - val restoredFilterState = libraryId?.let { browsePrefs?.savedState(it) } ?: CatalogFilterState() + ++recommendedRequestGeneration + ++catalogRequestGeneration + ++catalogQueryGeneration + ++collectionsRequestGeneration + catalogContinuation = null + val restoredFilterState = libraryId?.let { browsePrefs?.savedState(it, mediaScope) } ?: CatalogFilterState() _uiState.update { it.copy( selectedLibraryId = libraryId, + mediaScope = mediaScope, sections = emptyList(), sectionsError = null, + isLoadingSections = false, catalogItems = emptyList(), catalogTotal = 0, catalogHasMore = false, + isLoadingCatalog = false, + isLoadingMoreCatalog = false, filterState = restoredFilterState, browseSort = LibraryBrowseSort.fromFilterState(restoredFilterState), availableFilters = null, - preserveFilters = libraryId?.let { id -> browsePrefs?.preserveEnabled(id) } ?: true, + preserveFilters = libraryId?.let { id -> browsePrefs?.preserveEnabled(id, mediaScope) } ?: true, selectedNamePrefix = null, catalogError = null, collections = emptyList(), collectionsError = null, + isLoadingCollections = false, ) } } @@ -438,15 +480,15 @@ class LibrariesViewModel( catalogHasMore = false, ) } - browsePrefs?.saveState(_uiState.value.selectedLibraryId, reconciled) + browsePrefs?.saveState(_uiState.value.selectedLibraryId, reconciled, _uiState.value.mediaScope) _uiState.value.selectedLibraryId?.let { loadCatalog(it, reset = true, force = true) } } fun setPreserveFilters(enabled: Boolean) { val libraryId = _uiState.value.selectedLibraryId - browsePrefs?.setPreserveEnabled(libraryId, enabled) + browsePrefs?.setPreserveEnabled(libraryId, enabled, _uiState.value.mediaScope) _uiState.update { it.copy(preserveFilters = enabled) } - if (enabled) browsePrefs?.saveState(libraryId, _uiState.value.filterState) + if (enabled) browsePrefs?.saveState(libraryId, _uiState.value.filterState, _uiState.value.mediaScope) } fun selectBrowseSort(sort: LibraryBrowseSort) { @@ -466,7 +508,7 @@ class LibrariesViewModel( catalogHasMore = false, ) } - browsePrefs?.saveState(_uiState.value.selectedLibraryId, nextFilterState) + browsePrefs?.saveState(_uiState.value.selectedLibraryId, nextFilterState, _uiState.value.mediaScope) _uiState.value.selectedLibraryId?.let { loadCatalog(it, reset = true, force = true) } } @@ -516,8 +558,10 @@ class LibrariesViewModel( private fun loadRecommended(libraryId: Int, force: Boolean) { if (!force && recommendedLoadedLibraryId == libraryId) return recommendedLoadedLibraryId = libraryId + val mediaScope = _uiState.value.mediaScope val requestGeneration = ++recommendedRequestGeneration - viewModelScope.launch { + recommendedJob?.cancel() + recommendedJob = viewModelScope.launch { if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { @@ -548,13 +592,30 @@ class LibrariesViewModel( } when (result) { is ApiResult.Success -> { + val permits = Semaphore(3) + val scoped = result.data.sections.map { section -> + async { + permits.withPermit { + scopeLibrarySection(section, mediaScope) { cursor -> + currentCoroutineContext().ensureActive() + sectionRepository.getLibrarySectionCatalogItems(libraryId, section.id, owner, cursor) + } + } + } + }.awaitAll() if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch + if (!sectionRepository.isLibrarySectionAuthorityCurrent(owner)) { + recommendedLoadedLibraryId = null + _uiState.update { it.copy(isLoadingSections = false, sections = emptyList()) } + return@launch + } _uiState.update { if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { it.copy( isLoadingSections = false, - sections = result.data.sections.filter { section -> section.items.isNotEmpty() }, - sectionsError = null, + sections = scoped.map { it.section }.filter { it.items.isNotEmpty() }, + sectionsError = if (scoped.any { it.incomplete }) + "Some shelves could not be fully loaded. Retry or open Library to browse all titles." else null, ) } else { it @@ -601,6 +662,7 @@ class LibrariesViewModel( val requestState = _uiState.value val requestIdentity = CatalogRequestIdentity( libraryId = libraryId, + mediaScope = requestState.mediaScope, browseSort = requestState.browseSort, selectedNamePrefix = requestState.selectedNamePrefix, filterState = requestState.filterState, @@ -648,6 +710,7 @@ class LibrariesViewModel( when ( val result = catalogRepository.browse( libraryId = libraryId, + mediaType = requestState.mediaScope, sort = requestState.browseSort.sortField, order = requestState.browseSort.sortOrder, continuation = if (reset) null else catalogContinuation, @@ -831,6 +894,7 @@ class LibrariesViewModel( private fun CatalogRequestIdentity.matches(state: LibrariesUiState): Boolean = state.selectedLibraryId == libraryId && + state.mediaScope == mediaScope && state.browseSort == browseSort && state.selectedNamePrefix == selectedNamePrefix && state.filterState == filterState @@ -844,6 +908,7 @@ class LibrariesViewModel( private data class CatalogRequestIdentity( val libraryId: Int, + val mediaScope: String?, val browseSort: LibraryBrowseSort, val selectedNamePrefix: String?, val filterState: CatalogFilterState, @@ -857,7 +922,7 @@ private const val ChromeFadeDistanceDp = 80f @Composable fun LibrariesScreen( onItemClick: (String, Int?) -> Unit, - onCollectionClick: (LibraryCollection, Int) -> Unit, + onCollectionClick: (LibraryCollection, Int, String?) -> Unit, viewModel: LibrariesViewModel, activeProfile: Profile?, onLibrarySelectorClick: () -> Unit, @@ -875,8 +940,11 @@ fun LibrariesScreen( val selectedLibrary = state.libraries.firstOrNull { it.id == state.selectedLibraryId } // Movie, TV, and mixed libraries shuffle from the Browse tab when the // server offers it. + // A Movies- or Series-scoped mixed library offers no shuffle: a shuffle + // request carries no media type, so it would draw from both. val onShuffle = selectedLibrary ?.takeIf { state.selectedTab == LibrariesSubtab.Browse } + ?.takeIf { state.mediaScope == null } ?.takeIf { org.siloserver.silo.model.shuffle.isShuffleLibraryType(it.type) } ?.takeIf { shuffleLauncher?.supports(org.siloserver.silo.model.shuffle.ShuffleScopeKind.LIBRARY) == true } ?.let { library -> @@ -907,6 +975,7 @@ fun LibrariesScreen( // Recommended tab scroll state — drives the chrome scrim opacity so the // header fades in its scrim once the user scrolls the rows underneath it. val recommendedListState = rememberLazyListState() + LaunchedEffect(state.selectedLibraryId, state.mediaScope) { recommendedListState.scrollToItem(0) } val density = LocalDensity.current val chromeFadePx = remember(density) { with(density) { ChromeFadeDistanceDp.dp.toPx() } @@ -1004,7 +1073,7 @@ fun LibrariesScreen( topInset = topInset, onCollectionClick = { collection -> state.selectedLibraryId?.let { libraryId -> - onCollectionClick(collection, libraryId) + onCollectionClick(collection, libraryId, state.mediaScope) } }, onRetry = viewModel::retryCurrentTab, @@ -1021,6 +1090,8 @@ fun LibrariesScreen( canSwitch = state.libraries.size > 1, activeProfile = activeProfile, selectedTab = state.selectedTab, + mediaScope = state.mediaScope, + onMediaScopeSelected = viewModel::selectMediaScope, onLibrarySelectorClick = onLibrarySelectorClick, onTabSelected = viewModel::selectTab, onSearchClick = onSearchClick, @@ -1105,6 +1176,14 @@ private fun RecommendedTabContent( verticalArrangement = Arrangement.spacedBy(24.dp), ) { + if (state.sectionsError != null) { + item(key = "scope-load-error") { + Column(Modifier.padding(horizontal = 16.dp)) { + Text(state.sectionsError, color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = onRetry) { Text("Retry") } + } + } + } items( items = state.sections, key = { section -> section.id }, @@ -1349,6 +1428,7 @@ private fun CollectionsTabContent( ) { collection -> InlineLibraryCollectionCard( collection = collection, + showItemCount = state.mediaScope == null, onClick = { onCollectionClick(collection) }, ) } @@ -1361,6 +1441,7 @@ private fun CollectionsTabContent( @Composable private fun InlineLibraryCollectionCard( collection: LibraryCollection, + showItemCount: Boolean, onClick: () -> Unit, ) { // iOS `LibraryCollectionCard`: VStack(spacing: 6) of a 2:3.3 poster @@ -1387,7 +1468,7 @@ private fun InlineLibraryCollectionCard( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - Text( + if (showItemCount) Text( text = collection.itemCount?.takeIf { it > 0 }?.toString() ?: "Smart", fontSize = 12.sp, color = Color.White, @@ -1410,7 +1491,7 @@ private fun InlineLibraryCollectionCard( } if (caption.showsMetadata) { Text( - text = collection.itemCount?.let { "$it items" } ?: "Collection", + text = if (showItemCount) collection.itemCount?.let { "$it items" } ?: "Collection" else "Collection", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -1428,6 +1509,8 @@ private fun LibrariesFloatingChrome( canSwitch: Boolean, activeProfile: Profile?, selectedTab: LibrariesSubtab, + mediaScope: String?, + onMediaScopeSelected: (String) -> Unit, onLibrarySelectorClick: () -> Unit, onTabSelected: (LibrariesSubtab) -> Unit, onSearchClick: () -> Unit, @@ -1491,6 +1574,13 @@ private fun LibrariesFloatingChrome( // iOS: top bar bottom inset = smallPadding (8). Spacer(modifier = Modifier.height(8.dp)) + if (mediaScope != null) { + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = mediaScope == "movie", onClick = { onMediaScopeSelected("movie") }, label = { Text("Movies") }) + FilterChip(selected = mediaScope == "series", onClick = { onMediaScopeSelected("series") }, label = { Text("Series") }) + } + } + LibrarySubtabRow( selectedTab = selectedTab, onRecommendedClick = { onTabSelected(LibrariesSubtab.Recommended) }, diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt index 6a0fdda82f..1c3516e76c 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt @@ -31,6 +31,18 @@ class CollectionRouteTest { ) } + @Test + fun mixedLibraryCollectionRetainsSeriesScopeInItsDestination() { + assertEquals( + "collection/user?libraryId=7&source=user_collection&mediaScope=series", + libraryCollectionDetailRoute( + LibraryCollection(id = "user", name = "User", kind = "user_collections"), + libraryId = 7, + mediaScope = "series", + ), + ) + } + @Test fun legacyLibraryCollectionRouteRemainsSupported() { assertEquals( diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModelTest.kt index 5fc6b5cb89..28cc0434f5 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModelTest.kt @@ -66,7 +66,7 @@ class CollectionDetailViewModelTest { } val viewModel = fixture.viewModel( - SavedStateHandle(mapOf("libraryId" to "7", "source" to "user_collection")), + SavedStateHandle(mapOf("libraryId" to "7", "source" to "user_collection", "mediaScope" to "series")), ) fixture.store.put("detail", viewModel) viewModel.initialize("user-1") @@ -75,13 +75,16 @@ class CollectionDetailViewModelTest { assertEquals("User Picks", viewModel.uiState.value.title) assertEquals(2, viewModel.uiState.value.total) assertFalse(viewModel.uiState.value.canManage) + // A shuffle request can't carry the Series scope, so it is not offered. + assertEquals(null, viewModel.shuffleScopeKind) viewModel.loadMore() - awaitState(viewModel) { !it.isLoadingMore && !it.hasMore } + awaitState(viewModel) { !it.isLoadingMore } + assertFalse(viewModel.uiState.value.hasMore, "${viewModel.uiState.value.error}; requests=$requests") assertEquals(2, requests.count { it.path == "/api/v2/catalog" }) assertTrue(requests.filter { it.path == "/api/v2/catalog" }.all { - it.parameters["source"] == "user_collection" + it.parameters["source"] == "user_collection" && it.parameters["type"] == "series" }) viewModel.refresh() @@ -91,6 +94,7 @@ class CollectionDetailViewModelTest { val refresh = requests.last { it.path == "/api/v2/catalog" } assertEquals("user_collection", refresh.parameters["source"]) + assertEquals("series", refresh.parameters["type"]) assertFalse("cursor" in refresh.parameters) } @@ -107,6 +111,8 @@ class CollectionDetailViewModelTest { "/api/v2/catalog" -> { assertEquals("library_collection", parameters["source"]) assertEquals("regular-1", parameters["collection_id"]) + assertEquals("movie", parameters["type"]) + assertEquals("7", parameters["library_id"]) catalogBody(total = 1, hasMore = false) } else -> error("Unexpected path $path") @@ -114,16 +120,18 @@ class CollectionDetailViewModelTest { } val viewModel = fixture.viewModel( - SavedStateHandle(mapOf("libraryId" to "7", "source" to "library_collection")), + SavedStateHandle(mapOf("libraryId" to "7", "source" to "library_collection", "mediaScope" to "movie")), ) fixture.store.put("detail", viewModel) viewModel.initialize("regular-1") awaitState(viewModel) { !it.isLoading } + assertEquals(null, viewModel.uiState.value.error) assertEquals("Server Picks", viewModel.uiState.value.title) assertEquals(1, viewModel.uiState.value.total) assertFalse(viewModel.uiState.value.canManage) assertEquals("library_collection", requests.single { it.path == "/api/v2/catalog" }.parameters["source"]) + assertEquals(null, viewModel.shuffleScopeKind) } @Test @@ -151,6 +159,7 @@ class CollectionDetailViewModelTest { assertEquals("Personal Picks", viewModel.uiState.value.title) assertTrue(viewModel.uiState.value.canManage) assertEquals("user_collection", requests.single { it.path == "/api/v2/catalog" }.parameters["source"]) + assertEquals(org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION, viewModel.shuffleScopeKind) } private fun runCollectionTest(block: suspend (MutableList) -> Unit) = runTest { @@ -223,7 +232,7 @@ class CollectionDetailViewModelTest { { "items":[], "page":{"has_more":$hasMore,"next_cursor":${nextCursor?.let { "\"$it\"" } ?: "null"}}, - "total":$total + "total":$total,"total_exact":true,"window_cursor":"window" } """.trimIndent() diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt index fa0dc033cc..337bbe05bd 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -67,6 +67,148 @@ import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = android.app.Application::class) class LibrariesViewModelTest { + @Test + fun mixedLibraryBrowseUsesMovieScopeByDefault() = runTest { + val fixture = DeferredLibrariesFixture(emptySet(), firstLibraryType = "mixed") + 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) + viewModel.uiState.first { it.catalogItems.isNotEmpty() } + assertEquals(listOf("movie"), fixture.catalogTypes) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun switchingMixedScopeRejectsOldPagesAndSeparatesSavedFilters() = runTest { + val fixture = DeferredLibrariesFixture(setOf("catalog:1:added_at:desc:movie"), firstLibraryType = "mixed") + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val registry = FakeServerRegistry() + val prefs = BrowsePrefsStore(ApplicationProvider.getApplicationContext(), registry) + prefs.setPreserveEnabled(1, false, "movie") + prefs.setPreserveEnabled(1, true, "movie") + prefs.setPreserveEnabled(1, false, "series") + prefs.setPreserveEnabled(1, true, "series") + val viewModel = fixture.viewModel(prefs) + 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:movie") + val staleRequest = viewModel.onlyActiveRequest() + viewModel.selectMediaScope("series") + viewModel.uiState.first { it.catalogItems.isNotEmpty() } + fixture.complete("catalog:1:added_at:desc:movie", catalogPageBody("stale", hasMore = true)) + staleRequest.join() + assertEquals("series", viewModel.uiState.value.mediaScope) + assertEquals(listOf("immediate"), viewModel.uiState.value.catalogItems.map { it.contentId }) + assertEquals(false, viewModel.uiState.value.catalogHasMore) + viewModel.selectBrowseSort(LibraryBrowseSort.Title) + viewModel.uiState.first { !it.isLoadingCatalog } + viewModel.selectMediaScope("movie") + assertEquals(LibraryBrowseSort.RecentlyAdded, viewModel.uiState.value.browseSort) + viewModel.selectMediaScope("series") + assertEquals(LibraryBrowseSort.Title, viewModel.uiState.value.browseSort) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun switchingLibraryStopsObsoleteRecommendationPagination() = runTest { + assertObsoleteRecommendationStops(switchScope = false) + } + + @Test + fun switchingScopeStopsObsoleteRecommendationPagination() = runTest { + assertObsoleteRecommendationStops(switchScope = true) + } + + private suspend fun kotlinx.coroutines.test.TestScope.assertObsoleteRecommendationStops(switchScope: Boolean) { + val firstPage = "catalog:1:null:asc:null" + val fixture = DeferredLibrariesFixture(setOf(firstPage), firstLibraryType = "mixed", sectionResponse = """ + {"sections":[{"id":"recent","section_type":"recently_added","title":"Recent","total_count":40,"items":[]}]} + """.trimIndent()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest(firstPage) + val oldRequest = viewModel.onlyActiveRequest() + if (switchScope) { + viewModel.selectTab(LibrariesSubtab.Browse) + viewModel.selectMediaScope("series") + viewModel.uiState.first { !it.isLoadingCatalog } + } else { + viewModel.selectLibrary(2) + viewModel.uiState.first { !it.isLoadingSections } + } + fixture.complete(firstPage, catalogPageBody("old-film", hasMore = true)) + oldRequest.join() + // Unscoped catalog calls belong to the old shelf; the new Browse + // requests have a movie/series type. No second shelf page is allowed. + assertEquals(1, fixture.catalogQueries.count { it["type"] == null }) + assertEquals(true, oldRequest.isCancelled) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun mixedRecommendationsSwitchTypesAndKeepEpisodeProgress() = runTest { + val fixture = DeferredLibrariesFixture(emptySet(), firstLibraryType = "mixed", sectionResponse = """ + {"sections":[{"id":"recent","section_type":"recently_added","title":"Recent","item_limit":20,"total_count":2,"items":[ + {"content_id":"film","type":"movie","title":"Film"}, + {"content_id":"episode","type":"episode","title":"Episode","series_id":"show","position_seconds":12}]}]} + """.trimIndent()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + viewModel.uiState.first { it.sections.isNotEmpty() } + assertEquals(listOf("film"), viewModel.uiState.value.sections.flatMap { it.items }.map { it.contentId }) + viewModel.selectMediaScope("series") + viewModel.uiState.first { it.sections.isNotEmpty() } + val episode = viewModel.uiState.value.sections.single().items.single() + assertEquals("episode", episode.contentId) + assertEquals("show", episode.seriesId) + assertEquals(12.0, episode.positionSeconds) + viewModel.selectLibrary(2) + assertEquals(null, viewModel.uiState.value.mediaScope) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + + @Test + fun mixedScopeRemainsOnAnyFilterQueriesAndTheirNextPage() = runTest { + val pageOne = "catalog:1:added_at:desc:series:filtered:0" + val pageTwo = "catalog:1:added_at:desc:series:filtered:1" + val fixture = DeferredLibrariesFixture(setOf(pageOne, pageTwo), firstLibraryType = "mixed") + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + viewModel.uiState.first { !it.isLoadingLibraries && !it.isLoadingSections } + viewModel.selectMediaScope("series") + viewModel.selectTab(LibrariesSubtab.Browse) + viewModel.uiState.first { it.catalogItems.isNotEmpty() } + viewModel.applyFilterState(CatalogFilterState( + selections = mapOf(CatalogFacet.Genre to setOf("Drama")), matchAll = false, + )) + fixture.awaitRequest(pageOne) + fixture.complete(pageOne, catalogPageBody("first", hasMore = true)) + viewModel.uiState.first { it.catalogHasMore && !it.isLoadingCatalog } + viewModel.loadMoreCatalog() + fixture.awaitRequest(pageTwo) + fixture.complete(pageTwo, catalogPageBody("second", hasMore = false)) + viewModel.uiState.first { !it.isLoadingMoreCatalog && it.catalogItems.size == 2 } + assertEquals(listOf("first", "second"), viewModel.uiState.value.catalogItems.map { it.contentId }) + assertEquals(listOf( + mapOf("type" to "series", "library_id" to "1", "match" to "any", "cursor" to null), + mapOf("type" to "series", "library_id" to "1", "match" to "any", "cursor" to "1"), + ), fixture.catalogQueries.takeLast(2)) + } finally { store.clear(); Dispatchers.resetMain(); fixture.close() } + } + @Test fun recommendedLatePinDoesNotPublish() = runTest { val fixture = DeferredLibrariesFixture(setOf("sections:1")) @@ -568,6 +710,8 @@ class LibrariesViewModelTest { private class DeferredLibrariesFixture( private val deferredKeys: Set, + private val firstLibraryType: String = "movies", + private val sectionResponse: String = """{"sections":[]}""", engineDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) @@ -579,6 +723,8 @@ class LibrariesViewModelTest { val librariesStatusQueue = java.util.concurrent.ConcurrentLinkedQueue() @Volatile var librariesStatus: HttpStatusCode = HttpStatusCode.OK private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = owner } + val catalogTypes = mutableListOf() + val catalogQueries = mutableListOf>() private val requests = Channel(Channel.UNLIMITED) private val pendingRequests = mutableListOf() private val responses = deferredKeys.associateWith { CompletableDeferred() } @@ -596,10 +742,12 @@ class LibrariesViewModelTest { ?.let { SiloJson.parseToJsonElement(it.text).jsonObject } fun field(name: String): String? = body?.get(name)?.let { (it as? JsonPrimitive)?.contentOrNull } ?: request.url.parameters[name] + catalogTypes += field("type") + catalogQueries += listOf("type", "library_id", "match", "cursor").associateWith { field(it) } val rawSort = field("sort") val sort = rawSort?.removePrefix("-") val order = field("order") ?: if (rawSort?.startsWith("-") == true) "desc" else "asc" - val baseKey = "catalog:${field("library_id")}:$sort:$order" + val baseKey = "catalog:${field("library_id")}:$sort:$order" + if (firstLibraryType == "mixed") ":${field("type")}" else "" val filterSuffix = if ((body?.get("groups") as? JsonArray)?.isNotEmpty() == true) { ":filtered" } else { @@ -667,12 +815,12 @@ class LibrariesViewModelTest { private fun immediateBody(key: String): String = when { key == "libraries" -> librariesBodyQueue.poll() ?: librariesBody ?: """ {"items":[ - {"id":"1","name":"First","type":"movies","sort_order":0}, + {"id":"1","name":"First","type":"$firstLibraryType","sort_order":0}, {"id":"2","name":"Second","type":"movies","sort_order":1} ],"page":{"has_more":false}} """.trimIndent() key == "filters" -> filtersBody(null) - key.startsWith("sections:") -> """{"sections":[]}""" + key.startsWith("sections:") -> sectionResponse key.startsWith("collections:") -> """{"library_id":"1","collections":[],"groups":[]}""" key.startsWith("catalog:") -> catalogBody("immediate") else -> error("Unexpected request key $key") diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt index 725a605bcc..946bda95de 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt @@ -24,6 +24,12 @@ class LibraryChromeInsetSourceTest { * top and reads the viewport as its blur source) and every tab clears the * chrome's *measured* height rather than a hard-coded runway. */ + /** A shuffle request carries no media type, so a Movies/Series chip view must not offer it. */ + @Test + fun scopedMixedLibraryOffersNoLibraryShuffle() { + assertTrue(Regex("val onShuffle = selectedLibrary[\\s\\S]*?\\?\\.takeIf \\{ state\\.mediaScope == null \\}[\\s\\S]*?ShuffleScopeKind\\.LIBRARY").containsMatchIn(libraries)) + } + @Test fun sharedChromeOwnsReservedSpaceBeforeEveryLibraryTab() { val viewport = libraries.indexOf("LibraryContentViewport(") diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt index ec01336069..77b3c0d301 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryMediaScope.kt @@ -1,57 +1,13 @@ package org.siloserver.silo.tv.ui.screens.library -import kotlinx.serialization.encodeToString +import org.siloserver.silo.catalog.scopeLibrarySection import org.siloserver.silo.model.catalog.CatalogResponse import org.siloserver.silo.model.section.ResolvedSection -import org.siloserver.silo.model.section.SectionItem import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.apiv2.CatalogContinuationV2 -internal fun matchesTvMediaScope(type: String, scope: String?): Boolean = when (scope) { - "movie" -> type.trim().lowercase() in setOf("movie", "movies", "film") - "series" -> type.trim().lowercase() in setOf("series", "show", "shows", "tv", "episode") - else -> true -} - -internal data class ScopedTvSection(val section: ResolvedSection, val incomplete: Boolean = false) - -/** Section sources reject type overlays. Page their original order and filter locally. */ internal suspend fun scopeTvLibrarySection( section: ResolvedSection, mediaScope: String?, loadPage: suspend (CatalogContinuationV2?) -> ApiResult, -): ScopedTvSection { - if (mediaScope == null) return ScopedTvSection(section) - val initial = section.items.filter { matchesTvMediaScope(it.type, mediaScope) } - val target = (section.itemLimit.takeIf { it > 0 } ?: section.items.size.coerceAtLeast(20)).coerceAtMost(100) - fun result(items: List, incomplete: Boolean = false) = - ScopedTvSection(section.copy(items = items.take(target), totalCount = items.size.coerceAtMost(target)), incomplete) - // Inline total_count is not an exhaustion signal: several server shelves (e.g. recently added) - // report the bounded inline count. Only the catalog continuation can say nothing else remains. - if (initial.size >= target) return result(initial) - // The catalog section source pages the stored admin definition, not the profile-resolved one, so a - // customized or profile-added shelf cannot be refilled without dropping the profile's overrides. - if (section.customized || section.isCustom) return result(initial, incomplete = true) - - val originals = section.items.associateBy { it.contentId } - val visible = linkedMapOf() - var continuation: CatalogContinuationV2? = null - repeat(8) { - when (val page = loadPage(continuation)) { - is ApiResult.Success -> { - page.data.items.filter { matchesTvMediaScope(it.type, mediaScope) }.forEach { item -> - visible.putIfAbsent(item.contentId, originals[item.contentId] - ?: SiloJson.decodeFromString(SiloJson.encodeToString(item))) - } - if (visible.size >= target || !page.data.hasMore) return result(visible.values.toList()) - val next = page.data.continuation - if (next == null || next === continuation || page.data.items.isEmpty()) - return result((visible.values + initial).distinctBy { it.contentId }, true) - continuation = next - } - else -> return result((visible.values + initial).distinctBy { it.contentId }, true) - } - } - return result((visible.values + initial).distinctBy { it.contentId }, true) -} +) = scopeLibrarySection(section, mediaScope, loadPage) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt index 2e30d4ee69..bb06b67b32 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvMixedLibraryScopeTest.kt @@ -210,7 +210,7 @@ class TvMixedLibraryScopeTest { @Test fun seriesShelvesKeepEpisodeProgressAndHideMovies() = runTest { val episode = org.siloserver.silo.model.section.SectionItem("episode", "episode", "Episode", positionSeconds = 73.0) - val row = org.siloserver.silo.model.section.ResolvedSection("continue", "continue_watching", "Continue", totalCount = 2, + val row = org.siloserver.silo.model.section.ResolvedSection("continue", "continue_watching", "Continue", itemLimit = 20, totalCount = 2, items = listOf(episode, org.siloserver.silo.model.section.SectionItem("film", "movie", "Film"))) val result = scopeTvLibrarySection(row, "series") { ApiResult.Success(org.siloserver.silo.model.catalog.CatalogResponse(items = listOf( diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/catalog/LibraryMediaScope.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/catalog/LibraryMediaScope.kt new file mode 100644 index 0000000000..1c151c3d08 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/catalog/LibraryMediaScope.kt @@ -0,0 +1,67 @@ +package org.siloserver.silo.catalog + +import kotlinx.serialization.encodeToString +import org.siloserver.silo.model.catalog.CatalogResponse +import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.apiv2.CatalogContinuationV2 + +fun matchesLibraryMediaScope(type: String, scope: String?): Boolean = when (scope) { + "movie" -> type.trim().lowercase() in setOf("movie", "movies", "film") + "series" -> type.trim().lowercase() in setOf("series", "show", "shows", "tv", "episode") + else -> true +} + +data class ScopedLibrarySection(val section: ResolvedSection, val incomplete: Boolean = false) + +/** + * Section sources reject type overlays. Page their original order and filter locally. + * Shelves with profile overrides keep their scoped inline slice: a refill would page the admin definition. + */ +suspend fun scopeLibrarySection( + section: ResolvedSection, + mediaScope: String?, + loadPage: suspend (CatalogContinuationV2?) -> ApiResult, +): ScopedLibrarySection { + if (mediaScope == null) return ScopedLibrarySection(section) + val initial = section.items.filter { matchesLibraryMediaScope(it.type, mediaScope) } + val target = (section.itemLimit.takeIf { it > 0 } ?: section.items.size.coerceAtLeast(20)).coerceAtMost(100) + fun result(items: List, incomplete: Boolean = false) = + ScopedLibrarySection(section.copy(items = items.take(target), totalCount = items.size.coerceAtMost(target)), incomplete) + if (initial.size >= target) return result(initial) + // Recently Added/Released report total_count = len(items) after LIMIT item_limit, so the count only + // proves "more" when it exceeds the slice. Only an under-filled slice proves the shelf is exhausted. + val exhausted = section.totalCount <= section.items.size && + section.itemLimit > 0 && section.items.size < section.itemLimit + if (exhausted) return result(initial) + // The catalog section source pages the stored admin definition, not the profile-resolved one, so a + // customized or profile-added shelf cannot be refilled without dropping the profile's overrides. + if (section.customized || section.isCustom) return result(initial, incomplete = true) + + val originals = section.items.associateBy { it.contentId } + val visible = linkedMapOf() + var continuation: CatalogContinuationV2? = null + repeat(8) { + when (val page = loadPage(continuation)) { + is ApiResult.Success -> { + page.data.items.filter { matchesLibraryMediaScope(it.type, mediaScope) }.forEach { item -> + visible.putIfAbsent(item.contentId, originals[item.contentId] + ?: SiloJson.decodeFromString(SiloJson.encodeToString(item))) + } + // Inline items come first: a changing shelf (Random) can page a sample without them, and + // they are the start of an ordered shelf anyway, so the trim to target never evicts them. + // They also count toward the target, so a page that completes the shelf ends the paging. + val merged = (initial + visible.values).distinctBy { it.contentId } + if (merged.size >= target || !page.data.hasMore) return result(merged) + val next = page.data.continuation + if (next == null || next === continuation || page.data.items.isEmpty()) + return result(merged, true) + continuation = next + } + else -> return result((initial + visible.values).distinctBy { it.contentId }, true) + } + } + return result((initial + visible.values).distinctBy { it.contentId }, true) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt index 17f15527a1..965497170c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/SectionApi.kt @@ -37,6 +37,10 @@ class SectionApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api suspend fun getLibrarySectionItems(libraryId: Int, sectionId: String, owner: AuthScopeSnapshot): ApiResult = sectionItems.read(libraryId, sectionId, owner) + /** + * Pages the section's stored admin definition: the server's catalog section source does not apply + * profile overrides, so callers must not refill a `customized` or `is_custom` section through it. + */ suspend fun getLibrarySectionCatalogItems(libraryId: Int, sectionId: String, owner: AuthScopeSnapshot, continuation: CatalogContinuationV2?): ApiResult { if (!sectionItems.current(owner)) return identityChanged() diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/catalog/LibraryMediaScopeTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/catalog/LibraryMediaScopeTest.kt new file mode 100644 index 0000000000..1c7ce8226a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/catalog/LibraryMediaScopeTest.kt @@ -0,0 +1,98 @@ +package org.siloserver.silo.catalog + +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.CatalogResponse +import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.apiv2.CatalogContinuationV2 +import kotlin.test.* + +class LibraryMediaScopeTest { + private val movies = (1..20).map { SectionItem("film-$it", "movie", "Film $it") } + + /** Recently Added/Released report `total_count = len(items)` after `LIMIT item_limit`; a full slice proves nothing. */ + @Test fun fullInlineSliceRefillsEvenWhenTotalCountEqualsItsSize() = runTest { + val row = ResolvedSection("recent", "recently_added", "Recent", itemLimit = 20, totalCount = 20, items = movies) + var loads = 0 + val result = scopeLibrarySection(row, "series") { + loads++ + ApiResult.Success(CatalogResponse(items = movies.map { BrowseItem(it.contentId, it.type, it.title) } + + BrowseItem("show", "series", "Show"))) + } + assertEquals(1, loads) + assertEquals(listOf("show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** A Random shelf pages a fresh sample, which need not include the series already shown inline. */ + @Test fun completedRefillKeepsMatchingInlineItems() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 3, totalCount = 3, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("film-2", "movie", "Film 2"), SectionItem("inline-show", "series", "Inline"))) + val result = scopeLibrarySection(row, "series") { + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("other-show", "series", "Other"), BrowseItem("film-3", "movie", "Film 3")))) + } + assertEquals(listOf("inline-show", "other-show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** A fresh sample that alone fills the shelf must not evict the series already shown. */ + @Test fun refillThatFillsTheShelfKeepsMatchingInlineItems() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 2, totalCount = 2, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("inline-show", "series", "Inline"))) + val result = scopeLibrarySection(row, "series") { + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("show-a", "series", "A"), BrowseItem("show-b", "series", "B")), hasMore = true)) + } + assertEquals(listOf("inline-show", "show-a"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** Inline items count toward the shelf, so a refill page that completes it ends the paging. */ + @Test fun refillStopsOnceInlineAndRefilledItemsFillTheShelf() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 3, totalCount = 3, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("show-1", "series", "One"), SectionItem("show-2", "series", "Two"))) + var loads = 0 + val result = scopeLibrarySection(row, "series") { + loads++ + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("show-$loads-new", "series", "New"), BrowseItem("film-$loads", "movie", "Film")), + hasMore = true, continuation = CatalogContinuationV2("random", "page-$loads", null, emptySet()))) + } + assertEquals(1, loads) + assertEquals(listOf("show-1", "show-2", "show-1-new"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + @Test fun underfilledInlineSliceIsExhaustedWithoutRefill() = runTest { + val row = ResolvedSection("recent", "recently_added", "Recent", itemLimit = 20, totalCount = 2, + items = listOf(SectionItem("film", "movie", "Film"), SectionItem("show", "series", "Show"))) + val result = scopeLibrarySection(row, "series") { error("An under-filled slice is the whole shelf") } + assertEquals(listOf("show"), result.section.items.map { it.contentId }) + assertFalse(result.incomplete) + } + + /** The catalog section source reads the stored admin definition, so a profile override would be dropped. */ + @Test fun profileCustomizedShelfNeverRefillsFromTheStoredDefinition() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 20, totalCount = 21, customized = true, items = movies) + val result = scopeLibrarySection(row, "series") { + ApiResult.Success(CatalogResponse(items = listOf(BrowseItem("show", "series", "Show")))) + } + assertEquals(emptyList(), result.section.items) + assertTrue(result.incomplete, "The scoped slice is unverified, so say so rather than hide it silently") + } + + @Test fun profileAddedShelfNeverRefillsFromTheCatalogSource() = runTest { + val episode = SectionItem("episode", "episode", "Episode") + val row = ResolvedSection("mine", "random", "Mine", itemLimit = 20, totalCount = 21, isCustom = true, items = movies.take(19) + episode) + val result = scopeLibrarySection(row, "series") { error("Profile-added sections have no stored catalog definition") } + assertEquals(listOf(episode), result.section.items) + assertTrue(result.incomplete) + } + + @Test fun customizedShelfAlreadyFilledByItsScopeIsComplete() = runTest { + val row = ResolvedSection("random", "random", "Random", itemLimit = 20, totalCount = 21, customized = true, items = movies) + val result = scopeLibrarySection(row, "movie") { error("A filled scoped shelf needs no refill") } + assertEquals(movies, result.section.items) + assertFalse(result.incomplete) + } +} From c0172bfe7c1e108294f4ff778b021d77d38fab1e Mon Sep 17 00:00:00 2001 From: amokster88 <69913373+amokster88@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:52:12 +0530 Subject: [PATCH 69/87] feat(cast): start phone-to-TV playback without a handoff when the TV can (#488) * feat(cast): start phone-to-TV playback without a handoff when the TV can Pressing Play on the phone while Remote Control drives an Android TV ran the full profile handoff every time, even when the TV was already signed in with the same profile, and the TV ended the borrowed profile whenever a player closed. Each title took several seconds to start, and the TV showed nothing while it waited. Android TV: - Answer handoff_ready at once (reused: true) and play on the TV's own identity when the controller was authorized at hello against the TV's own server, no temporary identity is installed, and the TV is signed in with the offered profile (RemotePlaybackOwnIdentityPolicy). - Keep a borrowed profile while its phone holds the slot; hand it back when that phone leaves, another phone takes over, or after 10 minutes idle. The idle limit replaces the 60 s ready-without-launch timeout. - Unpublish an identity before its server logout, so nothing launches on or reuses an identity being ended; a started hand-back is no longer cancellable, and its logout is capped at 5 s. - Show "Starting {title}" / "From {phone}" on the standby view while a full handoff runs. Phone: - Send the item's title in handoff_offer (optional, display-only). - Show the title's poster and "Starting on {TV}..." from the tap until the TV reports that title, with tick, confirm, and reject haptics. - Read "Connected / to {TV}" in the mini player while the TV is idle. Closes #486 Co-Authored-By: Claude Opus 5.5 * fix(cast): close two launch races found in review - Phone: read the outgoing title before sending the launch frame, so the TV's state for the new title is never recorded as the title being replaced, which left the remote on "Starting..." until the 30 s timeout. - TV: a scheduled hand-back and launch admission now decide under the receiver lock. A hand-back commits only while nothing is playing on, or launching to, the identity; admission refuses an identity whose end has committed or that is gone, with handoff_required. The bookkeeping after an end now runs in its non-cancellable step. Co-Authored-By: Claude Opus 5.5 * fix(tv): clear only the ended generation's pending launch After a hand-back's logout, clear the pending launch only when it still belongs to the ended generation, under the receiver lock that launch admission uses. A launch admitted for a newer identity in the meantime keeps its protection from the idle limit until its player registers. Co-Authored-By: Claude Opus 5.5 * fix(tv): admit one launch at a time, from the phone that holds the TV A launch still being handled for a phone that just lost the slot could be admitted after the new phone's handoff began, and the new phone's handoff could then replace the identity before that launch's player registered. - Launch admission now runs only for the active session and only when no other launch is pending. Each admitted launch owns the pending marker, and cleanup clears the marker only for its owner. - A handoff offer is refused with handoff_cancel reason launch_pending while a launch is on its way to the player. - A pending launch expires after 30 s, so a player that never opens cannot hold off every later handoff. Co-Authored-By: Claude Opus 5.5 * fix(tv): drop an expired or abandoned launch before navigation opens it When a pending launch expired, or the receiver stopped, the marker was cleared but the request could still sit in the launch channel. Navigation would open it later, possibly under a newer phone's identity. Expiry now drops its own request if navigation has not taken it, and stop() drops any queued request. Co-Authored-By: Claude Opus 5.5 * fix(tv): check a phone's launch is still current before its player opens Dropping a queued request on expiry could not reach a request navigation had already taken. That launch could still open its player after it expired, or after its identity changed, and play under the wrong identity. - Each admitted launch now carries an id through navigation and the player route. Expiry or stop() revokes the id. - Navigation confirms the id right before it opens the player and skips a stale launch, releasing its pending marker so the next launch is not refused. - The player registers with the id. A launch that went stale in the moment before registration registers nothing, and the screen closes the way a phone's Stop does. Only the launched player takes the launch's identity generation. Co-Authored-By: Claude Opus 5.5 * fix(tv): tie a phone's launch to the identity it was admitted on The launch check from b35aeac7 left gaps. A launch on the TV's own profile carried no identity, so a profile switch before its player opened went unnoticed. A recreated player skipped the check for its launch even after the borrowed identity ended. The player screen started loading before the check ran, and a stale player left through the generic Back, which could close a newer launch's player. - Each admitted launch records the IdentityTransitionBarrier generation. Any sign-in, profile, server or borrowed-identity change since makes it stale, on the TV's own profile as well as a borrowed one. Admission refuses such a launch with handoff_required. - A recreated player re-registers only while its launch's identity is unchanged. - Navigation checks the launch before it creates the player screen, so a stale launch never starts loading. - A launch that goes stale just before registration stops what its player started and leaves only its own back stack entry. Co-Authored-By: Claude Opus 5.5 * fix(tv): ignore unrelated server removals and recheck a stale player before stopping it Every IdentityTransitionBarrier transition bumps its generation, including removing a saved server that is not the active one. A launch admitted just before such a removal was refused although its identity had not changed. A stale player's exit also stopped the shared service player before it checked whether a newer launch's player had already replaced it. - The receiver counts only WILL_CHANGE transitions that affect the current identity, the filter NotificationsRepository uses, and compares launches against that count. - The stale-launch callback now checks that this back stack entry is still on top right before it stops playback and pops it. Otherwise the newer navigation has already replaced and torn down this player. Co-Authored-By: Claude Opus 5.5 * fix(tv): read a launch's identity only while no identity change is in flight The identity revision advances at WILL_CHANGE, before the change lands. A launch admitted in between could take the new revision, check the old profile, and then stay valid after the switch. Admission now reads the identity and its revision inside IdentityTransitionBarrier.withCurrentGeneration, which holds identity changes off while it runs, so a change already under way is waited out instead of half observed. The read retries if changes keep landing, then refuses the launch with handoff_required. This is the same fence captureAccountSessionExpectation uses. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../android/cast/SiloCastArtworkResolver.kt | 18 + .../silo/android/cast/SiloCastController.kt | 133 ++++- .../silo/android/di/AndroidModule.kt | 1 + .../android/ui/navigation/AppNavigation.kt | 2 + .../ui/screens/cast/SiloCastLaunchHaptics.kt | 40 ++ .../ui/screens/cast/SiloCastMiniBar.kt | 29 +- .../ui/screens/cast/SiloCastRemoteScreen.kt | 59 +- .../tv/cast/RemotePlaybackIdentityManager.kt | 35 +- .../cast/RemotePlaybackOwnIdentityPolicy.kt | 31 ++ .../silo/tv/cast/TvSiloCastReceiver.kt | 509 +++++++++++++++--- .../siloserver/silo/tv/di/AndroidTvModule.kt | 2 + .../silo/tv/ui/navigation/TvAppNavigation.kt | 32 +- .../silo/tv/ui/navigation/TvRoute.kt | 7 +- .../ui/screens/cast/TvSiloCastStandbyView.kt | 42 +- .../tv/ui/screens/player/TvPlayerScreen.kt | 26 +- .../cast/RemotePlaybackIdentityClaimTest.kt | 55 ++ .../RemotePlaybackOwnIdentityPolicyTest.kt | 55 ++ .../siloserver/silo/cast/SiloCastMessage.kt | 2 + 18 files changed, 961 insertions(+), 117 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastLaunchHaptics.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicy.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicyTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastArtworkResolver.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastArtworkResolver.kt index 8ea9db076c..6621e11f61 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastArtworkResolver.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastArtworkResolver.kt @@ -10,6 +10,8 @@ data class SiloCastArtwork( val posterThumbhash: String? = null, val backdropUrl: String? = null, val backdropThumbhash: String? = null, + /** The item's display title (see [castDisplayTitle]), for one the TV has not reported yet. */ + val title: String? = null, ) { val isEmpty: Boolean get() = posterUrl == null && backdropUrl == null } @@ -31,9 +33,25 @@ internal suspend fun resolveCastArtwork( posterThumbhash = if (series?.posterUrl != null) series.posterThumbhash else detail.posterThumbhash, backdropUrl = detail.backdropUrl ?: series?.backdropUrl, backdropThumbhash = if (detail.backdropUrl != null) detail.backdropThumbhash else series?.backdropThumbhash, + title = castDisplayTitle(detail), ) } +/** + * One line naming an item that is starting on a TV. An episode reads + * "Series · S1 · E2", the TV player's own subtitle, since its bare title is + * often ambiguous ("Pilot"). + */ +internal fun castDisplayTitle(detail: ItemDetail): String? { + val series = detail.seriesTitle?.takeIf { detail.type == "episode" && it.isNotBlank() } + ?: return detail.title.takeIf { it.isNotBlank() } + return listOfNotNull( + series, + detail.seasonNumber?.let { "S$it" }, + detail.episodeNumber?.let { "E$it" }, + ).joinToString(" · ") +} + private suspend fun CatalogRepository.detailOrNull(contentId: String): ItemDetail? = getCachedItemDetail(contentId) ?: (getItemDetail(contentId) as? ApiResult.Success)?.data diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt index c9f5c275c6..20a7206f97 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt @@ -7,18 +7,25 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select @@ -26,6 +33,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import org.siloserver.silo.cast.SiloCastControlCommand import org.siloserver.silo.cast.SiloCastHello @@ -52,6 +60,7 @@ import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.api.DeviceLoginApi +import org.siloserver.silo.repository.CatalogRepository data class SiloCastControllerState( val targets: List = emptyList(), @@ -69,17 +78,28 @@ data class SiloCastControllerState( /** A silent foreground re-attach probe is in flight or unconfirmed — * the mini bar stays hidden until the TV confirms it is playing. */ val isAutoResuming: Boolean = false, - /** A launch (connect + profile handoff + launch frame) is in flight. The - * TV keeps pushing idle state until its player registers, so without - * this the remote would show "Pick something from your library…" for - * seconds right after the user picked something. */ - val isLaunching: Boolean = false, + /** A Play sent to the TV, from the tap until the TV reports that title. + * The TV keeps pushing idle state (or the outgoing title's state) until + * its player registers, so without this the remote would show "Pick + * something from your library…" right after the user picked something. */ + val launch: SiloCastPendingLaunch? = null, val error: String? = null, ) { val isConnected: Boolean get() = connectedTarget != null && !isReconnecting val hasActiveSession: Boolean get() = connectedTarget != null || isReconnecting } +data class SiloCastPendingLaunch( + val contentId: String, + /** The launch frame is on the wire; only the TV's state after that counts. */ + val isSent: Boolean = false, + /** What the TV reported playing when the launch went out; its state does not end the launch. */ + val replacingContentId: String? = null, +) + +/** Haptic moments of a Play sent to a TV, mirroring silo-apple's remote. */ +enum class SiloCastLaunchFeedback { Sent, Started, Failed } + /** * Phone-side SiloCast controller. Wire-compatible with silo-apple's * SiloControlClient/TVControlReceiver: TLS-PSK transport ([SiloCastTls]), @@ -104,6 +124,7 @@ class SiloCastController( private val serverRegistry: ServerRegistry, private val tokenManager: TokenManager, private val deviceLoginApi: DeviceLoginApi, + private val catalogRepository: CatalogRepository, private val lastTargetStore: SiloCastLastTargetStore, private val deviceNameProvider: () -> String, private val deviceIdProvider: () -> String, @@ -149,6 +170,12 @@ class SiloCastController( private val _state = MutableStateFlow(SiloCastControllerState()) val state: StateFlow = _state.asStateFlow() + private val _launchFeedback = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val launchFeedback: SharedFlow = _launchFeedback.asSharedFlow() + private var session: SiloCastTlsClientSession? = null private var output: OutputStream? = null private var readJob: Job? = null @@ -209,16 +236,31 @@ class SiloCastController( } fun launchOnTarget(target: SiloCastTarget, request: SiloCastLaunchRequest) { + val pending = SiloCastPendingLaunch(contentId = request.playback.contentId) val job = scope.launch(start = CoroutineStart.LAZY) { val self = currentCoroutineContext()[Job] ?: return@launch + // Looked up alongside the connect: the TV shows it while a full + // profile handoff runs. + val title = async { launchTitle(request.playback.contentId) } try { launchMutex.withLock { ensureConnected(target, allowCrossServer = true) - // AFTER ensureConnected: its teardown of any previous session - // resets the flag, so setting it earlier would be undone. - _state.update { it.copy(isLaunching = true) } - prepareRemoteIdentity(request) + // Again AFTER ensureConnected: its teardown of a previous + // session clears the launch set at the tap. + _state.update { if (isCurrentLaunch(self)) it.copy(launch = pending) else it } + prepareRemoteIdentity(request, title) + // Read before sending: the TV's state for the new title can + // arrive before the update below, and must not be taken + // for the title being replaced. + val replacing = _state.value.playbackState?.contentId send(SiloCastMessage.Launch(request)) + _state.update { + if (isCurrentLaunch(self) && it.launch == pending) { + it.copy(launch = pending.copy(isSent = true, replacingContentId = replacing)) + } else { + it + } + } // A cross-server handoff changes the TV's advertised // server after the socket was opened. Persist the actual // remote session scope, not the target's stale discovery @@ -231,12 +273,20 @@ class SiloCastController( ), ) } + // The TV reports a launched title within moments (its player + // registers before the stream loads); give up on one it never + // reports instead of leaving the remote on "Starting…". + val sent = _state.value.launch?.takeIf { it.isSent && it.contentId == pending.contentId } + if (sent != null) { + val ended = withTimeoutOrNull(LAUNCH_START_TIMEOUT_MS) { _state.first { it.launch !== sent } } + if (ended == null && isCurrentLaunch(self)) failLaunch("The TV didn't start playback.") + } } catch (error: CancellationException) { // Transport teardown cancels the handoff deferreds. Clear the // spinner when this is still the active launch, but do not let a // superseded launch overwrite the replacement's state. if (isCurrentLaunch(self)) { - _state.update { it.copy(isConnecting = false, connectingDeviceId = null, isLaunching = false) } + _state.update { it.copy(isConnecting = false, connectingDeviceId = null, launch = null) } } throw error } catch (error: Throwable) { @@ -245,12 +295,14 @@ class SiloCastController( it.copy( isConnecting = false, connectingDeviceId = null, - isLaunching = false, + launch = null, error = error.message ?: "Unable to cast.", ) } + _launchFeedback.tryEmit(SiloCastLaunchFeedback.Failed) } } finally { + title.cancel() synchronized(launchJobLock) { if (launchJob === self) launchJob = null } @@ -261,10 +313,38 @@ class SiloCastController( launchJob = job old } + // From the tap, after the swap: a superseded job can no longer + // overwrite this launch. + _state.update { it.copy(launch = pending, error = null) } + _launchFeedback.tryEmit(SiloCastLaunchFeedback.Sent) previous?.cancel(CancellationException("Superseded by a newer remote launch.")) job.start() } + /** Ends the pending launch as failed; the haptic fires only if one was pending. */ + private fun failLaunch(message: String?) { + var failed = false + _state.update { + failed = it.launch != null + it.copy(launch = null, error = message ?: it.error) + } + if (failed) _launchFeedback.tryEmit(SiloCastLaunchFeedback.Failed) + } + + /** The launched item's display title; display-only. */ + private suspend fun launchTitle(contentId: String): String? = + try { + // A warm-up, so the remote's artwork lookup for the same item + // joins this request instead of fetching it again. + val detail = catalogRepository.getCachedItemDetail(contentId) + ?: (catalogRepository.warmItemDetail(contentId) as? ApiResult.Success)?.data + detail?.let(::castDisplayTitle) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + private fun isCurrentLaunch(job: Job): Boolean = synchronized(launchJobLock) { launchJob === job } /** @@ -649,7 +729,7 @@ class SiloCastController( ) } - private suspend fun prepareRemoteIdentity(request: SiloCastLaunchRequest) { + private suspend fun prepareRemoteIdentity(request: SiloCastLaunchRequest, title: Deferred) { val version = awaitHandoffStep(VERSION_TIMEOUT_MS, "The TV did not respond.") { negotiatedVersion.await() } require(version == SiloCastProtocol.version) { "Update Silo on both devices to use Remote Control." } @@ -675,6 +755,10 @@ class SiloCastController( serverName = server.displayName, profileId = profileId, profileName = null, + // Brief wait only: a TV that already has this profile + // answers at once, and the title shows only during a + // full handoff. + title = withTimeoutOrNull(OFFER_TITLE_WAIT_MS) { title.await() }, ), ), ) @@ -819,7 +903,8 @@ class SiloCastController( // opened. if (session !== deadSession || suppressReconnect || reconnectJob != null) return@launch teardownTransportLocked() - _state.update { it.copy(isReconnecting = true, isConnecting = false, isLaunching = false) } + // Quietly: a launch already sent may still start on the TV. + _state.update { it.copy(isReconnecting = true, isConnecting = false, launch = null) } reconnectJob = scope.launch { try { for (attempt in 1..MAX_RECONNECT_ATTEMPTS) { @@ -911,6 +996,7 @@ class SiloCastController( return } val now = nowMs() + var started = false val reconciled = synchronized(volumeStateLock) { val next = if (isIdle) { volumeReconciler.clear() @@ -922,22 +1008,33 @@ class SiloCastController( ) } _state.update { + // The launch ends when the TV reports that title, or + // anything besides what it was playing at the tap (the + // TV may report a resolved id for the same title). + val launch = it.launch + started = launch != null && launch.isSent && !isIdle && + (next.contentId == launch.contentId || next.contentId != launch.replacingContentId) it.copy( playbackState = next, error = null, isAutoResuming = if (!isIdle) false else it.isAutoResuming, - isLaunching = if (!isIdle) false else it.isLaunching, + launch = if (started) null else launch, ) } next } clock.ingest(reconciled, now) + if (started) _launchFeedback.tryEmit(SiloCastLaunchFeedback.Started) } is SiloCastMessage.Error -> { if (sessionIsAutoResumed && !remoteScreenVisible) { quietDisconnect() + } else if (_state.value.launch?.isSent == true && message.error.code !in CONTROL_REPLY_ERRORS) { + failLaunch(message.error.message) } else { - _state.update { it.copy(error = message.error.message, isLaunching = false) } + // A reply to a control pressed mid-launch, or an error the + // launch's own handoff will surface, does not end it. + _state.update { it.copy(error = message.error.message) } } } is SiloCastMessage.Ping -> send(SiloCastMessage.Pong()) @@ -1022,7 +1119,7 @@ class SiloCastController( connectingDeviceId = null, isReconnecting = false, isAutoResuming = false, - isLaunching = false, + launch = null, ) } } @@ -1076,6 +1173,10 @@ class SiloCastController( const val VERSION_TIMEOUT_MS = 5_000L const val CHALLENGE_TIMEOUT_MS = 15_000L const val READY_TIMEOUT_MS = 35_000L + const val OFFER_TITLE_WAIT_MS = 300L + const val LAUNCH_START_TIMEOUT_MS = 30_000L + /** TV errors that answer a control command rather than a launch. */ + val CONTROL_REPLY_ERRORS = setOf("player_not_ready", "command_failed") const val HEARTBEAT_INTERVAL_MS = 3_000L const val MAX_MISSED_HEARTBEATS = 3 const val MAX_RECONNECT_ATTEMPTS = 5 diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 462de40b21..ba51e25acc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -257,6 +257,7 @@ val androidModule = module { serverRegistry = get(), tokenManager = get(), deviceLoginApi = get(), + catalogRepository = get(), lastTargetStore = get(), deviceNameProvider = { android.os.Build.MODEL?.trim()?.ifBlank { null } ?: "Android Phone" diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 10b6f614ef..ba6a1051a2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.flow.map import org.siloserver.silo.android.cast.GoogleCastMiniBar import org.siloserver.silo.android.cast.SiloCastController import org.siloserver.silo.android.cast.SiloCastSessionManager +import org.siloserver.silo.android.ui.screens.cast.SiloCastLaunchHaptics import org.siloserver.silo.android.ui.screens.cast.SiloCastMiniBar import org.siloserver.silo.android.ui.screens.cast.SiloCastRemoteScreen import org.siloserver.silo.android.ui.screens.MainScreen @@ -250,6 +251,7 @@ fun AppNavigation( siloCastController.startBrowsing() onDispose { siloCastController.stopBrowsing() } } + SiloCastLaunchHaptics(siloCastController) // Graceful handling of server-side session invalidation (refresh 401'd). // The TokenManager has already wiped the active server's tokens by the diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastLaunchHaptics.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastLaunchHaptics.kt new file mode 100644 index 0000000000..f014823282 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastLaunchHaptics.kt @@ -0,0 +1,40 @@ +package org.siloserver.silo.android.ui.screens.cast + +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalView +import org.siloserver.silo.android.cast.SiloCastController +import org.siloserver.silo.android.cast.SiloCastLaunchFeedback + +/** + * Haptics for a Play sent to a TV, wherever the user is in the app: a light + * tick when it goes out, success once the TV reports the title, and an error + * if it fails. Mirrors silo-apple's remote. + */ +@Composable +fun SiloCastLaunchHaptics(controller: SiloCastController) { + val view = LocalView.current + LaunchedEffect(controller, view) { + controller.launchFeedback.collect { feedback -> + view.performHapticFeedback( + when (feedback) { + SiloCastLaunchFeedback.Sent -> HapticFeedbackConstants.CLOCK_TICK + SiloCastLaunchFeedback.Started -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.CONFIRM + } else { + HapticFeedbackConstants.LONG_PRESS + } + SiloCastLaunchFeedback.Failed -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.REJECT + } else { + HapticFeedbackConstants.LONG_PRESS + } + }, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt index e653154e3a..fa60941e66 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt @@ -37,11 +37,12 @@ import org.siloserver.silo.android.cast.SiloCastController import org.siloserver.silo.common.ui.components.ThumbhashImage /** - * Persistent "Playing on " bar shown above the tab content whenever a TV + * Persistent Remote Control bar shown above the tab content whenever a TV * control session is active and the full remote is dismissed. Mirrors - * silo-apple's `SiloControlMiniBar`: poster thumb, title, target line, and a - * trailing play/pause button (spinner while reconnecting). Tapping reopens - * the remote; disconnect lives in the remote's menu, not here. + * silo-apple's `SiloControlMiniBar`: poster thumb, title, target line + * ("Playing on", "Starting on", or "Connected / to" while the TV is idle), + * and a trailing play/pause button (spinner while reconnecting or starting). + * Tapping reopens the remote; disconnect lives in the remote's menu, not here. * * Inset handling is the caller's: on tab screens the bar sits in the * Scaffold's bottomBar slot directly above the nav menu (which owns the @@ -55,6 +56,8 @@ fun SiloCastMiniBar( ) { val state by controller.state.collectAsState() val playback = state.playbackState + val launch = state.launch + val isIdle = playback?.contentId.isNullOrEmpty() val targetName = state.connectedTarget?.name ?: "Silo TV" // Keep the bar up through a reconnect (with a spinner) instead of having // it vanish and pop back; hide it while an auto-resume probe is still @@ -67,7 +70,7 @@ fun SiloCastMiniBar( exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), modifier = modifier, ) { - val artwork = rememberSiloCastArtwork(playback?.contentId) + val artwork = rememberSiloCastArtwork(launch?.contentId ?: playback?.contentId) Surface( onClick = onOpenRemote, modifier = Modifier @@ -87,13 +90,23 @@ fun SiloCastMiniBar( Column(modifier = Modifier.weight(1f)) { Text( - if (state.isReconnecting) "Reconnecting…" else (playback?.title ?: "Connected"), + when { + state.isReconnecting -> "Reconnecting…" + launch != null -> artwork.title ?: "Starting playback" + isIdle -> "Connected" + else -> playback?.title ?: "Connected" + }, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall, ) Text( - if (state.isReconnecting) "to $targetName" else "Playing on $targetName", + when { + state.isReconnecting -> "to $targetName" + launch != null -> "Starting on $targetName…" + isIdle -> "to $targetName" + else -> "Playing on $targetName" + }, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -101,7 +114,7 @@ fun SiloCastMiniBar( ) } - if (state.isReconnecting) { + if (state.isReconnecting || launch != null) { CircularProgressIndicator( modifier = Modifier.size(22.dp), strokeWidth = 2.dp, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt index 106eb5860f..e8f5c079d7 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt @@ -118,7 +118,9 @@ fun SiloCastRemoteScreen( ) { val state by controller.state.collectAsState() val playback = state.playbackState - val artwork = rememberSiloCastArtwork(playback?.contentId) + val launch = state.launch + // A title on its way to the TV shows its own artwork, not the outgoing one. + val artwork = rememberSiloCastArtwork(launch?.contentId ?: playback?.contentId) val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var showTargetPicker by remember { mutableStateOf(false) } @@ -181,6 +183,14 @@ fun SiloCastRemoteScreen( ) { when { state.isReconnecting -> RemoteStatus(title = "Reconnecting…", showSpinner = true) + // From the tap until the TV reports the title, including + // over an idle TV or the title being replaced. + launch != null -> RemoteLaunching( + title = artwork.title, + targetName = state.connectedTarget?.name, + posterUrl = artwork.posterUrl ?: artwork.backdropUrl, + posterThumbhash = artwork.posterThumbhash ?: artwork.backdropThumbhash, + ) playback == null -> RemoteConnecting( targetName = state.connectedTarget?.name, // A fully torn-down session (TV disconnected, reconnect @@ -189,10 +199,6 @@ fun SiloCastRemoteScreen( ?: "Not connected to a TV.".takeIf { state.connectedTarget == null && !state.isConnecting }, onChooseTv = { showTargetPicker = true }, ) - playback.contentId == null && state.isLaunching -> RemoteStatus( - title = "Starting playback on ${state.connectedTarget?.name ?: "Silo TV"}…", - showSpinner = true, - ) playback.contentId == null -> RemoteIdleConnected( targetName = state.connectedTarget?.name, ) @@ -404,6 +410,49 @@ private fun RemoteStatus(title: String, showSpinner: Boolean) { } } +/** A Play on its way to the TV: the title's poster and "Starting on …". */ +@Composable +private fun RemoteLaunching( + title: String?, + targetName: String?, + posterUrl: String?, + posterThumbhash: String?, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + modifier = Modifier.padding(24.dp), + ) { + RemotePoster(posterUrl = posterUrl, posterThumbhash = posterThumbhash) + if (!title.isNullOrEmpty()) { + Text( + title, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = RemoteOnSurface, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CircularProgressIndicator( + color = RemoteOnSurface, + strokeWidth = 2.dp, + modifier = Modifier.size(18.dp), + ) + Text( + "Starting on ${targetName ?: "Silo TV"}…", + style = MaterialTheme.typography.titleMedium, + color = RemoteSecondary, + textAlign = TextAlign.Center, + ) + } + } +} + /** Connected with nothing playing — mirrors Apple's idle remote. */ @Composable private fun RemoteIdleConnected(targetName: String?) { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt index 5122d104ff..90867d4c02 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt @@ -5,6 +5,7 @@ import java.util.UUID import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import org.siloserver.silo.cast.SiloCastHandoffChallenge import org.siloserver.silo.cast.SiloCastHandoffOffer import org.siloserver.silo.cast.SiloCastHandoffReady @@ -67,17 +68,24 @@ class RemotePlaybackIdentityManager( controllerDeviceId: String, controllerDeviceName: String?, receiverRun: Long, + /** Called once the offer needs the full server exchange rather than a reuse. */ + onFullHandoff: () -> Unit = {}, beforeActivation: suspend () -> Unit = {}, onChallenge: suspend (SiloCastHandoffChallenge) -> Unit, ): SiloCastHandoffReady = mutex.withLock { validateOffer(offer) + // An end in flight has already unpublished its identity (see + // [endLocked]) and holds this lock, so an identity being ended is + // never reused: this waits for it and takes the full path. activeIdentity?.takeIf { matches(offer, controllerDeviceId) }?.let { active -> val reclaimed = active.copy(receiverRun = receiverRun) activeIdentity = reclaimed return@withLock reclaimed.toReady(offer.requestId, reused = true) } + onFullHandoff() + val capability = deviceLoginApi.remotePlaybackCapabilityAt(offer.serverURL).successOrThrow() require(capability.remotePlaybackHandoff && SiloCastProtocol.version in capability.protocolVersions) { "The phone's Silo server does not support remote playback handoff." @@ -203,23 +211,29 @@ class RemotePlaybackIdentityManager( private suspend fun endLocked() { val active = activeIdentity + // Unpublish before the logout below: while it is in flight, readers + // outside the lock must not launch a title under this identity. + activeIdentity = null try { if (active != null && tokenManager.hasTemporaryScope()) { - deviceLoginApi.endRemotePlayback( - AuthScopeSnapshot( - serverId = active.serverId, - serverUrl = active.serverUrl, - profileId = active.profileId, - profileToken = null, - credentialGenerationId = active.generationId, - ), - ) + // Bounded: a handoff waiting on this lock must not sit behind + // an unreachable server's request timeout. + withTimeoutOrNull(LOGOUT_TIMEOUT_MS) { + deviceLoginApi.endRemotePlayback( + AuthScopeSnapshot( + serverId = active.serverId, + serverUrl = active.serverUrl, + profileId = active.profileId, + profileToken = null, + credentialGenerationId = active.generationId, + ), + ) + } } } finally { // Local credential teardown is mandatory even if the best-effort // server logout throws or the transport disappears mid-request. tokenManager.endTemporaryScope() - activeIdentity = null } } @@ -266,5 +280,6 @@ class RemotePlaybackIdentityManager( private companion object { const val DEFAULT_SESSION_MS = 24 * 60 * 60 * 1_000L + const val LOGOUT_TIMEOUT_MS = 5_000L } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicy.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicy.kt new file mode 100644 index 0000000000..1d7751798e --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicy.kt @@ -0,0 +1,31 @@ +package org.siloserver.silo.tv.cast + +import org.siloserver.silo.network.AndroidServerRegistry + +/** + * Decides when a phone's handoff offer can skip the server-mediated profile + * exchange because the TV is already signed in as the offered profile on the + * same server. Mirrors silo-apple's `RemotePlaybackOwnIdentityPolicy`. + * + * The fast path never grants authorization: [authorizedAtHello] must already + * hold, so a cross-server controller always takes the full handoff. + */ +internal object RemotePlaybackOwnIdentityPolicy { + fun accepts( + /** The controller's hello matched this TV's own server. */ + authorizedAtHello: Boolean, + /** A phone's temporary identity is installed, or still being ended. */ + holdsTemporaryIdentity: Boolean, + signedIn: Boolean, + ownServerId: String?, + ownProfileId: String?, + offerServerId: String, + offerProfileId: String, + ): Boolean = + authorizedAtHello && + !holdsTemporaryIdentity && + signedIn && + AndroidServerRegistry.serverIdsMatch(ownServerId, offerServerId) && + !ownProfileId.isNullOrBlank() && + ownProfileId == offerProfileId +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index 3dc0eeace0..28383fd0c8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -4,11 +4,14 @@ import android.util.Log import java.io.Closeable import java.net.ServerSocket import java.net.Socket +import java.time.Instant +import java.util.UUID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope @@ -32,6 +35,8 @@ import org.siloserver.silo.cast.SiloCastControlCommand import org.siloserver.silo.cast.SiloCastError import org.siloserver.silo.cast.SiloCastHello import org.siloserver.silo.cast.SiloCastHandoffCancel +import org.siloserver.silo.cast.SiloCastHandoffOffer +import org.siloserver.silo.cast.SiloCastHandoffReady import org.siloserver.silo.cast.SiloCastLaunchRequest import org.siloserver.silo.cast.SiloCastMessage import org.siloserver.silo.cast.SiloCastPeerRole @@ -45,6 +50,9 @@ import org.siloserver.silo.common.lan.SiloCastTls import org.siloserver.silo.common.lan.SiloCastTlsSession import org.siloserver.silo.network.AndroidServerRegistry import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionPhase +import org.siloserver.silo.network.TokenManager /** * TV-side SiloCast receiver for the cross-platform v2 protocol shared with @@ -65,10 +73,18 @@ import org.siloserver.silo.network.ServerRegistry * right after every accepted control command. * - A deliberate teardown sends a `close` frame ahead of the FIN so the * controller can tell "disconnected on purpose" from a dropped link. + * - A same-server phone offering the profile this TV is already signed in + * with gets `handoff_ready` at once and plays on the TV's own identity. + * A borrowed profile stays installed while its phone is connected, so the + * next title reuses it, and is handed back when that phone leaves, another + * controller takes over, or after [IDENTITY_IDLE_MS] with nothing playing. */ class TvSiloCastReceiver( private val advertiser: SiloCastNsdAdvertiser, private val serverRegistry: ServerRegistry, + private val tokenManager: TokenManager, + /** Reports sign-in, profile, server and borrowed-identity changes. */ + private val identityTransitions: IdentityTransitionBarrier, private val identityManager: RemotePlaybackIdentityManager, private val deviceNameProvider: () -> String, private val deviceIdProvider: () -> String, @@ -80,8 +96,12 @@ class TvSiloCastReceiver( data class StandbyState( val controllerName: String?, val serverName: String?, + /** Set while a full profile handoff prepares a title the phone is about to play. */ + val preparing: Preparing? = null, ) { val controllerLabel: String get() = controllerName?.takeIf { it.isNotBlank() } ?: "phone" + + data class Preparing(val title: String?) } private val json = Json { @@ -102,10 +122,33 @@ class TvSiloCastReceiver( private var sessionEpoch: Long = 0 private var activePlayer: ActivePlayer? = null private val volumeTracker = SiloCastVolumeTracker() - private val launchRequestChannel = Channel(capacity = 1) - val launchRequests: Flow = launchRequestChannel.receiveAsFlow() - private var pendingPlayerIdentityGeneration: String? = null + private val launchRequestChannel = Channel(capacity = 1) + val launchRequests: Flow = launchRequestChannel.receiveAsFlow() + // The admitted launch on its way to the player, until its player registers + // or the launch expires. Non-null holds off other launches and handoffs; + // cleanup clears it only for its own launch. + private var pendingLaunch: AdmittedLaunch? = null + // The launch whose player registered last. Its player registers again when + // its screen is recreated (an activity restart), which is allowed only + // while the identity it was admitted on is unchanged. + private var registeredLaunch: AdmittedLaunch? = null + // Counts sign-in, profile, server and borrowed-identity changes that affect + // the identity in use. A launch stays current only while it is unchanged; + // removing some other saved server does not count. + private var identityRevision: Long = 0 + + init { + identityTransitions.installGate { transition -> + if (transition.phase == IdentityTransitionPhase.WILL_CHANGE && transition.affectsCurrentIdentity) { + synchronized(this@TvSiloCastReceiver) { identityRevision += 1 } + } + } + } private var identityEndJob: Job? = null + // A scheduled end that has committed to ending this generation. It and + // launch admission decide under the receiver lock, so a launch is never + // admitted onto an identity whose end has started. + private var endingGenerationId: String? = null // stop() cancels the receiver scope, so cleanup for a ready-but-unconsumed // temporary identity must have an owner that survives that cancellation. private val identityCleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -168,7 +211,10 @@ class TvSiloCastReceiver( DiagnosticsCastLogger.event("TV cast receiver stopped") advertiser.stop() val stoppedRun = receiverRun - pendingPlayerIdentityGeneration = null + pendingLaunch = null + // A launch navigation has not taken yet belongs to the session being + // torn down; it must not open when the TV app comes back. + launchRequestChannel.tryReceive() identityEndJob = null // Close the session directly (not via closePreviousController, which // launches the goodbye on `scope` — the scope we cancel a line later, @@ -202,23 +248,43 @@ class TvSiloCastReceiver( } } + /** + * Registers the player on screen. [launchId] is the [Launch.id] the player + * was opened for, if a phone launched it. Navigation already refuses a stale + * launch; if one goes stale in the moment before its player registers + * (expired, or its identity changed), nothing is registered and + * [onStaleLaunch] runs so the screen closes instead of playing on. + */ @Synchronized fun registerPlayer( adapter: TvSiloCastPlayerAdapter, + launchId: String? = null, + onStaleLaunch: () -> Unit = {}, stateProvider: () -> SiloCastPlaybackState, ): Closeable { + if (launchId != null && !confirmLaunchLocked(launchId)) { + DiagnosticsCastLogger.warning("TV cast launch expired before its player opened") + onStaleLaunch() + return Closeable {} + } DiagnosticsCastLogger.event("TV cast player registered") identityEndJob?.cancel() identityEndJob = null - val identityGeneration = pendingPlayerIdentityGeneration - ?: identityManager.activeIdentity?.generationId - pendingPlayerIdentityGeneration = null + // Only the launched player takes the launch's generation; another + // player leaves the pending launch to its own player. + val launched = pendingLaunch?.takeIf { launchId != null && it.id == launchId } + val identityGeneration = launched?.generation ?: identityManager.activeIdentity?.generationId + if (launched != null) { + pendingLaunch = null + registeredLaunch = launched + } val player = ActivePlayer( adapter = adapter, stateProvider = stateProvider, identityGeneration = identityGeneration, ) activePlayer = player + activeSession?.let { clearPreparing(it, refresh = false) } _standbyState.value = null advertiser.updatePlaying(true) return Closeable { @@ -227,8 +293,16 @@ class TvSiloCastReceiver( DiagnosticsCastLogger.event("TV cast player unregistered") activePlayer = null advertiser.updatePlaying(false) - if (player.identityGeneration != null) { - scheduleIdentityEnd(player.identityGeneration) + val generation = player.identityGeneration + if (generation != null) { + // The phone that lent the profile is still here: keep + // it so that phone's next title starts without a new + // handoff, until it leaves or the TV sits idle. + if (isLenderConnected(generation)) { + scheduleIdentityEnd(generation, IDENTITY_IDLE_MS) + } else { + scheduleIdentityEnd(generation, keepIfLenderReturns = true) + } } refreshStandbyState() } @@ -376,11 +450,6 @@ class TvSiloCastReceiver( } catch (t: Throwable) { Log.w(TAG, "SiloCast controller session ended", t) } finally { - val orphanedReadyIdentity = if (session.remoteLaunchReady && activePlayer == null) { - identityManager.activeIdentity?.generationId - } else { - null - } synchronized(this) { if (activeSession === session) { activeSession = null @@ -388,8 +457,15 @@ class TvSiloCastReceiver( } } session.close() - if (orphanedReadyIdentity != null) { - scheduleIdentityEnd(orphanedReadyIdentity, READY_TIMEOUT_MS) + // The phone that lent the profile left (disconnected, dropped, was + // disconnected from the TV, or lost the slot to another phone). + // Hand the profile back unless a title is playing on it or on its + // way to the player; playback outlives a lost socket. + // A phone that reconnects within the grace keeps it. + val lent = identityManager.activeIdentity + ?.takeIf { it.controllerDeviceId == session.controllerDeviceId } + if (lent != null && isIdentityIdle()) { + scheduleIdentityEnd(lent.generationId, keepIfLenderReturns = true) } } } @@ -457,6 +533,21 @@ class TvSiloCastReceiver( ) return true } + // A launch on its way to the player would land on whatever + // identity this handoff installs. The phone tries again once the + // player opens, or the pending launch expires. + if (synchronized(this) { pendingLaunch != null }) { + session.send( + SiloCastMessage.HandoffCancel( + SiloCastHandoffCancel( + requestId = offer.requestId, + reason = "launch_pending", + message = LAUNCH_PENDING_MESSAGE, + ), + ), + ) + return true + } session.handoffJob?.cancel() // A reused identity must survive a pending cleanup. One this // offer would replace stays until approval, so its cleanup still @@ -466,20 +557,47 @@ class TvSiloCastReceiver( identityEndJob?.cancel() identityEndJob = null } + session.remoteLaunchReady = false + session.ownIdentityProfileId = null val run = receiverRun session.handoffJob = scope?.launch { try { + // The hello may have been judged against a phone's + // identity that is gone now; with none installed, the + // TV's own server decides. + if (identityManager.activeIdentity == null) { + session.isAuthorized = AndroidServerRegistry.serverIdsMatch( + session.controllerServerId, + serverRegistry.activeServerId.value, + ) + refreshStandbyState() + } + ownIdentityReady(session, offer)?.let { ready -> + clearPreparing(session) + session.ownIdentityProfileId = offer.profileId + DiagnosticsCastLogger.event("TV cast handoff skipped on own profile") + session.send(SiloCastMessage.HandoffReady(ready)) + session.send(SiloCastMessage.State(currentState())) + return@launch + } + var fullHandoff = false val ready = identityManager.prepare( offer = offer, controllerDeviceId = controllerId, controllerDeviceName = session.controllerDeviceName, receiverRun = run, + onFullHandoff = { + fullHandoff = true + showPreparing(session, offer.title) + }, beforeActivation = ::stopPlayerBeforeIdentitySwap, ) { challenge -> if (activeSession === session) { session.send(SiloCastMessage.HandoffChallenge(challenge)) } } + // A reuse is instant; drop a cancelled offer's title. + if (!fullHandoff) clearPreparing(session) if (activeSession !== session) { identityManager.end() return@launch @@ -491,33 +609,25 @@ class TvSiloCastReceiver( refreshAdvertisement() session.send(SiloCastMessage.HandoffReady(ready)) session.send(SiloCastMessage.State(currentState())) - session.readyTimeoutJob?.cancel() - session.readyTimeoutJob = launch { - delay(READY_TIMEOUT_MS) - // Ready-without-launch only abandons an UNUSED - // identity: with a player active (a reused-identity - // offer mid-playback), ending it would rip the - // token overlay out from under the live stream. - if (activeSession === session && session.remoteLaunchReady) { - session.remoteLaunchReady = false - if (activePlayer == null) { - identityManager.end() - refreshAdvertisement() - session.send( - SiloCastMessage.Error( - SiloCastError( - code = "launch_timeout", - message = "No content was launched, so the temporary profile was restored.", - ), - ), - ) - session.goodbyeAndClose() - } + // Replaces the old ready-without-launch timeout: with + // nothing playing, the profile goes back after the + // idle limit. A player cancels this when it opens. + if (activePlayer == null) { + identityManager.activeIdentity?.let { + scheduleIdentityEnd(it.generationId, IDENTITY_IDLE_MS) + } + } + if (fullHandoff) { + session.preparingClearJob?.cancel() + session.preparingClearJob = scope?.launch { + delay(PREPARING_AFTER_READY_MS) + clearPreparing(session) } } } catch (e: CancellationException) { throw e } catch (t: Throwable) { + clearPreparing(session) if (session.remoteLaunchReady && activePlayer == null) { identityManager.end() session.remoteLaunchReady = false @@ -540,16 +650,19 @@ class TvSiloCastReceiver( is SiloCastMessage.HandoffCancel -> { session.handoffJob?.cancel() session.handoffJob = null - // Disarm the ready-without-launch watchdog: the offer it was - // guarding is dead, and with a player active (identity in use - // by live playback) letting it fire would end the session. - session.readyTimeoutJob?.cancel() - session.readyTimeoutJob = null - if (session.remoteLaunchReady && activePlayer == null) { - val generation = identityManager.activeIdentity?.generationId - if (generation != null) scheduleIdentityEnd(generation, 0L) - } session.remoteLaunchReady = false + session.ownIdentityProfileId = null + clearPreparing(session) + // The phone is still here, so a profile it lent stays for its + // next title, as on tvOS. Make sure the idle limit covers one + // a cancelled handoff installed without arming it. + identityManager.activeIdentity + ?.takeIf { + it.controllerDeviceId == session.controllerDeviceId && + isIdentityIdle() && + identityEndJob?.isActive != true + } + ?.let { scheduleIdentityEnd(it.generationId, IDENTITY_IDLE_MS) } } is SiloCastMessage.Launch -> { if (!requireAuthorized(session)) return true @@ -572,7 +685,10 @@ class TvSiloCastReceiver( ) return true } - if (!session.remoteLaunchReady || identityManager.activeIdentity == null) { + // A sign-in, profile or borrowed-identity change after this + // makes the launch stale (see confirmLaunch). + val identity = settledLaunchIdentity(session)?.takeIf { it.onOwnIdentity || it.borrowed != null } + if (identity == null) { session.send( SiloCastMessage.Error( SiloCastError( @@ -583,11 +699,9 @@ class TvSiloCastReceiver( ) return true } - if (!AndroidServerRegistry.serverIdsMatch( - message.launch.serverId, - identityManager.activeIdentity?.serverId, - ) - ) { + val onOwnIdentity = identity.onOwnIdentity + val borrowed = identity.borrowed + if (!AndroidServerRegistry.serverIdsMatch(message.launch.serverId, identity.serverId)) { session.send( SiloCastMessage.Error( SiloCastError( @@ -598,10 +712,39 @@ class TvSiloCastReceiver( ) return true } - val generation = identityManager.activeIdentity?.generationId - pendingPlayerIdentityGeneration = generation - if (generation == null || !launchRequestChannel.trySend(message.launch).isSuccess) { - pendingPlayerIdentityGeneration = null + val generation = if (onOwnIdentity) null else borrowed?.generationId + val launchOwner = UUID.randomUUID().toString() + val admitted = AdmittedLaunch(launchOwner, generation, identity.revision) + val refusal = synchronized(this) { + when { + // A phone that lost the slot while this was in flight. + activeSession !== session -> SiloCastError( + code = "unauthorized", + message = "Another phone is controlling this TV.", + ) + pendingLaunch != null -> SiloCastError( + code = "launch_pending", + message = LAUNCH_PENDING_MESSAGE, + ) + // The identity changed since the checks above, or its + // hand-back has committed. + !isAdmittedIdentityCurrentLocked(admitted) -> SiloCastError( + code = "handoff_required", + message = "Prepare the phone profile before playing.", + ) + else -> { + pendingLaunch = admitted + null + } + } + } + if (refusal != null) { + if (refusal.code == "handoff_required") session.remoteLaunchReady = false + session.send(SiloCastMessage.Error(refusal)) + return true + } + if (!launchRequestChannel.trySend(Launch(message.launch, launchOwner)).isSuccess) { + clearPendingLaunch(launchOwner) session.send( SiloCastMessage.Error( SiloCastError( @@ -612,10 +755,17 @@ class TvSiloCastReceiver( ) return true } + // A launch whose player never opens must not hold off every + // later handoff. + scope?.launch { + delay(PENDING_LAUNCH_TIMEOUT_MS) + clearPendingLaunch(launchOwner, dropQueued = true) + } session.remoteLaunchReady = false + session.ownIdentityProfileId = null + // The TV is opening the player now; the preparing view is done. + clearPreparing(session, refresh = false) _standbyState.value = null - session.readyTimeoutJob?.cancel() - session.readyTimeoutJob = null } is SiloCastMessage.Control -> { if (!requireAuthorized(session)) return true @@ -690,19 +840,65 @@ class TvSiloCastReceiver( return false } + /** + * Ends [generationId] after [delayMs]. With [keepIfLenderReturns] (the + * lending phone left), a phone back in the slot by then keeps it under + * the idle limit instead: a dropped link reconnects within seconds. + */ private fun scheduleIdentityEnd( generationId: String, delayMs: Long = IDENTITY_END_GRACE_MS, + keepIfLenderReturns: Boolean = false, ) { identityEndJob?.cancel() val owner = scope ?: return identityEndJob = owner.launch { delay(delayMs) - if (!identityManager.end(generationId)) return@launch - pendingPlayerIdentityGeneration = null - refreshAdvertisement() + if (keepIfLenderReturns && isLenderConnected(generationId) && isIdentityIdle()) { + scheduleIdentityEnd(generationId, IDENTITY_IDLE_MS) + return@launch + } + // Commit under the lock launch admission uses: a launch admitted + // first keeps the identity (its player schedules the next end); + // once committed, a later launch is refused. + val commit = synchronized(this@TvSiloCastReceiver) { + if (isIdentityIdle()) { + endingGenerationId = generationId + true + } else { + false + } + } + if (!commit) return@launch + // Cancellable only while waiting: a new offer may still keep the + // identity, but once the logout starts it runs to the end, and + // that offer's prepare() waits for it and takes the full path. + val ended = try { + withContext(NonCancellable) { + identityManager.end(generationId).also { done -> + if (done) { + // Only this generation's stale launch: a newer one + // admitted meanwhile keeps its protection. + synchronized(this@TvSiloCastReceiver) { + if (pendingLaunch?.generation == generationId) pendingLaunch = null + } + refreshAdvertisement() + } + } + } + } finally { + synchronized(this@TvSiloCastReceiver) { + if (endingGenerationId == generationId) endingGenerationId = null + } + } + if (!ended) return@launch + // A newer offer cancelled this mid-logout and now owns the + // session's authorization; judging it here could drop a phone + // that is still handing off. + if (!isActive) return@launch val session = activeSession if (session != null) { + session.remoteLaunchReady = false session.isAuthorized = AndroidServerRegistry.serverIdsMatch( session.controllerServerId, serverRegistry.activeServerId.value, @@ -717,13 +913,163 @@ class TvSiloCastReceiver( } } + /** No title is playing on, or on its way to, a phone's identity. */ + private fun isIdentityIdle(): Boolean = activePlayer == null && pendingLaunch == null + + /** + * Whether launch [id] may still open its player: it is the pending launch, + * or the launch whose player registered last, and the identity it was + * admitted on is unchanged. Navigation checks this before it opens the + * player and before it creates the player screen; registration checks it + * again. A stale launch gives up its place, so the next launch is not + * refused while it waits out its expiry. + */ + @Synchronized + fun confirmLaunch(id: String): Boolean = confirmLaunchLocked(id) + + /** Caller holds the receiver lock. */ + private fun confirmLaunchLocked(id: String): Boolean { + pendingLaunch?.takeIf { it.id == id }?.let { pending -> + if (isAdmittedIdentityCurrentLocked(pending)) return true + pendingLaunch = null + return false + } + registeredLaunch?.takeIf { it.id == id }?.let { registered -> + if (isAdmittedIdentityCurrentLocked(registered)) return true + registeredLaunch = null + } + return false + } + + /** + * Reads the identity [session]'s launch would play on. The barrier holds + * identity changes off while this runs, so the checks agree with the + * revision read alongside them: a change already under way is waited out, + * never half-observed. Null if changes kept landing meanwhile. + */ + private suspend fun settledLaunchIdentity(session: ControllerSession): LaunchIdentity? { + repeat(SETTLE_ATTEMPTS) { + val observed = identityTransitions.generation.value + identityTransitions.withCurrentGeneration(observed) { + val ownProfileId = session.ownIdentityProfileId + // Launch-ready on the TV's own identity: still signed in as + // that profile with no phone's identity installed since. + val onOwnIdentity = ownProfileId != null && + identityManager.activeIdentity == null && + !tokenManager.hasTemporaryScope() && + tokenManager.getProfileId() == ownProfileId + // activeIdentity is unpublished as soon as an end starts, so a + // launch never lands on an identity being handed back. + val borrowed = identityManager.activeIdentity?.takeIf { session.remoteLaunchReady } + LaunchIdentity( + revision = synchronized(this@TvSiloCastReceiver) { identityRevision }, + onOwnIdentity = onOwnIdentity, + borrowed = borrowed, + serverId = if (onOwnIdentity) serverRegistry.activeServerId.value else borrowed?.serverId, + ) + }?.let { return it } + } + return null + } + + /** + * Caller holds the receiver lock. No sign-in, profile, server or borrowed + * identity change since [launch] was admitted, and a borrowed identity is + * not being ended. + */ + private fun isAdmittedIdentityCurrentLocked(launch: AdmittedLaunch): Boolean { + if (identityRevision != launch.identityRevision) return false + val generation = launch.generation ?: return true + return identityManager.activeIdentity?.generationId == generation && endingGenerationId != generation + } + + /** + * Clears the pending launch only if [owner] still holds it. With + * [dropQueued], also drops its request if navigation has not taken it yet, + * so an expired launch never opens later under another identity. Admission + * holds every other launch off while one is pending, so a request still + * queued is this one. + */ + private fun clearPendingLaunch(owner: String, dropQueued: Boolean = false) { + synchronized(this) { + if (pendingLaunch?.id == owner) { + pendingLaunch = null + if (dropQueued) launchRequestChannel.tryReceive() + } + } + } + + /** Whether the phone that lent [generationId] holds the controller slot. */ + private fun isLenderConnected(generationId: String): Boolean { + val identity = identityManager.activeIdentity ?: return false + val session = activeSession ?: return false + return identity.generationId == generationId && + session.isAuthorized && + session.controllerDeviceId == identity.controllerDeviceId + } + + /** + * The `handoff_ready` for an offer the TV can play on its own identity + * (see [RemotePlaybackOwnIdentityPolicy]), or null when the offer needs + * the full handoff. + */ + private suspend fun ownIdentityReady( + session: ControllerSession, + offer: SiloCastHandoffOffer, + ): SiloCastHandoffReady? { + val ownServerId = serverRegistry.activeServerId.value + val ownScope = tokenManager.snapshotCurrentScope() + val accepts = RemotePlaybackOwnIdentityPolicy.accepts( + authorizedAtHello = session.isAuthorized && + AndroidServerRegistry.serverIdsMatch(session.controllerServerId, ownServerId), + holdsTemporaryIdentity = identityManager.activeIdentity != null || tokenManager.hasTemporaryScope(), + signedIn = !tokenManager.getAccessToken().isNullOrBlank(), + ownServerId = ownScope?.serverId?.takeIf { AndroidServerRegistry.serverIdsMatch(it, ownServerId) }, + ownProfileId = ownScope?.profileId, + offerServerId = offer.serverId, + offerProfileId = offer.profileId, + ) + if (!accepts) return null + return SiloCastHandoffReady( + requestId = offer.requestId, + serverId = offer.serverId, + profileId = offer.profileId, + // Required on the wire, though neither phone reads it. The TV's + // own sign-in has no session deadline to report, so this uses + // the same one-day fallback as a handoff whose server omits one. + sessionExpiresAt = Instant.ofEpochMilli(System.currentTimeMillis() + OWN_IDENTITY_SESSION_MS).toString(), + reused = true, + ) + } + + private fun showPreparing(session: ControllerSession, title: String?) { + session.preparingClearJob?.cancel() + session.preparingClearJob = null + session.preparing = StandbyState.Preparing( + title = title?.trim()?.take(MAX_PREPARING_TITLE_LENGTH)?.ifBlank { null }, + ) + refreshStandbyState() + } + + private fun clearPreparing(session: ControllerSession, refresh: Boolean = true) { + session.preparingClearJob?.cancel() + session.preparingClearJob = null + if (session.preparing == null) return + session.preparing = null + if (refresh) refreshStandbyState() + } + private fun refreshStandbyState() { val session = activeSession - _standbyState.value = if (session != null && session.isAuthorized && activePlayer == null) { + val preparing = session?.preparing + // A cross-server phone is not authorized until its handoff finishes, + // but the TV still shows what it is preparing for that phone. + _standbyState.value = if (session != null && activePlayer == null && (session.isAuthorized || preparing != null)) { StandbyState( controllerName = session.controllerDeviceName, serverName = identityManager.activeIdentity?.serverName ?: serverRegistry.activeEntry.value?.displayName, + preparing = preparing, ) } else { null @@ -836,11 +1182,18 @@ class TvSiloCastReceiver( @Volatile var remoteLaunchReady: Boolean = false + /** Launch-ready on the TV's own identity for this profile (no handoff ran). */ + @Volatile + var ownIdentityProfileId: String? = null + + @Volatile + var preparing: StandbyState.Preparing? = null + @Volatile var missedHeartbeats: Int = 0 var job: Job? = null var handoffJob: Job? = null - var readyTimeoutJob: Job? = null + var preparingClearJob: Job? = null suspend fun send(message: SiloCastMessage) { val payload = json.encodeToString(SiloCastMessage.serializer(), message).encodeToByteArray() @@ -873,13 +1226,30 @@ class TvSiloCastReceiver( fun close() { handoffJob?.cancel() - readyTimeoutJob?.cancel() + preparingClearJob?.cancel() tls.close() runCatching { socket.close() } job?.cancel() } } + /** A phone's launch on its way to the player, with the [id] it was admitted under. */ + data class Launch(val request: SiloCastLaunchRequest, val id: String) + + /** + * A launch as admitted: the phone's borrowed identity [generation] (null on + * the TV's own identity) and the receiver's [identityRevision] then. + */ + private data class AdmittedLaunch(val id: String, val generation: String?, val identityRevision: Long) + + /** What a launch would play on: the TV's own profile or a [borrowed] one, as of [revision]. */ + private data class LaunchIdentity( + val revision: Long, + val onOwnIdentity: Boolean, + val borrowed: RemotePlaybackIdentityManager.ActiveIdentity?, + val serverId: String?, + ) + private data class ActivePlayer( val adapter: TvSiloCastPlayerAdapter, val stateProvider: () -> SiloCastPlaybackState, @@ -896,8 +1266,15 @@ class TvSiloCastReceiver( const val MAX_MISSED_HEARTBEATS = 3 const val AUTH_GRACE_MS = 5_000L const val GOODBYE_TIMEOUT_MS = 1_000L - const val READY_TIMEOUT_MS = 60_000L const val IDENTITY_END_GRACE_MS = 2_000L const val PLAYBACK_TEARDOWN_TIMEOUT_MS = 15_000L + const val PENDING_LAUNCH_TIMEOUT_MS = 30_000L + // Identity changes rarely land back to back; past this, refuse the launch. + const val SETTLE_ATTEMPTS = 3 + const val LAUNCH_PENDING_MESSAGE = "The TV is still starting the last title. Try again in a moment." + const val IDENTITY_IDLE_MS = 10 * 60_000L + const val PREPARING_AFTER_READY_MS = 20_000L + const val OWN_IDENTITY_SESSION_MS = 24 * 60 * 60 * 1_000L + const val MAX_PREPARING_TITLE_LENGTH = 120 } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 6bd2f4ef75..4ca793e4ce 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -390,6 +390,8 @@ val androidTvModule = module { TvSiloCastReceiver( advertiser = get(), serverRegistry = get(), + tokenManager = get(), + identityTransitions = get(), identityManager = get(), deviceNameProvider = ::tvDeviceName, deviceIdProvider = { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 67f918c9e8..3959d19758 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -546,8 +546,10 @@ fun TvAppNavigation( } LaunchedEffect(siloCastReceiver) { - siloCastReceiver.launchRequests.collect { request -> - val playback = request.playback + siloCastReceiver.launchRequests.collect { launch -> + // An expired launch, or one whose identity changed, never opens. + if (!siloCastReceiver.confirmLaunch(launch.id)) return@collect + val playback = launch.request.playback val destination = TvRoute.Player( contentId = playback.contentId, libraryId = playback.libraryId, @@ -555,6 +557,7 @@ fun TvAppNavigation( resumePositionSeconds = if (playback.startFromBeginning) 0.0 else playback.resumePosition, audioTrackIndex = playback.audioTrackIndex, subtitleTrackIndex = playback.subtitleTrackIndex, + castLaunchId = launch.id, ).route // Replace whichever player is on top, not just the video one. This // only knew about TvRoute.Player, so a cast launch during an @@ -1383,8 +1386,31 @@ fun TvAppNavigation( nullable = true defaultValue = null }, + navArgument(TvRoute.Player.ARG_CAST_LAUNCH) { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStack -> + val castLaunchId = backStack.arguments?.getString(TvRoute.Player.ARG_CAST_LAUNCH) + // Acts only while this entry is on top: once a newer launch has + // replaced it, that navigation already tears this player down. + val leaveStaleCastLaunch: (stopPlayback: () -> Unit) -> Unit = { stopPlayback -> + if (navController.currentBackStackEntry?.id == backStack.id) { + stopPlayback() + navController.popBackStack() + } + } + // A phone's launch that went stale after navigation took it never + // creates its player, so nothing starts loading under another identity. + val castLaunchCurrent = remember(backStack.id) { + castLaunchId == null || siloCastReceiver.confirmLaunch(castLaunchId) + } + if (!castLaunchCurrent) { + LaunchedEffect(backStack.id) { leaveStaleCastLaunch {} } + return@composable + } val contentId = backStack.arguments ?.getString(TvRoute.Player.ARG_CONTENT_ID) .orEmpty() @@ -1467,6 +1493,8 @@ fun TvAppNavigation( episodeSelectionHandoff = episodeSelectionHandoff, navigationSettled = !transition.isRunning, shuffleId = backStack.arguments?.getString(TvRoute.Player.ARG_SHUFFLE_ID), + castLaunchId = castLaunchId, + onStaleCastLaunch = leaveStaleCastLaunch, onExit = { navController.popBackStack() }, // Host Stop: back to the room's lobby in place of the player. // The membership is kept, so the lobby follows the next Start. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt index b7fa5836a1..67342b68ca 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt @@ -129,6 +129,8 @@ sealed class TvRoute(val route: String) { val libraryId: Int? = null, /** The running shuffle this item is a pick of; picks play from the beginning. */ val shuffleId: String? = null, + /** The phone launch this player is opened for, checked when the player registers. */ + val castLaunchId: String? = null, ) : TvRoute( buildString { append("player/${contentId.routeEncode()}") @@ -153,6 +155,7 @@ sealed class TvRoute(val route: String) { add("${VideoPlayerRouteArgs.RESUME_POSITION}=$value") } shuffleId?.takeIf { it.isNotBlank() }?.let { add("$ARG_SHUFFLE_ID=${it.routeEncode()}") } + castLaunchId?.takeIf { it.isNotBlank() }?.let { add("$ARG_CAST_LAUNCH=${it.routeEncode()}") } } if (query.isNotEmpty()) append("?").append(query.joinToString("&")) }, @@ -164,7 +167,8 @@ sealed class TvRoute(val route: String) { "&subtitleAutoResolved={subtitleAutoResolved}" + "&autoAdvanceCount={autoAdvanceCount}&resumePosition={resumePosition}" + "&episodeSelectionHandoffNonce={episodeSelectionHandoffNonce}" + - "&shuffleId={shuffleId}" + "&shuffleId={shuffleId}" + + "&castLaunch={castLaunch}" const val ARG_CONTENT_ID = "contentId" const val ARG_FILE_ID = "fileId" const val ARG_QUALITY = "quality" @@ -177,6 +181,7 @@ sealed class TvRoute(val route: String) { const val ARG_RESUME_POSITION = VideoPlayerRouteArgs.RESUME_POSITION const val ARG_EPISODE_SELECTION_HANDOFF_NONCE = "episodeSelectionHandoffNonce" const val ARG_SHUFFLE_ID = "shuffleId" + const val ARG_CAST_LAUNCH = "castLaunch" } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/cast/TvSiloCastStandbyView.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/cast/TvSiloCastStandbyView.kt index 1bfc23f4ab..3f5c11e8bc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/cast/TvSiloCastStandbyView.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/cast/TvSiloCastStandbyView.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Tv +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -20,6 +21,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button @@ -28,11 +30,17 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.siloserver.silo.tv.cast.TvSiloCastReceiver +/** + * Full-screen takeover while a phone controls the TV with nothing playing: + * "Ready for ", or, while a profile handoff prepares a title the phone + * is about to play, "Starting " with a spinner. + */ @Composable fun TvSiloCastStandbyView( state: TvSiloCastReceiver.StandbyState, onDisconnect: () -> Unit, ) { + val preparing = state.preparing val disconnectFocus = remember { FocusRequester() } LaunchedEffect(Unit) { disconnectFocus.requestFocus() } @@ -53,27 +61,45 @@ fun TvSiloCastStandbyView( .background(Color.White.copy(alpha = 0.08f), CircleShape), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Outlined.Tv, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(76.dp), - ) + if (preparing != null) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 5.dp, + modifier = Modifier.size(76.dp), + ) + } else { + Icon( + imageVector = Icons.Outlined.Tv, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(76.dp), + ) + } } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = "Ready for ${state.controllerLabel}", + text = when { + preparing == null -> "Ready for ${state.controllerLabel}" + preparing.title != null -> "Starting ${preparing.title}" + else -> "Starting playback" + }, color = Color.White, fontSize = 52.sp, lineHeight = 58.sp, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, ) Text( - text = state.serverName ?: "Remote control active", + text = if (preparing != null) { + "From ${state.controllerName?.takeIf { it.isNotBlank() } ?: "your phone"}" + } else { + state.serverName ?: "Remote control active" + }, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index e4d1b1f13e..1d3b280855 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -279,6 +279,14 @@ fun TvPlayerScreen( navigationSettled: Boolean = true, // The running shuffle this item is a pick of; picks play from the beginning. shuffleId: String? = null, + // The phone launch that opened this player, checked when it registers. + castLaunchId: String? = null, + // Called when that launch went stale before this player registered. It + // runs stopPlayback and leaves only while this player is still on top. + onStaleCastLaunch: (stopPlayback: () -> Unit) -> Unit = { stopPlayback -> + stopPlayback() + onExit() + }, // Scope the ViewModel key by fileId too so switching 4K <-> 1080p on // the detail screen and replaying actually spins up a fresh player // session instead of reusing the cached one bound to the first fileId. @@ -508,10 +516,13 @@ fun TvPlayerScreen( ) } } + var staleCastLaunch by remember(castLaunchId) { mutableStateOf(false) } TvSiloCastPlayerRegistration( siloCastReceiver = siloCastReceiver, viewModel = viewModel, contentId = contentId, + castLaunchId = castLaunchId, + onStaleCastLaunch = { staleCastLaunch = true }, watchParty = watchParty, mediaController = mediaController, playbackSpeed = playbackSpeed, @@ -546,6 +557,13 @@ fun TvPlayerScreen( } } val stopPlaybackAndExit = { exitPlayer(true, null) } + val latestOnStaleCastLaunch by rememberUpdatedState(onStaleCastLaunch) + // The phone's launch went stale just before this player registered. The + // callback checks this player is still on top right before stopping what + // it started, so a newer launch's player is never stopped or popped. + LaunchedEffect(staleCastLaunch) { + if (staleCastLaunch) latestOnStaleCastLaunch { exitPlayer(false) {} } + } // A remote "stop"/"terminate" command tears the screen down like a Back press. LaunchedEffect(Unit) { viewModel.remoteStopRequests.collect { stopPlaybackAndExit() } @@ -2593,6 +2611,8 @@ private fun TvSiloCastPlayerRegistration( siloCastReceiver: TvSiloCastReceiver, viewModel: TvPlayerViewModel, contentId: String, + castLaunchId: String?, + onStaleCastLaunch: () -> Unit, watchParty: TvWatchPartyScreenController?, mediaController: MediaController?, playbackSpeed: Double, @@ -2681,7 +2701,11 @@ private fun TvSiloCastPlayerRegistration( // A phone's launch never silently replaces the party player. launchRefusal = { watchParty?.refuseLaunch() }, ) - val registration = siloCastReceiver.registerPlayer(adapter) { + val registration = siloCastReceiver.registerPlayer( + adapter = adapter, + launchId = castLaunchId, + onStaleLaunch = onStaleCastLaunch, + ) { val volumeState = siloCastReceiver.resolvePlayerVolume( currentVolume = latestSiloCastMediaController?.volume?.toDouble(), ) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt index b3cc188e6f..7cf656f50f 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt @@ -2,13 +2,21 @@ package org.siloserver.silo.tv.cast import android.app.Application import kotlin.test.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.siloserver.silo.cast.SiloCastHandoffOffer +import org.siloserver.silo.model.auth.DeviceLoginStartResponse import org.siloserver.silo.network.AndroidServerRegistry +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.DeviceLoginApi /** * The receiver's stop() ends the temporary identity only after the player's @@ -78,6 +86,33 @@ class RemotePlaybackIdentityClaimTest { assertTrue(tokens.hasTemporaryScope()) } + @Test fun anIdentityBeingEndedIsNeverReused() = runTest { + val gated = GatedLogoutApi(api) + val manager = RemotePlaybackIdentityManager(gated, tokens, deviceNameProvider = { "TV" }) + suspend fun prepareOn(run: Long) = + manager.prepare(offer(), CONTROLLER, controllerDeviceName = null, receiverRun = run) {} + + prepareOn(STOPPED_RUN) + val ending = assertNotNull(manager.activeIdentity) + val logout = CompletableDeferred<Unit>() + gated.logoutGate = logout + + val end = launch { manager.end(ending.generationId) } + runCurrent() + // The logout is in flight: nothing outside the lock may launch on it. + assertNull(manager.activeIdentity) + + val sameOffer = async { prepareOn(NEXT_RUN) } + runCurrent() + logout.complete(Unit) + end.join() + val ready = sameOffer.await() + + assertFalse(ready.reused) + assertEquals(2, gated.startCalls) + assertNotEquals(ending.generationId, manager.activeIdentity?.generationId) + } + private suspend fun prepare(offer: SiloCastHandoffOffer, run: Long) = manager.prepare(offer, CONTROLLER, controllerDeviceName = null, receiverRun = run) {} @@ -88,6 +123,26 @@ class RemotePlaybackIdentityClaimTest { profileId = profileId, ) + /** Counts handoff starts and can hold a logout open; the rest is [FakeRemotePlaybackApi]. */ + private class GatedLogoutApi(private val inner: FakeRemotePlaybackApi) : DeviceLoginApi by inner { + var startCalls = 0 + var logoutGate: CompletableDeferred<Unit>? = null + + override suspend fun startRemotePlaybackAt( + serverUrl: String, + deviceName: String?, + devicePlatform: String?, + ): ApiResult<DeviceLoginStartResponse> { + startCalls += 1 + return inner.startRemotePlaybackAt(serverUrl, deviceName, devicePlatform) + } + + override suspend fun endRemotePlayback(scope: AuthScopeSnapshot): ApiResult<Unit> { + logoutGate?.await() + return inner.endRemotePlayback(scope) + } + } + private companion object { const val SERVER_URL = "https://media.example.test" const val CONTROLLER = "phone-1" diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicyTest.kt new file mode 100644 index 0000000000..ae0514f38b --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackOwnIdentityPolicyTest.kt @@ -0,0 +1,55 @@ +package org.siloserver.silo.tv.cast + +import android.app.Application +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.siloserver.silo.network.AndroidServerRegistry + +/** + * The same-profile fast path skips the server's profile exchange, so every + * condition must hold; failing any one sends the offer down the full handoff. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RemotePlaybackOwnIdentityPolicyTest { + + @Test fun acceptsTheTvsOwnProfileOnItsOwnServer() { + assertTrue(accepts()) + } + + @Test fun refusesEachMissingCondition() { + assertFalse(accepts(authorizedAtHello = false), "a controller not authorized at hello") + assertFalse(accepts(holdsTemporaryIdentity = true), "a phone's identity installed or ending") + assertFalse(accepts(signedIn = false), "a TV that is signed out") + assertFalse(accepts(ownServerId = OTHER_SERVER_ID), "a different server") + assertFalse(accepts(ownServerId = null), "no active server") + assertFalse(accepts(ownProfileId = "profile-2"), "a different profile") + assertFalse(accepts(ownProfileId = null), "no active profile") + assertFalse(accepts(ownProfileId = ""), "a blank profile") + } + + private fun accepts( + authorizedAtHello: Boolean = true, + holdsTemporaryIdentity: Boolean = false, + signedIn: Boolean = true, + ownServerId: String? = SERVER_ID, + ownProfileId: String? = "profile-1", + ) = RemotePlaybackOwnIdentityPolicy.accepts( + authorizedAtHello = authorizedAtHello, + holdsTemporaryIdentity = holdsTemporaryIdentity, + signedIn = signedIn, + ownServerId = ownServerId, + ownProfileId = ownProfileId, + offerServerId = SERVER_ID, + offerProfileId = "profile-1", + ) + + private companion object { + val SERVER_ID = AndroidServerRegistry.idFor("https://media.example.test") + val OTHER_SERVER_ID = AndroidServerRegistry.idFor("https://other.example.test") + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt index eb52afed18..473c1898e7 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt @@ -183,6 +183,8 @@ data class SiloCastHandoffOffer( val serverName: String? = null, val profileId: String, val profileName: String? = null, + /** Display-only: what the phone is about to launch. Older peers omit and ignore it. */ + val title: String? = null, ) @Serializable From 05b3f916a4d30c3b3e063fd181415960639d572f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:10:24 -0400 Subject: [PATCH 70/87] fix(tv): show the real Silo app icon in setup step 1 (#533) The "Set up with your phone" screen pictured step 1, "Open Silo on your phone", with three slanted bars on black: a hand-drawn stand-in that has no play triangle or bar cutouts and doesn't match the app icon. Draw the flat Silo mark from silo-branding (logo/svg/silo-mark-flat.svg, added as the silo_mark vector) on the app icon's #010D9F field, at the 84% height the icon uses. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../components/marquee/TvMarqueeSetupSteps.kt | 35 ++++++++----------- .../androidMain/res/drawable/silo_mark.xml | 24 +++++++++++++ 2 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 androidTvApp/src/androidMain/res/drawable/silo_mark.xml diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt index afad8c5f60..34a61acca9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/marquee/TvMarqueeSetupSteps.kt @@ -1,6 +1,6 @@ package org.siloserver.silo.tv.ui.components.marquee -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -23,7 +23,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontWeight @@ -32,7 +34,7 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text import org.siloserver.silo.common.ui.marquee.MarqueeColors -import kotlin.math.tan +import org.siloserver.silo.tv.R /** * How to set this TV up from a phone: three numbered steps, one action each, @@ -80,33 +82,24 @@ private fun Step(number: Int, title: String, picture: @Composable () -> Unit) { } } -/** The Silo app icon: the three-bar mark on black. */ +/** The Silo app icon: the mark on the deep blue field. */ @Composable private fun AppIconPicture() { Box( Modifier .size(46.dp) .clip(RoundedCornerShape(11.dp)) - .background(Color.Black) + .background(colorResource(R.color.silo_icon_background)) .border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(11.dp)), contentAlignment = Alignment.Center, ) { - Canvas(Modifier.size(width = 13.dp, height = 28.dp)) { - val bar = size.height / 3.6f - val slope = tan(Math.toRadians(-18.0)).toFloat() - listOf(MarqueeColors.BrandBlue, MarqueeColors.BrandRed, MarqueeColors.BrandOrange).forEachIndexed { index, color -> - val top = index * (bar + size.height * 0.05f) - // A bar slanted like CSS skewY(-18deg). - val path = Path().apply { - moveTo(0f, top) - lineTo(size.width, top + slope * size.width) - lineTo(size.width, top + bar + slope * size.width) - lineTo(0f, top + bar) - close() - } - drawPath(path, color) - } - } + // The icon draws the mark at 84% of its height. + Image( + painter = painterResource(R.drawable.silo_mark), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(38.5.dp), + ) } } diff --git a/androidTvApp/src/androidMain/res/drawable/silo_mark.xml b/androidTvApp/src/androidMain/res/drawable/silo_mark.xml new file mode 100644 index 0000000000..453a747d39 --- /dev/null +++ b/androidTvApp/src/androidMain/res/drawable/silo_mark.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The Silo mark, flat, from silo-branding logo/svg/silo-mark-flat.svg. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="51.41dp" + android:viewportWidth="266.2084" + android:viewportHeight="855.3524"> + <path + android:fillColor="#0034FB" + android:fillType="evenOdd" + android:pathData="M0.167,24.131 A24.000,24.000 0 0 1 36.167,3.346 L239.822,120.927 A24.000,24.000 0 0 1 239.822,162.496 L36.167,280.077 A24.000,24.000 0 0 1 0.167,259.292 Z" /> + <path + android:fillColor="#0034FB" + android:fillType="evenOdd" + android:pathData="M0.167,356.350 A27.000,27.000 0 0 1 13.667,332.967 L225.667,210.569 A27.000,27.000 0 0 1 266.167,233.952 L266.167,323.098 A27.000,27.000 0 0 1 252.667,346.481 L40.667,468.879 A27.000,27.000 0 0 1 0.167,445.496 Z M81.167,349.097 A11.000,11.000 0 0 1 86.667,339.571 L168.667,292.228 A11.000,11.000 0 0 1 185.167,301.754 L185.167,330.351 A11.000,11.000 0 0 1 179.667,339.877 L97.667,387.220 A11.000,11.000 0 0 1 81.167,377.694 Z" /> + <path + android:fillColor="#F50B4F" + android:fillType="evenOdd" + android:pathData="M0.167,547.750 A27.000,27.000 0 0 1 13.667,524.367 L225.667,401.969 A27.000,27.000 0 0 1 266.167,425.352 L266.167,514.498 A27.000,27.000 0 0 1 252.667,537.881 L40.667,660.279 A27.000,27.000 0 0 1 0.167,636.896 Z M81.167,540.497 A11.000,11.000 0 0 1 86.667,530.971 L168.667,483.628 A11.000,11.000 0 0 1 185.167,493.154 L185.167,521.751 A11.000,11.000 0 0 1 179.667,531.277 L97.667,578.620 A11.000,11.000 0 0 1 81.167,569.094 Z" /> + <path + android:fillColor="#FA7604" + android:fillType="evenOdd" + android:pathData="M0.167,739.150 A27.000,27.000 0 0 1 13.667,715.767 L225.667,593.369 A27.000,27.000 0 0 1 266.167,616.752 L266.167,705.898 A27.000,27.000 0 0 1 252.667,729.281 L40.667,851.679 A27.000,27.000 0 0 1 0.167,828.296 Z M81.167,731.897 A11.000,11.000 0 0 1 86.667,722.371 L168.667,675.028 A11.000,11.000 0 0 1 185.167,684.554 L185.167,713.151 A11.000,11.000 0 0 1 179.667,722.677 L97.667,770.020 A11.000,11.000 0 0 1 81.167,760.494 Z" /> +</vector> From 1e956c3624b6c3edd03d9df3f6bef40e4e698346 Mon Sep 17 00:00:00 2001 From: Suspense <rhainland@gmail.com> Date: Sat, 10 Oct 2026 05:36:09 +0200 Subject: [PATCH 71/87] fix(settings): let a device go back to the profile's playback settings (#500) * fix(settings): let a device go back to the profile's playback settings Android writes every playback setting as a device override, but the only way back was "Reset Playback Overrides", which cleared everything at once, without asking and without feedback. Each setting the profile can also hold now offers "Use profile setting": the first option of the Quality, Audio Language, Show Next Up and Skip Intros pickers (checked while the device has no value of its own), and a "Use Profile Setting" row under the Auto-Play Next Episode and Skip Credits switches while the device holds its own value. Choosing it deletes the device value through the same scoped queue writes use, and clears the keys a control writes together (both quality axes; the intro skip enum and its deprecated boolean). The store tracks which keys this device holds, from its own writes and from the scope each refresh reports. Choosing no audio language now clears the device value instead of storing an explicit null over the profile's language, so the separate "No preference" entry is gone from the device picker. The bulk action is now "Use Profile Settings". It asks first ("Use your profile's settings on this device?") and shows a notice afterwards, which says when the change is still waiting for the server. The copy says that device-only settings go back to their defaults, which is what the action does to them. On TV, the option pickers now focus the current choice when they open; before, the dialog's first row kept focus, which would now be "Use profile setting". * fix(settings): confirm the profile reset against the server's answer Use Profile Settings reported success whenever the write queue came back empty. The queue also drops deletes it cannot retry, such as a 401 or 403, so the notice could say the profile's settings apply while the device values were still on the server. The reset now reports success only when the following refresh reads the server's values and none of them still resolves from this device. * fix(settings): confirm a device reset only for the profile it started on resetAllDeviceSettings confirmed success from a refresh that re-read the active scope, so a profile switch or sign-out during the reset could confirm it from another profile's answer and write that answer into the wrong store. The confirming refresh now runs against the scope captured at the start, with its authority, and the reset reports false once that scope is no longer active. * fix(settings): stop promising a reset will apply after reconnecting "Use Profile Settings" said the profile's settings would apply when the device reconnects whenever the server didn't confirm the reset. That only holds while the app stays alive and offline. The write queue is in memory and drops deletes it can't retry (a refused delete, a profile switch), so in those cases nothing applies later. The notice now says the server didn't confirm the change and asks the user to try again; running the reset again is safe. The legacy device-settings import also marks each value it writes as this device's own, as every other device write does, so imported values offer "Use profile setting" before the next refresh confirms them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../settings/AndroidPlayerSettingsStore.kt | 215 +++++++++++++--- .../common/settings/PlayerSettingsStore.kt | 29 ++- .../common/settings/UseProfileSettingsCopy.kt | 51 ++++ .../AndroidPlayerSettingsStoreTest.kt | 191 ++++++++++++++ .../ServerDrivenConfigRefresherTest.kt | 2 +- .../ui/screens/settings/PlaybackSettings.kt | 124 +++++++++- .../ui/screens/settings/SettingsComponents.kt | 8 +- .../ui/screens/settings/SettingsPages.kt | 13 + .../ui/screens/settings/SettingsViewModel.kt | 32 ++- ...erViewModelLoadOwnershipIntegrationTest.kt | 2 +- ...SettingsViewModelOfflinePreferencesTest.kt | 34 ++- .../ui/screens/settings/TvSettingsScreen.kt | 232 ++++++++++++++---- .../screens/settings/TvSettingsViewModel.kt | 35 ++- .../tv/testing/FakePlayerSettingsStore.kt | 2 +- .../PlaybackSettingsKeysContractTest.kt | 48 ++++ .../model/settings/PlaybackSettingsKeys.kt | 36 +++ 16 files changed, 941 insertions(+), 113 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/UseProfileSettingsCopy.kt create mode 100644 shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeysContractTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt index 518008a0e9..90e97a5762 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStore.kt @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import org.siloserver.silo.domain.player.IntroSkipMode import org.siloserver.silo.model.download.DownloadQuality @@ -399,6 +400,9 @@ class AndroidPlayerSettingsStore( override val subtitleMatchesDeviceFlow: Flow<Boolean> = profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.SubtitleMatchesDevice, false) } + override val deviceOverrideKeysFlow: Flow<Set<String>> = + profileScopedFlow(emptySet()) { p, s -> p[deviceOverridesKey(s)].orEmpty() } + override val showAudiobooksFlow: Flow<Boolean> = profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.NavShowAudiobooks, false) } @@ -490,7 +494,10 @@ class AndroidPlayerSettingsStore( // step, and the flusher then drops the write for good. val clamped = PlaybackSpeedRange.normalize(value) withScope { scope, store -> - store.edit { it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PlaybackSpeed)] = clamped.toString() } + store.edit { + it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PlaybackSpeed)] = clamped.toString() + markDeviceOverride(it, scope, PlaybackSettingsKeys.PlaybackSpeed) + } serverSettingsFlusher.enqueue( scope.profileId, PlaybackSettingsKeys.PlaybackSpeed, @@ -531,6 +538,7 @@ class AndroidPlayerSettingsStore( store.edit { it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PreferredQuality)] = normalized it[intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.MaxBitrateKbps)] = capped + markDeviceOverride(it, scope, PlaybackSettingsKeys.PreferredQuality, PlaybackSettingsKeys.MaxBitrateKbps) } serverSettingsFlusher.enqueue( scope.profileId, @@ -549,8 +557,20 @@ class AndroidPlayerSettingsStore( } } - override suspend fun setAudioLanguage(value: String) = - writeString(PlaybackSettingsKeys.AudioLanguage, value) + /** + * `""` (no preference) clears this device's value instead of storing an + * explicit null over the profile's language. The legacy endpoint spelled + * "no preference" as `""` and its migration turned that into no stored + * row; sending JSON null at device scope would instead pin "no + * preference" here and hide the profile's choice. + */ + override suspend fun setAudioLanguage(value: String) { + if (value.isEmpty()) { + resetDeviceSetting(PlaybackSettingsKeys.AudioLanguage) + } else { + writeString(PlaybackSettingsKeys.AudioLanguage, value) + } + } override suspend fun setVideoGravity(value: String) { val safe = if (value in VALID_VIDEO_GRAVITY) value else "fit" @@ -591,6 +611,7 @@ class AndroidPlayerSettingsStore( // Setting an explicit appearance implicitly enables the // device override (matches iOS `setSubtitleAppearance`). prefs[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true + markDeviceOverride(prefs, scope, PlaybackSettingsKeys.SubtitleAppearance) serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } } @@ -615,6 +636,7 @@ class AndroidPlayerSettingsStore( if (prefs.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") == json) return@edit prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + markDeviceOverride(prefs, scope, PlaybackSettingsKeys.SubtitleAppearance) serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } } @@ -633,7 +655,21 @@ class AndroidPlayerSettingsStore( // ---- Server-sync surface ------------------------------------------ override suspend fun refreshFromServer() { - val repo = settingsRepository ?: return + refreshFromServerConfirmed() + } + + /** + * [refreshFromServer], answering whether the server's effective values + * were read and applied. + * + * With [pinned], the refresh is for that captured scope only: the request + * carries its authority, the answer is written to its store, and a scope + * that stops being the active one (a profile switch, a sign-out, a server + * change) makes it apply nothing and answer false. Without it the refresh + * follows whichever scope is active when it runs. + */ + private suspend fun refreshFromServerConfirmed(pinned: Scope? = null): Boolean { + val repo = settingsRepository ?: return false // Push before pull. Local writes sit in the flusher's debounce for // ~750ms; a refresh inside that window read the server's OLD value and // wrote it back over the change the user had just made. The player @@ -646,28 +682,60 @@ class AndroidPlayerSettingsStore( // gates revision-dependent fields on a current answer and a server // upgraded while the app ran is noticed. contractRevision?.let { revision -> - getServerUrl()?.takeIf { it.isNotBlank() }?.let { url -> runCatching { revision.refresh(url) } } + (pinned?.serverUrl ?: getServerUrl())?.takeIf { it.isNotBlank() }?.let { url -> runCatching { revision.refresh(url) } } } runCatching { serverSettingsFlusher.flushNow() } - withScope { scope, store -> - // 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 - // Draining is not the same as landing: a write that failed - // transiently stays queued for retry and flushNow still returns - // normally, so this response was answered from the value the write - // has not reached yet. Applying it for those keys is the same - // clobber the push-first order exists to prevent — it put the - // server's old Dolby Vision value back and restarted the session on - // it. Every other key still hydrates from the canonical answer. - applyEffectiveLocally( - scope = scope, - store = store, - effective = result.data, - unlandedKeys = serverSettingsFlusher.pendingKeys(scope.profileId), - ) + if (pinned != null) { + return applyServerEffective(repo, pinned, storeFor(pinned.profileId), pinned.authority) { isStillCurrent(pinned) } } + var applied = false + withScope { scope, store -> applied = applyServerEffective(repo, scope, store, authority = null) { true } } + return applied + } + + private suspend fun applyServerEffective( + repo: SettingsRepository, + scope: Scope, + store: DataStore<Preferences>, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + isCurrent: suspend () -> Boolean, + ): Boolean { + if (!isCurrent()) return false + // Batched canonical resolution: one request answers every + // device-relevant key, each with the scope it resolved from. + val result = repo.getEffectiveValues(RemoteDeviceSettings, authority = authority) + if (result !is ApiResult.Success || !isCurrent()) return false + // Draining is not the same as landing: a write that failed + // transiently stays queued for retry and flushNow still returns + // normally, so this response was answered from the value the write + // has not reached yet. Applying it for those keys is the same + // clobber the push-first order exists to prevent — it put the + // server's old Dolby Vision value back and restarted the session on + // it. Every other key still hydrates from the canonical answer. + return applyEffectiveLocally( + scope = scope, + store = store, + effective = result.data, + unlandedKeys = serverSettingsFlusher.pendingKeys(scope.profileId), + isCurrent = isCurrent, + ) + } + + /** + * Whether [captured] is still the active scope: same profile, server and + * device, and the same signed-in identity (the check + * [importLegacyDeviceSettings] applies to its captured authority). + */ + private suspend fun isStillCurrent(captured: Scope): Boolean { + val now = currentScope() ?: return false + if (now.profileId != captured.profileId || now.serverUrl != captured.serverUrl || now.deviceId != captured.deviceId) { + return false + } + val owner = captured.authority ?: return now.authority == null + val current = now.authority + return owner.isSameIdentityAs(current) && + owner.profileId == current?.profileId && + owner.profileToken == current?.profileToken } override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) { @@ -692,6 +760,7 @@ class AndroidPlayerSettingsStore( // otherwise the fields left by whatever resolved while the // override was off win right back over it. writeGranularAppearance(it, scope, sanitized) + markDeviceOverride(it, scope, PlaybackSettingsKeys.SubtitleAppearance) // In the transaction, so it enqueues in commit order. serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl, scope.authority) } @@ -705,6 +774,7 @@ class AndroidPlayerSettingsStore( ?.let { value -> it[savedCustomKey] = value } } it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false + clearDeviceOverride(it, scope, PlaybackSettingsKeys.SubtitleAppearance) } serverSettingsFlusher.enqueueDelete(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, scope.serverUrl, scope.authority) serverSettingsFlusher.flushNow() @@ -713,15 +783,35 @@ class AndroidPlayerSettingsStore( } } + /** + * Goes back to the profile's value for [key] on this device: deletes the + * device-scoped row through the same scoped queue every write uses, so a + * profile switch or an offline spell treats the clear exactly like a set. + * + * Keys one control writes together ([PlaybackSettingsKeys.deviceOverrideGroup]) + * are cleared together. The override marker is dropped right away, so the + * picker shows the choice at once; the value itself follows from the + * refresh once the delete has landed. Until then the device keeps playing + * with the value it had, because the refresh leaves a key with an unsent + * write alone. + */ override suspend fun resetDeviceSetting(key: String) { - withScope { scope, _ -> - serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl, scope.authority) + val keys = PlaybackSettingsKeys.deviceOverrideGroup(key).filter { it in RemoteDeviceSettings } + if (keys.isEmpty()) return + withScope { scope, store -> + store.edit { + clearDeviceOverride(it, scope, *keys.toTypedArray()) + for (cleared in keys) { + serverSettingsFlusher.enqueueDelete(scope.profileId, cleared, scope.serverUrl, scope.authority) + } + } serverSettingsFlusher.flushNow() refreshFromServer() } } - override suspend fun resetAllDeviceSettings() { + override suspend fun resetAllDeviceSettings(): Boolean { + var landed = false withScope { scope, store -> // Only the server-stored keys have rows to delete; the granular // subtitle.* fields live inside playback.subtitle_appearance. @@ -729,6 +819,7 @@ class AndroidPlayerSettingsStore( serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl, scope.authority) } store.edit { + it.remove(deviceOverridesKey(scope)) it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false // The local-only playback keys have no server row to delete, so // the refresh below can never restore their defaults. Removing @@ -743,8 +834,19 @@ class AndroidPlayerSettingsStore( it.remove(intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PassOutThreshold)) } serverSettingsFlusher.flushNow() - refreshFromServer() + val unsent = serverSettingsFlusher.pendingKeys(scope.profileId).any { it in RemoteDeviceSettings } + // An empty queue is not proof: the flusher also drops deletes it + // cannot retry (a 401/403, a replaced owner). Only the server's + // own answer, with nothing left resolving from this device, + // confirms the profile's settings apply. The refresh is pinned to + // the scope this reset started in: if the user switches profile or + // signs out meanwhile, another profile's answer must neither land + // in this store nor confirm this reset. + val confirmed = refreshFromServerConfirmed(pinned = scope) + val remaining = store.data.first()[deviceOverridesKey(scope)].orEmpty() + landed = !unsent && confirmed && remaining.none { it in RemoteDeviceSettings } && isStillCurrent(scope) } + return landed } override suspend fun flushPendingDeviceSettings() { @@ -759,8 +861,13 @@ class AndroidPlayerSettingsStore( // cannot describe them yet, so the local value stays authoritative // until the queued write lands. unlandedKeys: Set<String> = emptySet(), - ) { + isCurrent: suspend () -> Boolean = { true }, + ): Boolean { + var applied = false store.edit { prefs -> + if (!isCurrent()) return@edit + applied = true + val overrides = prefs[deviceOverridesKey(scope)].orEmpty().toMutableSet() for (key in RemoteDeviceSettings) { if (key in unlandedKeys) continue // The canonical endpoint answers every known key, including @@ -773,7 +880,12 @@ class AndroidPlayerSettingsStore( // guessed at. val entry = effective[key] ?: continue writeJsonValue(prefs, scope, key, entry.value) + // Where the value resolved from is the truth about whether + // this device holds its own, including one set elsewhere (the + // web admin, a previous install) or cleared out-of-band. + if (entry.scope == SettingScope.PROFILE_DEVICE.wire) overrides += key else overrides -= key } + prefs[deviceOverridesKey(scope)] = overrides // An appearance whose write has not landed leaves the flag and the // granular overlay alone too: they describe where the composite // resolved from, and the response predates the queued edit. @@ -802,6 +914,7 @@ class AndroidPlayerSettingsStore( } } } + return applied } /** @@ -859,14 +972,20 @@ class AndroidPlayerSettingsStore( private suspend fun writeBool(key: String, value: Boolean) { withScope { scope, store -> - store.edit { it[booleanPreferencesKey(scope.keyPrefix + key)] = value } + store.edit { + it[booleanPreferencesKey(scope.keyPrefix + key)] = value + markDeviceOverride(it, scope, key) + } serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl, scope.authority) } } private suspend fun writeInt(key: String, value: Int) { withScope { scope, store -> - store.edit { it[intPreferencesKey(scope.keyPrefix + key)] = value } + store.edit { + it[intPreferencesKey(scope.keyPrefix + key)] = value + markDeviceOverride(it, scope, key) + } serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl, scope.authority) } } @@ -897,7 +1016,10 @@ class AndroidPlayerSettingsStore( private suspend fun writeString(key: String, value: String) { withScope { scope, store -> - store.edit { it[stringPreferencesKey(scope.keyPrefix + key)] = value } + store.edit { + it[stringPreferencesKey(scope.keyPrefix + key)] = value + markDeviceOverride(it, scope, key) + } serverSettingsFlusher.enqueue(scope.profileId, key, value, scope.serverUrl, scope.authority) } } @@ -926,6 +1048,9 @@ class AndroidPlayerSettingsStore( store.edit { prefs -> if (!current()) return@edit writeRawString(prefs, scope, key, raw) + // The server now holds it at device scope, so it shows as this + // device's own even before a refresh confirms it. + markDeviceOverride(prefs, scope, key) if (key == PlaybackSettingsKeys.SubtitleAppearance) { val appearance = SubtitleAppearance.decode(raw).sanitized() prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = raw @@ -937,6 +1062,28 @@ class AndroidPlayerSettingsStore( return current() } + private fun deviceOverridesKey(scope: Scope): Preferences.Key<Set<String>> = + stringSetPreferencesKey(scope.keyPrefix + DEVICE_OVERRIDE_KEYS) + + /** Records that this device now holds its own value for [keys]. */ + private fun markDeviceOverride( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + vararg keys: String, + ) { + val remote = keys.filter { it in RemoteDeviceSettings } + if (remote.isEmpty()) return + prefs[deviceOverridesKey(scope)] = prefs[deviceOverridesKey(scope)].orEmpty() + remote + } + + private fun clearDeviceOverride( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + vararg keys: String, + ) { + prefs[deviceOverridesKey(scope)] = prefs[deviceOverridesKey(scope)].orEmpty() - keys.toSet() + } + private suspend inline fun withScope( block: (scope: Scope, store: DataStore<Preferences>) -> Unit, ) { @@ -1002,6 +1149,12 @@ class AndroidPlayerSettingsStore( private companion object { const val SAVED_CUSTOM_SUBTITLE_APPEARANCE = "subtitle_appearance.saved_custom" + /** + * Local-only: the server-stored keys this device holds its own value + * for. Set by every write from here and corrected by every refresh, + * which says where each value resolved from. + */ + const val DEVICE_OVERRIDE_KEYS = "device_override_keys" const val MIGRATION_SENTINEL_LEGACY = "migration_v1" const val MISSING_SENTINEL = "__missing__" // F1/F2 local-only defaults (mirror DefaultResumeRewindSeconds=7.0 and diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt index 9c1e204b78..2b295b01c2 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/PlayerSettingsStore.kt @@ -133,6 +133,14 @@ interface PlayerSettingsStore { val subtitleMatchesDeviceFlow: Flow<Boolean> /** iOS AppNavPreferences.showAudiobooks parity — audiobook surfaces are opt-in. */ val showAudiobooksFlow: Flow<Boolean> + + /** + * The server-stored keys this device holds its own value for, in the + * active profile. A control whose key is absent follows the profile (or + * the default); see [PlaybackSettingsKeys.hasDeviceOverride][org.siloserver.silo.model.settings.PlaybackSettingsKeys.hasDeviceOverride]. + */ + val deviceOverrideKeysFlow: Flow<Set<String>> + get() = flowOf(emptySet()) /** [subtitleAppearanceFlow] with the match-device override applied. */ val effectiveSubtitleAppearanceFlow: Flow<org.siloserver.silo.model.settings.SubtitleAppearance> @@ -247,20 +255,23 @@ interface PlayerSettingsStore { suspend fun setShowAudiobooks(enabled: Boolean) /** - * Clear the server-side device override for one key. Local DataStore - * is repopulated from the cascade (user → global → default) on the - * next refresh. + * Go back to the profile's value for one setting on this device: clears + * the device-scoped value for [key] (and any key its control writes with + * it), then refreshes so the profile's value, or the default, applies. */ suspend fun resetDeviceSetting(key: String) /** - * Return this device's playback settings to their defaults — the user's - * "Reset playback settings" action. Clears every server-side device - * override (as iOS `PlayerSettings.resetAllDeviceSettings()` does) and the - * local-only playback keys that have no server row to clear, since those - * would otherwise survive an action whose whole promise is the defaults. + * The user's "Use Profile Settings" action. Clears every server-side + * device override (as iOS `PlayerSettings.resetAllDeviceSettings()` does) + * and resets the local-only playback keys, which have no profile value, to + * their defaults. + * + * Returns true when every clear reached the server and the server's + * answer no longer resolves any setting from this device; false when some + * are still queued (offline) or the server could not confirm it. */ - suspend fun resetAllDeviceSettings() + suspend fun resetAllDeviceSettings(): Boolean /** * Cancel any in-flight debounce, drain pending writes, and suspend diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/UseProfileSettingsCopy.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/UseProfileSettingsCopy.kt new file mode 100644 index 0000000000..052e307fe9 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/UseProfileSettingsCopy.kt @@ -0,0 +1,51 @@ +package org.siloserver.silo.common.settings + +/** + * The words for going back to the profile's playback settings, shared by the + * phone and TV settings so the two say the same thing. + */ +object UseProfileSettingsCopy { + /** + * The first option of every picker for a setting the profile also holds, + * and the action under such a switch. Choosing it clears this device's + * value. + */ + const val OPTION = "Use profile setting" + + /** The row that does it for one switch. */ + const val ROW = "Use Profile Setting" + + /** The row that does it for every playback setting at once. */ + const val RESET_ALL_ROW = "Use Profile Settings" + + const val CONFIRM_TITLE = "Use your profile's settings on this device?" + + // Truthful about the local-only settings (picture-in-picture, Force HDR + // Passthrough, rewind on resume…): they have no profile value, so the + // action returns them to their defaults. + const val CONFIRM_MESSAGE = + "This removes the settings changed on this device. Settings that only apply to this " + + "device go back to their defaults." + + const val CONFIRM_BUTTON = "Use Profile Settings" + + const val FOOTER = + "Goes back to your profile's playback settings on this device. Settings that only apply " + + "to this device go back to their defaults." + + /** Accessibility label for the row under [setting]'s switch. */ + fun rowDescription(setting: String): String = "Use profile setting for $setting" + + /** + * The notice after the reset. [landed] is false whenever the server's + * answer didn't confirm it: offline, a refused delete, or a profile switch + * mid-reset. The queue is in memory and drops what it can't retry, so the + * text promises nothing about later; running the reset again is safe. + */ + fun notice(landed: Boolean): String = + if (landed) { + "Now using your profile's settings." + } else { + "Couldn't confirm the change with the server. Try again later." + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt index 9c3cf2f6ec..a6fdbd3817 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/AndroidPlayerSettingsStoreTest.kt @@ -144,6 +144,10 @@ class AndroidPlayerSettingsStoreTest { assertEquals("720p",store.preferredQualityFlow.first()) assertEquals(2000,store.maxBitrateKbpsFlow.first()) assertFalse(store.autoPlayNextFlow.first()) + assertEquals( + setOf(PlaybackSettingsKeys.PreferredQuality, PlaybackSettingsKeys.MaxBitrateKbps, PlaybackSettingsKeys.AutoPlayNext), + store.deviceOverrideKeysFlow.first(), + ) assertTrue(fakeFlusher.calls.isEmpty()) } finally { client.close() } } @@ -801,6 +805,183 @@ class AndroidPlayerSettingsStoreTest { } } + @Test + fun `using the profile setting deletes the device value through the scoped queue`() = runTest { + val api = FakeSettingsApi() + val store = newStore(repository = SettingsRepository(api), deviceId = "device") + store.setAutoSkipCredits(true) + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + + // The server now resolves the profile's value. + api.effective = mapOf( + stored(PlaybackSettingsKeys.AutoSkipCredits, JsonPrimitive(false), scope = SettingScope.PROFILE.wire), + ) + val flushesBefore = fakeFlusher.flushNowCount + store.resetDeviceSetting(PlaybackSettingsKeys.AutoSkipCredits) + + val delete = fakeFlusher.calls.last() + assertTrue(delete.isDelete) + assertEquals(PlaybackSettingsKeys.AutoSkipCredits, delete.key) + // Stamped with the profile and server it was made under, like a set, + // so a profile or server switch before it lands drops it rather than + // clearing someone else's value. + assertEquals(activeProfileId, delete.profileId) + assertEquals(serverUrl, delete.serverUrl) + assertTrue(fakeFlusher.flushNowCount > flushesBefore, "the clear is pushed right away") + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + assertFalse(store.autoSkipCreditsFlow.first()) + } + + @Test + fun `using the profile setting clears every key the control writes`() = runTest { + val store = newStore(repository = SettingsRepository(FakeSettingsApi())) + store.setQuality("1080p", 8000) + store.setIntroSkipMode(IntroSkipMode.NEVER) + + store.resetDeviceSetting(PlaybackSettingsKeys.PreferredQuality) + store.resetDeviceSetting(PlaybackSettingsKeys.IntroSkipMode) + + val deleted = fakeFlusher.calls.filter { it.isDelete }.map { it.key }.toSet() + assertEquals( + setOf( + PlaybackSettingsKeys.PreferredQuality, + PlaybackSettingsKeys.MaxBitrateKbps, + PlaybackSettingsKeys.IntroSkipMode, + PlaybackSettingsKeys.AutoSkipIntro, + ), + deleted, + ) + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `no audio language preference clears the device value instead of storing null`() = runTest { + val store = newStore(repository = SettingsRepository(FakeSettingsApi())) + store.setAudioLanguage("ja") + assertTrue(PlaybackSettingsKeys.AudioLanguage in store.deviceOverrideKeysFlow.first()) + + store.setAudioLanguage("") + + val audio = fakeFlusher.calls.filter { it.key == PlaybackSettingsKeys.AudioLanguage } + assertEquals(listOf(false, true), audio.map { it.isDelete }, "a PUT for ja, then a DELETE") + assertFalse(PlaybackSettingsKeys.AudioLanguage in store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `a refresh records which values this device holds itself`() = runTest { + val api = FakeSettingsApi( + effective = mapOf( + stored(PlaybackSettingsKeys.AutoPlayNext, JsonPrimitive(false)), + stored(PlaybackSettingsKeys.AutoSkipCredits, JsonPrimitive(true), scope = SettingScope.PROFILE.wire), + defaulted(PlaybackSettingsKeys.IntroSkipMode, JsonPrimitive("ask")), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setIntroSkipMode(IntroSkipMode.NEVER) + store.refreshFromServer() + // Answered from the device scope only for auto-play; the intro-skip + // write has landed (nothing pending), so the server's answer wins. + assertEquals(setOf(PlaybackSettingsKeys.AutoPlayNext), store.deviceOverrideKeysFlow.first()) + + // A write still queued keeps its marker: the response predates it. + store.setAutoSkipCredits(false) + fakeFlusher.pending = setOf(PlaybackSettingsKeys.AutoSkipCredits) + store.refreshFromServer() + assertEquals( + setOf(PlaybackSettingsKeys.AutoPlayNext, PlaybackSettingsKeys.AutoSkipCredits), + store.deviceOverrideKeysFlow.first(), + ) + } + + @Test + fun `device values are tracked per profile`() = runTest { + var profile = "profile-a" + val stores = mutableMapOf<String, DataStore<Preferences>>() + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), legacyCache = fakeLegacyCache, + getActiveProfileId = { profile }, getServerUrl = { serverUrl }, + getDeviceId = { "device" }, serverSettingsFlusher = fakeFlusher, + dataStoreFactory = { id -> stores.getOrPut(id) { + PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "overrides_$id.preferences_pb") }, + ) + } }, + ) + store.setAutoSkipCredits(true) + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + profile = "profile-b" + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + store.resetDeviceSetting(PlaybackSettingsKeys.AutoSkipCredits) + assertEquals("profile-b", fakeFlusher.calls.last().profileId) + profile = "profile-a" + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `resetAllDeviceSettings clears the markers and reports whether the clears landed`() = runTest { + val store = newStore(repository = SettingsRepository(FakeSettingsApi())) + store.setAutoSkipCredits(true) + assertTrue(store.resetAllDeviceSettings()) + assertEquals(emptySet(), store.deviceOverrideKeysFlow.first()) + + // Offline: the deletes stay queued, and the caller must not claim the + // profile's settings already apply. + fakeFlusher.pending = setOf(PlaybackSettingsKeys.AutoSkipCredits) + assertFalse(store.resetAllDeviceSettings()) + } + + @Test + fun `resetAllDeviceSettings does not report success when the server still has a device value`() = runTest { + // The flusher drops a delete it cannot retry (a 401/403, a replaced + // owner), so the queue comes back empty while the row survives. + val api = FakeSettingsApi( + effective = mapOf( + PlaybackSettingsKeys.AutoSkipCredits to EffectiveSettingValue( + key = PlaybackSettingsKeys.AutoSkipCredits, + value = JsonPrimitive(true), + source = SettingScope.PROFILE_DEVICE.wire, + scope = SettingScope.PROFILE_DEVICE.wire, + ), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setAutoSkipCredits(true) + assertFalse(store.resetAllDeviceSettings()) + assertEquals(setOf(PlaybackSettingsKeys.AutoSkipCredits), store.deviceOverrideKeysFlow.first()) + } + + @Test + fun `resetAllDeviceSettings does not confirm a reset for a profile switched away from mid-reset`() = runTest { + val ownerA = org.siloserver.silo.network.AuthScopeSnapshot("server", "profile-a", serverUrl, "token-a", credentialEpoch = 1) + var owner = ownerA + val stores = mutableMapOf<String, DataStore<Preferences>>() + // Profile B's answer: every device row is gone, which would confirm + // the reset if it were read as profile A's. + val api = FakeSettingsApi( + effective = mapOf(defaulted(PlaybackSettingsKeys.AutoSkipCredits, JsonPrimitive(false))), + ) + val store = AndroidPlayerSettingsStore( + context = mockContextStub(), legacyCache = fakeLegacyCache, + getActiveProfileId = { owner.profileId }, getServerUrl = { serverUrl }, + getDeviceId = { "device" }, serverSettingsFlusher = fakeFlusher, + settingsRepository = SettingsRepository(api), getAuthScope = { owner }, + dataStoreFactory = { id -> stores.getOrPut(id) { + PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "reset_switch_$id.preferences_pb") }, + ) + } }, + ) + store.setAutoSkipCredits(true) + // The user switches to profile B while the reset's refresh is in flight. + api.onEffective = { owner = ownerA.copy(profileId = "profile-b", profileToken = "token-b") } + + assertFalse(store.resetAllDeviceSettings()) + assertEquals(listOf<org.siloserver.silo.network.AuthScopeSnapshot?>(ownerA), api.requestedAuthorities) + // Profile A's store did not take profile B's answer either. + owner = ownerA + assertTrue(store.autoSkipCreditsFlow.first()) + } + @Test fun `setSubtitleDeviceOverrideEnabled false enqueues delete and clears local flag`() = runTest { val repo = SettingsRepository(FakeSettingsApi()) @@ -1049,6 +1230,10 @@ private class FakeServerSettingsFlusher : ServerSettingsFlusher { override suspend fun flushNow() { flushNowCount++ } + + /** Keys whose write is still queued after a flush, as offline would leave them. */ + var pending: Set<String> = emptySet() + override fun pendingKeys(profileId: String): Set<String> = pending } /** @@ -1102,6 +1287,10 @@ private class FakeSettingsApi( // up a second DataStore over the same file. var effective: Map<String, EffectiveSettingValue> = effective var requestedKeys: List<String> = emptyList() + val requestedAuthorities = mutableListOf<org.siloserver.silo.network.AuthScopeSnapshot?>() + + /** Runs while a request is in flight, before it is answered. */ + var onEffective: () -> Unit = {} override suspend fun getEffectiveValues( keys: List<String>, @@ -1110,6 +1299,8 @@ private class FakeSettingsApi( authority: org.siloserver.silo.network.AuthScopeSnapshot?, ): ApiResult<EffectiveSettingValuesResponse> { requestedKeys = keys + requestedAuthorities += authority + onEffective() // Like the server: answer only the keys this contract knows. val entries = keys.mapNotNull { effective[it] } return ApiResult.Success(EffectiveSettingValuesResponse(settings = entries, revision = 1)) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresherTest.kt index 3dca5c38e8..29fe0f1fa3 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresherTest.kt @@ -262,6 +262,6 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { } override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) = Unit override suspend fun resetDeviceSetting(key: String) = Unit - override suspend fun resetAllDeviceSettings() = Unit + override suspend fun resetAllDeviceSettings() = true override suspend fun flushPendingDeviceSettings() = Unit } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt index 825f27ecc9..059affb259 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/PlaybackSettings.kt @@ -2,12 +2,21 @@ package org.siloserver.silo.android.ui.screens.settings import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import org.siloserver.silo.android.R +import org.siloserver.silo.android.ui.components.SiloConfirmDialog +import org.siloserver.silo.common.settings.UseProfileSettingsCopy import org.siloserver.silo.domain.player.IntroSkipMode import org.siloserver.silo.model.settings.LanguageOptions +import org.siloserver.silo.model.settings.PlaybackSettingsKeys import org.siloserver.silo.model.settings.QualityPresets import org.siloserver.silo.model.settings.SettingKeys @@ -31,6 +40,56 @@ private fun nextUpPromptLabel(seconds: Int): String = when { else -> "${seconds / 60} minutes before end" } +/** + * The menu for a profile-layered picker: "Use profile setting" first, then + * [options]. Routes that first option to [onUseProfileSetting] and checks it + * while the device has no value of its own. + */ +@Composable +private fun ProfileLayeredDropdownRow( + label: String, + value: String, + options: List<String>, + overridden: Boolean, + onOptionSelected: (String) -> Unit, + onUseProfileSetting: () -> Unit, +) { + SettingsDropdownRow( + label = label, + value = value, + options = listOf(UseProfileSettingsCopy.OPTION) + options, + selectedOption = if (overridden) value else UseProfileSettingsCopy.OPTION, + onOptionSelected = { option -> + if (option == UseProfileSettingsCopy.OPTION) onUseProfileSetting() else onOptionSelected(option) + }, + ) +} + +/** + * A switch for a setting the profile also holds. A switch has no menu to put + * "Use profile setting" in, so while this device holds its own value the + * action sits in its own row right under the switch, as Cards & Posters' + * "Use Profile Default" does. + */ +@Composable +private fun ProfileLayeredSwitchRow( + label: String, + checked: Boolean, + overridden: Boolean, + onCheckedChange: (Boolean) -> Unit, + onUseProfileSetting: () -> Unit, +) { + SettingsSwitchRow(label = label, checked = checked, onCheckedChange = onCheckedChange) + if (overridden) { + SettingsNavigationRow( + label = UseProfileSettingsCopy.ROW, + onClick = onUseProfileSetting, + showChevron = false, + modifier = Modifier.semantics { contentDescription = UseProfileSettingsCopy.rowDescription(label) }, + ) + } +} + /** * Playback → Streaming (Apple `PlaybackSettingsView` "Streaming"): quality, * audio language, Dolby Vision, and — where the Apple list ends with @@ -46,16 +105,22 @@ fun PlaybackStreamingSection( dolbyVisionEnabled: Boolean, dvProfile7HDR10Fallback: Boolean, pictureInPictureEnabled: Boolean, + /** Keys this device holds its own value for. */ + deviceOverrides: Set<String>, /** Receives a [QualityPresets] preset id. */ onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, onPictureInPictureEnabledChanged: (Boolean) -> Unit, + /** Receives the key of the control going back to the profile's value. */ + onUseProfileSetting: (String) -> Unit, modifier: Modifier = Modifier, ) { + // No "No preference" entry: on a device that means going back to the + // profile's language, which "Use profile setting" already says. val audioLanguageOptions = remember(audioLanguage, audioLanguageSuggestions) { - LanguageOptions.options( + LanguageOptions.namedOptions( key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, currentValue = audioLanguage, runtimeValues = audioLanguageSuggestions, @@ -68,23 +133,27 @@ fun PlaybackStreamingSection( val qualityFooter = QualityPresets.presetFor(qualityResolution, maxBitrateKbps)?.description ?: "$qualityLabel." SettingsSection(title = "Streaming", footer = qualityFooter, modifier = modifier) { - SettingsDropdownRow( + ProfileLayeredDropdownRow( label = "Quality", value = qualityLabel, options = QualityPresets.ALL.map { it.label }, + overridden = PlaybackSettingsKeys.hasDeviceOverride(deviceOverrides, PlaybackSettingsKeys.PreferredQuality), onOptionSelected = { label -> QualityPresets.ALL.firstOrNull { it.label == label } ?.let { onQualityPresetSelected(it.id) } }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.PreferredQuality) }, ) - SettingsDropdownRow( + ProfileLayeredDropdownRow( label = "Audio Language", value = LanguageOptions.label(audioLanguage, SettingKeys.PLAYBACK_AUDIO_LANGUAGE), options = audioLanguageOptions.map { it.second }, + overridden = PlaybackSettingsKeys.hasDeviceOverride(deviceOverrides, PlaybackSettingsKeys.AudioLanguage), onOptionSelected = { label -> - onAudioLanguageChanged(LanguageOptions.wireValue(label, audioLanguageOptions)) + audioLanguageOptions.firstOrNull { it.second == label }?.let { onAudioLanguageChanged(it.first) } }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.AudioLanguage) }, ) // Dolby Vision (off plays the HDR10 base layer) with the Profile 7 @@ -122,14 +191,19 @@ fun PlaybackEpisodesSection( autoSkipCredits: Boolean, resumeRewindSeconds: Int, passOutThreshold: Int, + /** Keys this device holds its own value for. */ + deviceOverrides: Set<String>, onAutoPlayNextChanged: (Boolean) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, onResumeRewindSecondsChanged: (Int) -> Unit, onPassOutThresholdChanged: (Int) -> Unit, + /** Receives the key of the control going back to the profile's value. */ + onUseProfileSetting: (String) -> Unit, modifier: Modifier = Modifier, ) { + fun overridden(key: String) = PlaybackSettingsKeys.hasDeviceOverride(deviceOverrides, key) val introSkipOptions = IntroSkipMode.entries.map { it to stringResource(introSkipModeLabel(it)) } SettingsSection( title = "Episodes", @@ -137,36 +211,44 @@ fun PlaybackEpisodesSection( "Prompt sets how many episodes play in a row before Silo asks whether you're still watching.", modifier = modifier, ) { - SettingsSwitchRow( + ProfileLayeredSwitchRow( label = "Auto-Play Next Episode", checked = autoPlayNext, + overridden = overridden(PlaybackSettingsKeys.AutoPlayNext), onCheckedChange = onAutoPlayNextChanged, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.AutoPlayNext) }, ) - SettingsDropdownRow( + ProfileLayeredDropdownRow( label = "Show Next Up", value = nextUpPromptLabel(nextUpPromptSeconds), options = nextUpPromptOptions.map(::nextUpPromptLabel), + overridden = overridden(PlaybackSettingsKeys.NextUpPromptSeconds), onOptionSelected = { label -> - onNextUpPromptSecondsChanged(nextUpPromptOptions.first { nextUpPromptLabel(it) == label }) + nextUpPromptOptions.firstOrNull { nextUpPromptLabel(it) == label }?.let(onNextUpPromptSecondsChanged) }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.NextUpPromptSeconds) }, ) // Three-way, not a switch: the boolean this replaced could not say // "never". Option labels and semantics are fixed by the contract. - SettingsDropdownRow( + ProfileLayeredDropdownRow( label = "Skip Intros", value = stringResource(introSkipModeLabel(introSkipMode)), options = introSkipOptions.map { it.second }, + overridden = overridden(PlaybackSettingsKeys.IntroSkipMode), onOptionSelected = { label -> introSkipOptions.firstOrNull { it.second == label }?.let { onIntroSkipModeChanged(it.first) } }, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.IntroSkipMode) }, ) - SettingsSwitchRow( + ProfileLayeredSwitchRow( label = "Skip Credits", checked = autoSkipCredits, + overridden = overridden(PlaybackSettingsKeys.AutoSkipCredits), onCheckedChange = onAutoSkipCreditsChanged, + onUseProfileSetting = { onUseProfileSetting(PlaybackSettingsKeys.AutoSkipCredits) }, ) SettingsDropdownRow( @@ -189,17 +271,33 @@ fun PlaybackEpisodesSection( } } -/** Playback → reset, the Apple page's last group. */ +/** + * Playback → "Use Profile Settings", the Apple page's last group. Asks first: + * it clears every playback setting changed on this device at once. + */ @Composable fun PlaybackResetSection(onResetPlaybackOverrides: () -> Unit, modifier: Modifier = Modifier) { + var confirming by rememberSaveable { mutableStateOf(false) } SettingsSection( title = null, - footer = "Resets playback choices for this device and profile back to the server fallback.", + footer = UseProfileSettingsCopy.FOOTER, modifier = modifier, ) { SettingsDestructiveRow( - label = "Reset Playback Overrides", - onClick = onResetPlaybackOverrides, + label = UseProfileSettingsCopy.RESET_ALL_ROW, + onClick = { confirming = true }, + ) + } + if (confirming) { + SiloConfirmDialog( + title = UseProfileSettingsCopy.CONFIRM_TITLE, + body = UseProfileSettingsCopy.CONFIRM_MESSAGE, + confirmLabel = UseProfileSettingsCopy.CONFIRM_BUTTON, + onConfirm = { + confirming = false + onResetPlaybackOverrides() + }, + onDismiss = { confirming = false }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt index 5a19792111..c80a401257 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsComponents.kt @@ -521,6 +521,12 @@ fun SettingsDropdownRow( description: String? = null, icon: ImageVector? = null, enabled: Boolean = true, + /** + * The option the menu checks. Defaults to [value]; differs when the row + * shows the value in effect but the choice is another option, such as + * "Use profile setting". + */ + selectedOption: String = value, ) { var expanded by remember { mutableStateOf(false) } @@ -553,7 +559,7 @@ fun SettingsDropdownRow( options.forEach { option -> SettingsMenuItem( label = option, - selected = option == value, + selected = option == selectedOption, onClick = { onOptionSelected(option) expanded = false diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt index 4087af8b18..5902004ab2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.settings +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import org.koin.compose.koinInject import org.siloserver.silo.android.ui.components.SiloConfirmDialog import org.siloserver.silo.android.ui.components.SiloTopBar @@ -24,6 +26,7 @@ import org.siloserver.silo.android.ui.theme.SettingsDimens import org.siloserver.silo.android.ui.theme.SiloSettingsBackground import org.siloserver.silo.android.ui.util.formatBytes import org.siloserver.silo.common.settings.TitleArtStore +import org.siloserver.silo.common.settings.UseProfileSettingsCopy import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.metadata.MetadataAiOnView import org.siloserver.silo.model.settings.SeekMedia @@ -121,6 +124,12 @@ fun SettingsInterfaceScreen(viewModel: SettingsViewModel, onBackClick: () -> Uni fun SettingsPlaybackScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit) { val state by viewModel.uiState.collectAsState() val seekIntervals by viewModel.seekIntervals.state.collectAsState() + val context = LocalContext.current + LaunchedEffect(state.playbackOverridesReset) { + val landed = state.playbackOverridesReset ?: return@LaunchedEffect + Toast.makeText(context, UseProfileSettingsCopy.notice(landed), Toast.LENGTH_SHORT).show() + viewModel.onPlaybackOverridesResetShown() + } SettingsPageScaffold(title = "Playback", onBackClick = onBackClick) { item(key = "streaming") { @@ -132,11 +141,13 @@ fun SettingsPlaybackScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit dolbyVisionEnabled = state.dolbyVisionEnabled, dvProfile7HDR10Fallback = state.dvProfile7HDR10Fallback, pictureInPictureEnabled = state.pictureInPictureEnabled, + deviceOverrides = state.deviceOverrides, onQualityPresetSelected = viewModel::setQualityPreset, onAudioLanguageChanged = viewModel::setAudioLanguage, onDolbyVisionEnabledChanged = viewModel::setDolbyVisionEnabled, onDvProfile7HDR10FallbackChanged = viewModel::setDvProfile7HDR10Fallback, onPictureInPictureEnabledChanged = viewModel::setPictureInPictureEnabled, + onUseProfileSetting = viewModel::useProfileSetting, ) } item(key = "episodes") { @@ -147,12 +158,14 @@ fun SettingsPlaybackScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit autoSkipCredits = state.autoSkipCredits, resumeRewindSeconds = state.resumeRewindSeconds, passOutThreshold = state.passOutThreshold, + deviceOverrides = state.deviceOverrides, onAutoPlayNextChanged = viewModel::setAutoPlayNext, onNextUpPromptSecondsChanged = viewModel::setNextUpPromptSeconds, onIntroSkipModeChanged = viewModel::setIntroSkipMode, onAutoSkipCreditsChanged = viewModel::setAutoSkipCredits, onResumeRewindSecondsChanged = viewModel::setResumeRewindSeconds, onPassOutThresholdChanged = viewModel::setPassOutThreshold, + onUseProfileSetting = viewModel::useProfileSetting, ) } SeekMedia.entries.forEach { media -> diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 9f90fd5a51..323b48b9ba 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -102,6 +102,17 @@ data class SettingsUiState( // many seconds before the end to surface the card (0 = only at end). val autoPlayNext: Boolean = true, val nextUpPromptSeconds: Int = 30, + /** + * Keys this device holds its own value for. A profile-layered control + * whose key is absent shows "Use profile setting" as its choice. + */ + val deviceOverrides: Set<String> = emptySet(), + /** + * Outcome of the last "Use Profile Settings", until the screen has told the + * user: true when the server confirmed every clear, false when it + * couldn't (offline, refused, or interrupted). + */ + val playbackOverridesReset: Boolean? = null, // Seconds to skip back on resume (0 = off); consecutive auto-advances // before the "Still watching?" prompt (0 = off). val resumeRewindSeconds: Int = 7, @@ -311,7 +322,11 @@ class SettingsViewModel( }.launchIn(viewModelScope) playerSettingsStore.subtitleTextOpacitySupportedFlow.onEach { supported -> _uiState.update { it.copy(subtitleTextOpacitySupported = supported) } - }.launchIn(viewModelScope) } + }.launchIn(viewModelScope) + playerSettingsStore.deviceOverrideKeysFlow.onEach { keys -> + _uiState.update { it.copy(deviceOverrides = keys) } + }.launchIn(viewModelScope) + } fun setDownloadsWifiOnly(value: Boolean) { viewModelScope.launch { playerSettingsStore.setDownloadsWifiOnly(value) } @@ -562,8 +577,21 @@ class SettingsViewModel( } } + /** "Use Profile Settings": clears every setting this device holds its own value for. */ fun resetPlaybackOverrides() { - viewModelScope.launch { playerSettingsStore.resetAllDeviceSettings() } + viewModelScope.launch { + val landed = playerSettingsStore.resetAllDeviceSettings() + _uiState.update { it.copy(playbackOverridesReset = landed) } + } + } + + fun onPlaybackOverridesResetShown() { + _uiState.update { it.copy(playbackOverridesReset = null) } + } + + /** Goes back to the profile's value for one setting's control on this device. */ + fun useProfileSetting(key: String) { + viewModelScope.launch { playerSettingsStore.resetDeviceSetting(key) } } /** Lifecycle hook — call from ON_STOP so debounced writes survive. */ diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index 164495e842..8894f05d2e 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -1740,7 +1740,7 @@ private class FakePlayerSettingsStore( override suspend fun setSubtitleMatchesDevice(enabled: Boolean) = Unit override suspend fun setShowAudiobooks(enabled: Boolean) = Unit override suspend fun resetDeviceSetting(key: String) = Unit - override suspend fun resetAllDeviceSettings() = Unit + override suspend fun resetAllDeviceSettings() = true override suspend fun flushPendingDeviceSettings() = Unit } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt index 74bf2e7a59..4a6e54add5 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -37,6 +37,7 @@ import org.siloserver.silo.model.feature.RequestsFeatureStore import org.siloserver.silo.repository.MetadataAiRepository import org.siloserver.silo.repository.RequestsRepository import org.siloserver.silo.common.settings.CardPresentationUiState +import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.domain.settings.ProfileSettingsController import org.siloserver.silo.model.profile.ActiveProfileStore import org.siloserver.silo.model.profile.Profile @@ -62,6 +63,7 @@ import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.SettingsRepository import java.lang.reflect.Proxy import kotlin.test.assertEquals +import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -127,7 +129,37 @@ class SettingsViewModelOfflinePreferencesTest { assertEquals("always", profiles.activeProfile.value?.subtitleMode) } + @Test + fun useProfileSettingsReportsWhetherTheClearsLanded() { + var landed = true + val cleared = mutableListOf<String>() + val store = object : PlayerSettingsStore by idleStore<PlayerSettingsStore>() { + override suspend fun resetAllDeviceSettings() = landed + override suspend fun resetDeviceSetting(key: String) { + cleared += key + } + } + scenario(playerSettingsStore = store) { vm, _, _ -> + vm.resetPlaybackOverrides() + runCurrent() + assertEquals(true, vm.uiState.value.playbackOverridesReset) + vm.onPlaybackOverridesResetShown() + assertNull(vm.uiState.value.playbackOverridesReset) + + // Unconfirmed (offline, refused): the notice must not claim success. + landed = false + vm.resetPlaybackOverrides() + runCurrent() + assertEquals(false, vm.uiState.value.playbackOverridesReset) + + vm.useProfileSetting(SettingKeys.PLAYBACK_AUTO_SKIP_CREDITS) + runCurrent() + assertEquals(listOf(SettingKeys.PLAYBACK_AUTO_SKIP_CREDITS), cleared) + } + } + private fun scenario( + playerSettingsStore: PlayerSettingsStore = idleStore(), block: suspend TestScope.(SettingsViewModel, PendingSettingsApi, ActiveProfileStore) -> Unit, ) = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -142,7 +174,7 @@ class SettingsViewModelOfflinePreferencesTest { val authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens) val vm = SettingsViewModel( authRepository = authRepository, - playerSettingsStore = idleStore(), + playerSettingsStore = playerSettingsStore, activeProfileStore = profiles, notificationsRepository = NotificationsRepository(NotificationsV2Api(client, tokens, ApiV2Gate.Unrestricted)), profileSettings = ProfileSettingsController(SettingsRepository(api)), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index e83058ff51..23f17d364e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.screens.settings +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.background @@ -81,6 +82,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -101,6 +103,8 @@ import org.siloserver.silo.common.network.clientVersionLabel import org.siloserver.silo.common.settings.CardPresentationSource import org.siloserver.silo.common.settings.CardPresentationSupport import org.siloserver.silo.common.settings.TitleArtStore +import org.siloserver.silo.common.settings.UseProfileSettingsCopy +import org.siloserver.silo.model.settings.PlaybackSettingsKeys import org.siloserver.silo.model.settings.CardCaption import org.siloserver.silo.model.settings.CardPosterSize import org.siloserver.silo.model.settings.CardPresentation @@ -182,6 +186,12 @@ fun TvSettingsScreen( var detailFocusRequest by remember { mutableStateOf(0) } var showSignOutConfirm by remember { mutableStateOf(false) } + LaunchedEffect(state.playbackOverridesReset) { + val landed = state.playbackOverridesReset ?: return@LaunchedEffect + Toast.makeText(context, UseProfileSettingsCopy.notice(landed), Toast.LENGTH_SHORT).show() + viewModel.onPlaybackOverridesResetShown() + } + LaunchedEffect(Unit) { // Was four attempts judged on requestFocus() returning true — that is // acceptance, not arrival. onInitialContentFocus() hands content focus @@ -293,6 +303,7 @@ fun TvSettingsScreen( onPassOutThresholdChanged = viewModel::onPassOutThresholdChanged, onNextUpPromptSecondsChanged = viewModel::onNextUpPromptSecondsChanged, onResetPlaybackOverrides = viewModel::resetPlaybackOverrides, + onUseProfileSetting = viewModel::onUseProfileSetting, seekIntervals = viewModel.seekIntervals, onSubtitleModeChanged = viewModel::onSubtitleModeChanged, onSubtitleLanguageChanged = viewModel::onSubtitleLanguageChanged, @@ -443,6 +454,7 @@ private fun SettingsSplitLayout( onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, onResetPlaybackOverrides: () -> Unit, + onUseProfileSetting: (String) -> Unit, seekIntervals: SeekIntervalSettingsModel? = null, onSubtitleModeChanged: (SubtitleMode) -> Unit, onSubtitleLanguageChanged: (String) -> Unit, @@ -524,6 +536,7 @@ private fun SettingsSplitLayout( onPassOutThresholdChanged = onPassOutThresholdChanged, onNextUpPromptSecondsChanged = onNextUpPromptSecondsChanged, onResetPlaybackOverrides = onResetPlaybackOverrides, + onUseProfileSetting = onUseProfileSetting, seekIntervals = seekIntervals, onSubtitleModeChanged = onSubtitleModeChanged, onSubtitleLanguageChanged = onSubtitleLanguageChanged, @@ -807,6 +820,7 @@ private fun SettingsDetailPane( onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, onResetPlaybackOverrides: () -> Unit, + onUseProfileSetting: (String) -> Unit, seekIntervals: SeekIntervalSettingsModel? = null, onSubtitleModeChanged: (SubtitleMode) -> Unit, onSubtitleLanguageChanged: (String) -> Unit, @@ -863,6 +877,7 @@ private fun SettingsDetailPane( onPassOutThresholdChanged = onPassOutThresholdChanged, onNextUpPromptSecondsChanged = onNextUpPromptSecondsChanged, onResetPlaybackOverrides = onResetPlaybackOverrides, + onUseProfileSetting = onUseProfileSetting, seekIntervals = seekIntervals, ) TvSettingsCategory.Subtitles -> TvSubtitleSettingsPane( @@ -1180,16 +1195,33 @@ private fun TvPlaybackSettingsPane( onPassOutThresholdChanged: (Int) -> Unit, onNextUpPromptSecondsChanged: (Int) -> Unit, onResetPlaybackOverrides: () -> Unit, + /** Receives the key of the control going back to the profile's value. */ + onUseProfileSetting: (String) -> Unit, seekIntervals: SeekIntervalSettingsModel? = null, ) { var activePicker by remember { mutableStateOf<PlaybackPicker?>(null) } + var confirmingReset by remember { mutableStateOf(false) } + fun overridden(key: String) = PlaybackSettingsKeys.hasDeviceOverride(state.deviceOverrides, key) + // "Use profile setting" leads every profile-layered picker and is the + // checked choice while the device has no value of its own. + fun profileLayered(key: String, options: List<PickerOption>, selectedId: String) = + Pair( + listOf(PickerOption(UseProfileSettingId, UseProfileSettingsCopy.OPTION)) + options, + if (overridden(key)) selectedId else UseProfileSettingId, + ) + // A switch's "Use Profile Setting" row leaves once used, so focus is + // handed back to the switch above it rather than lost. + val autoPlayNextFocus = remember { FocusRequester() } + val skipCreditsFocus = remember { FocusRequester() } val seekState = seekIntervals?.state?.collectAsState()?.value var seekPicker by remember { mutableStateOf<Pair<SeekMedia, SeekDirection>?>(null) } // Where focus goes when a skip-interval group drops the focused row and // has no row of its own left to take it (the row just above the groups). val seekFallbackFocus = remember { FocusRequester() } + // No "No preference" entry: on a device that means going back to the + // profile's language, which "Use profile setting" already says. val audioLanguages = remember(state.audioLanguage, state.audioLanguageSuggestions) { - LanguageOptions.options( + LanguageOptions.namedOptions( key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, currentValue = state.audioLanguage, runtimeValues = state.audioLanguageSuggestions, @@ -1273,7 +1305,17 @@ private fun TvPlaybackSettingsPane( label = "Auto-Play Next Episode", checked = state.autoPlayNext, onCheckedChange = onAutoPlayNextChanged, + focusRequester = autoPlayNextFocus, ) + if (overridden(PlaybackSettingsKeys.AutoPlayNext)) { + UseProfileSettingRow( + setting = "Auto-Play Next Episode", + onClick = { + autoPlayNextFocus.requestFocus() + onUseProfileSetting(PlaybackSettingsKeys.AutoPlayNext) + }, + ) + } SettingsValueRow( label = "Show Next Up", value = nextUpPromptLabel(state.nextUpPromptSeconds), @@ -1291,7 +1333,17 @@ private fun TvPlaybackSettingsPane( label = "Skip Credits", checked = state.autoSkipCredits, onCheckedChange = onAutoSkipCreditsChanged, + focusRequester = skipCreditsFocus, ) + if (overridden(PlaybackSettingsKeys.AutoSkipCredits)) { + UseProfileSettingRow( + setting = "Skip Credits", + onClick = { + skipCreditsFocus.requestFocus() + onUseProfileSetting(PlaybackSettingsKeys.AutoSkipCredits) + }, + ) + } SettingsValueRow( label = "Rewind on Resume", value = resumeRewindLabel(state.resumeRewindSeconds), @@ -1335,13 +1387,11 @@ private fun TvPlaybackSettingsPane( item { SettingsGroup(title = "Reset") { SettingsActionRow( - label = "Reset Playback Overrides", - onClick = onResetPlaybackOverrides, + label = UseProfileSettingsCopy.RESET_ALL_ROW, + onClick = { confirmingReset = true }, destructive = true, ) - SettingsFooterText( - text = "Resets playback choices for this Android TV and profile back to the server fallback.", - ) + SettingsFooterText(text = UseProfileSettingsCopy.FOOTER) } } } @@ -1350,46 +1400,92 @@ private fun TvPlaybackSettingsPane( // 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 = QualityPresets.ALL.map { PickerOption(it.id, it.label) }, - selectedId = QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.id - ?: "", - onSelect = { id -> - onQualityPresetSelected(id) - activePicker = null - }, - onDismiss = { activePicker = null }, - ) - PlaybackPicker.AudioLanguage -> TvSettingsPickerSheet( - title = "Audio Language", - options = audioLanguages.map { PickerOption(it.first, it.second) }, - selectedId = state.audioLanguage, - onSelect = { onAudioLanguageChanged(it); activePicker = null }, - onDismiss = { activePicker = null }, - ) - PlaybackPicker.NextUpPrompt -> TvSettingsPickerSheet( - title = "Show Next Up", - options = NextUpPromptOptions.map { PickerOption(it.toString(), nextUpPromptLabel(it)) }, - selectedId = state.nextUpPromptSeconds.toString(), - onSelect = { id -> - id.toIntOrNull()?.let(onNextUpPromptSecondsChanged) - activePicker = null - }, - onDismiss = { activePicker = null }, - ) - PlaybackPicker.IntroSkipMode -> TvSettingsPickerSheet( - title = "Skip Intros", - options = IntroSkipMode.entries.map { mode -> - PickerOption(mode.wireValue, stringResource(introSkipModeLabel(mode))) - }, - selectedId = state.introSkipMode.wireValue, - onSelect = { id -> - IntroSkipMode.entries.firstOrNull { it.wireValue == id }?.let(onIntroSkipModeChanged) - activePicker = null - }, - onDismiss = { activePicker = null }, - ) + PlaybackPicker.Quality -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.PreferredQuality, + QualityPresets.ALL.map { PickerOption(it.id, it.label) }, + QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.id ?: "", + ) + TvSettingsPickerSheet( + title = "Quality", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.PreferredQuality) + } else { + onQualityPresetSelected(id) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } + PlaybackPicker.AudioLanguage -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.AudioLanguage, + audioLanguages.map { PickerOption(it.first, it.second) }, + state.audioLanguage, + ) + TvSettingsPickerSheet( + title = "Audio Language", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.AudioLanguage) + } else { + onAudioLanguageChanged(id) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } + PlaybackPicker.NextUpPrompt -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.NextUpPromptSeconds, + NextUpPromptOptions.map { PickerOption(it.toString(), nextUpPromptLabel(it)) }, + state.nextUpPromptSeconds.toString(), + ) + TvSettingsPickerSheet( + title = "Show Next Up", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.NextUpPromptSeconds) + } else { + id.toIntOrNull()?.let(onNextUpPromptSecondsChanged) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } + PlaybackPicker.IntroSkipMode -> { + val (options, selectedId) = profileLayered( + PlaybackSettingsKeys.IntroSkipMode, + IntroSkipMode.entries.map { mode -> + PickerOption(mode.wireValue, stringResource(introSkipModeLabel(mode))) + }, + state.introSkipMode.wireValue, + ) + TvSettingsPickerSheet( + title = "Skip Intros", + options = options, + selectedId = selectedId, + onSelect = { id -> + if (id == UseProfileSettingId) { + onUseProfileSetting(PlaybackSettingsKeys.IntroSkipMode) + } else { + IntroSkipMode.entries.firstOrNull { it.wireValue == id }?.let(onIntroSkipModeChanged) + } + activePicker = null + }, + onDismiss = { activePicker = null }, + ) + } PlaybackPicker.ResumeRewind -> TvSettingsPickerSheet( title = "Rewind on Resume", options = ResumeRewindOptions.map { PickerOption(it.toString(), resumeRewindLabel(it)) }, @@ -1428,6 +1524,37 @@ private fun TvPlaybackSettingsPane( onDismiss = { seekPicker = null }, ) } + + if (confirmingReset) { + TvSettingsConfirmDialog( + title = UseProfileSettingsCopy.CONFIRM_TITLE, + message = UseProfileSettingsCopy.CONFIRM_MESSAGE, + confirmLabel = UseProfileSettingsCopy.CONFIRM_BUTTON, + onConfirm = { + confirmingReset = false + onResetPlaybackOverrides() + }, + onDismiss = { confirmingReset = false }, + ) + } +} + +/** Picker id for the synthetic "Use profile setting" row (never on the wire). */ +private const val UseProfileSettingId = "use_profile_setting" + +/** + * The action under a profile-layered switch while this device holds its own + * value. A switch has no picker to lead with "Use profile setting", so it gets + * a row of its own, as Cards & Posters' "Use Profile Default" does. + */ +@Composable +private fun UseProfileSettingRow(setting: String, onClick: () -> Unit) { + val description = UseProfileSettingsCopy.rowDescription(setting) + SettingsActionRow( + label = UseProfileSettingsCopy.ROW, + onClick = onClick, + modifier = Modifier.semantics { contentDescription = description }, + ) } private fun seekIntervalGroupTitle(media: SeekMedia): String = when (media) { @@ -2119,7 +2246,11 @@ fun TvSettingsPickerSheet( val initialFocus = remember { FocusRequester() } val selectedIndex = options.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) - var pickerHasFocus by remember { mutableStateOf(false) } + // The target row's own focus, not the sheet's: the dialog hands focus to + // its first row as it opens, so "something in the sheet is focused" was + // already true before the request landed, and focus stayed on the first + // row instead of the current choice. + var targetHasFocus by remember { mutableStateOf(false) } val focusTargetIndex = if (options.isEmpty()) -1 else selectedIndex val listState: LazyListState = rememberLazyListState() @@ -2130,7 +2261,7 @@ fun TvSettingsPickerSheet( maxAttempts = TvContentInitialFocusMaxAttempts, awaitAttempt = { withFrameNanos { } }, requestFocus = initialFocus::requestFocus, - isFocused = { pickerHasFocus }, + isFocused = { targetHasFocus }, ) } } @@ -2145,7 +2276,6 @@ fun TvSettingsPickerSheet( Box( modifier = Modifier .fillMaxSize() - .onFocusChanged { pickerHasFocus = it.hasFocus } .background(Color.Black.copy(alpha = 0.88f)), contentAlignment = Alignment.Center, ) { @@ -2213,7 +2343,9 @@ fun TvSettingsPickerSheet( selected = option.id == selectedId, onClick = { onSelect(option.id) }, modifier = if (isFocusTarget) { - Modifier.focusRequester(initialFocus) + Modifier + .focusRequester(initialFocus) + .onFocusChanged { targetHasFocus = it.isFocused } } else { Modifier }, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 044d596b7b..1b97fa1510 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -142,6 +142,17 @@ class TvSettingsViewModel( // Seconds before the end of an episode to surface the Up-Next prompt // (0 = at the very end). Mirrors tvOS `nextUpPromptSeconds`. val nextUpPromptSeconds: Int = 10, + /** + * Keys this device holds its own value for. A profile-layered control + * whose key is absent shows "Use profile setting" as its choice. + */ + val deviceOverrides: Set<String> = emptySet(), + /** + * Outcome of the last "Use Profile Settings", until the screen has told + * the user: true when the server confirmed every clear, false when it + * couldn't (offline, refused, or interrupted). + */ + val playbackOverridesReset: Boolean? = null, // Cards & Posters (`ui.card_presentation`), mirrored from // CardPresentationStore. Source drives the "Only This Device" toggle // and the "Use Profile Default" action; support gates the whole group. @@ -387,6 +398,11 @@ class TvSettingsViewModel( _uiState.update { it.copy(nextUpPromptSeconds = seconds) } } } + viewModelScope.launch { + playerSettingsStore.deviceOverrideKeysFlow.collect { keys -> + _uiState.update { it.copy(deviceOverrides = keys) } + } + } viewModelScope.launch { playerSettingsStore.matchContentFrameRateFlow.collect { value -> _uiState.update { it.copy(matchContentFrameRate = value) } @@ -733,11 +749,24 @@ class TvSettingsViewModel( } /** - * Clear every server-side device override for this device. Mirrors - * iOS tvOS "Reset Playback Overrides" (TVSettingsView.swift:137). + * "Use Profile Settings": clears every setting this device holds its own + * value for. Mirrors iOS tvOS "Reset Playback Overrides" + * (TVSettingsView.swift:137). */ fun resetPlaybackOverrides() { - viewModelScope.launch { playerSettingsStore.resetAllDeviceSettings() } + viewModelScope.launch { + val landed = playerSettingsStore.resetAllDeviceSettings() + _uiState.update { it.copy(playbackOverridesReset = landed) } + } + } + + fun onPlaybackOverridesResetShown() { + _uiState.update { it.copy(playbackOverridesReset = null) } + } + + /** Goes back to the profile's value for one setting's control on this device. */ + fun onUseProfileSetting(key: String) { + viewModelScope.launch { playerSettingsStore.resetDeviceSetting(key) } } /** Lifecycle hook — call from MainTvActivity.onStop. */ diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/testing/FakePlayerSettingsStore.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/testing/FakePlayerSettingsStore.kt index 033ba2c121..7d25feebf5 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/testing/FakePlayerSettingsStore.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/testing/FakePlayerSettingsStore.kt @@ -142,7 +142,7 @@ internal class FakePlayerSettingsStore : PlayerSettingsStore { override suspend fun refreshFromServer() {} override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) {} override suspend fun resetDeviceSetting(key: String) {} - override suspend fun resetAllDeviceSettings() {} + override suspend fun resetAllDeviceSettings(): Boolean = true var importSucceeds = true override suspend fun importLegacyDeviceSettings( authority: org.siloserver.silo.network.AuthScopeSnapshot, diff --git a/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeysContractTest.kt b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeysContractTest.kt new file mode 100644 index 0000000000..14639ff8a9 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeysContractTest.kt @@ -0,0 +1,48 @@ +package org.siloserver.silo.model.settings + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * "Use profile setting" is only honest for a device setting the profile can + * also hold; for the rest, clearing the device value falls to the default. + * Pins [PlaybackSettingsKeys.ProfileLayeredDeviceSettings] to the vendored + * manifest. + */ +class PlaybackSettingsKeysContractTest { + + private val definitions by lazy { + val raw = checkNotNull(javaClass.classLoader?.getResource("settings/v1/manifest.json")) { + "Missing test resource settings/v1/manifest.json" + }.readText() + Json.parseToJsonElement(raw).jsonObject.getValue("definitions").jsonArray + .map { it.jsonObject } + .associateBy { it.getValue("key").jsonPrimitive.content } + } + + private fun scopes(key: String): List<String> = + definitions.getValue(key).getValue("allowed_scopes").jsonArray.map { it.jsonPrimitive.content } + + @Test + fun profileLayeredKeysAreExactlyTheDeviceKeysWithAProfileScope() { + val expected = PlaybackSettingsKeys.DeviceSettings + .filter { it in definitions } + .filter { "profile_device" in scopes(it) && "profile" in scopes(it) } + .toSet() + assertEquals(expected, PlaybackSettingsKeys.ProfileLayeredDeviceSettings) + } + + @Test + fun overrideGroupsStayWithinTheProfileLayer() { + for (key in PlaybackSettingsKeys.ProfileLayeredDeviceSettings) { + val group = PlaybackSettingsKeys.deviceOverrideGroup(key) + assertTrue(key in group, key) + assertTrue(group.all { it in PlaybackSettingsKeys.ProfileLayeredDeviceSettings }, key) + } + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt index 658d1ee0b3..c6f82d971f 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/PlaybackSettingsKeys.kt @@ -198,6 +198,42 @@ object PlaybackSettingsKeys { SubtitlePosition, ) + /** + * The device settings that also have a profile layer (`profile` is among + * their allowed scopes), so clearing this device's value falls back to the + * profile's rather than straight to the contract default. These are the + * ones that offer "Use profile setting". Pinned to the manifest by + * `PlaybackSettingsKeysContractTest`. + */ + val ProfileLayeredDeviceSettings: Set<String> = setOf( + PreferredQuality, + MaxBitrateKbps, + AudioLanguage, + AutoSkipIntro, + IntroSkipMode, + AutoSkipCredits, + AutoPlayNext, + NextUpPromptSeconds, + SubtitleAppearance, + ) + + /** + * The keys one control writes together, so going back to the profile's + * value has to clear them together. Quality is one picker over two axes, + * and the intro-skip enum has the deprecated boolean the server mirrors + * beside it; clearing only one of a pair would leave half a device choice + * behind. + */ + fun deviceOverrideGroup(key: String): List<String> = when (key) { + PreferredQuality, MaxBitrateKbps -> listOf(PreferredQuality, MaxBitrateKbps) + IntroSkipMode, AutoSkipIntro -> listOf(IntroSkipMode, AutoSkipIntro) + else -> listOf(key) + } + + /** Whether this device holds its own value for [key]'s control. */ + fun hasDeviceOverride(overrides: Set<String>, key: String): Boolean = + deviceOverrideGroup(key).any { it in overrides } + /** * `old local slot -> current key`, for the two keys the settings cutover * renamed. From af99d879f5c09f2f0ff7bce5a04e73879170e9db Mon Sep 17 00:00:00 2001 From: d3v1l1989 <64855033+d3v1l1989@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:36:45 +0200 Subject: [PATCH 72/87] fix(collections): open library-tab collection items without a library scope (#478) * fix(collections): open library-tab collection items without a library scope Items in a collection opened from a library's Collections tab were read with that library's id. The server answers 404 for an item scoped to a library that does not hold it, so any item filed in another library showed as unavailable. User collections often mix libraries, and admin collections can be shown in libraries other than their primary one. Phone and TV now open these items without a library scope, like the personal Collections screens, the web client, and the Apple clients. The route's library id still loads the collection itself. * fix(collections): shuffle library-tab collections without a library scope A collection shuffle draws from the whole collection, but both apps started it with the tab's library, so the player asked for each pick with library_id set and the server returned 404 for any pick filed in another library. Start collection shuffles unscoped, like item clicks and the Apple apps, and drop the phone view model's unused browseLibraryId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../silo/android/ui/navigation/AppNavigation.kt | 15 +++++---------- .../collections/CollectionDetailViewModel.kt | 2 -- .../ui/navigation/CollectionRouteTest.kt | 17 +++++++++++++++++ .../silo/tv/ui/navigation/TvAppNavigation.kt | 7 +++++-- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index ba6a1051a2..fa9862a9ba 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -1175,21 +1175,16 @@ fun AppNavigation( CollectionDetailScreen( collectionId = backStackEntry.arguments?.getString("collectionId") ?: "", onBackClick = { navController.popBackStack() }, + // Collection items can live in other libraries, and a + // library-scoped item read 404s for those. Open them, and + // shuffle picks, unscoped, like the web client. onShuffleStarted = { shuffle -> navController.navigate( - org.siloserver.silo.android.ui.screens.shuffle.shufflePlayerRoute( - shuffle, - backStackEntry.arguments?.getString("libraryId")?.toIntOrNull(), - ), + org.siloserver.silo.android.ui.screens.shuffle.shufflePlayerRoute(shuffle), ) }, onItemClick = { contentId -> - navController.navigate( - Route.ItemDetail( - contentId, - libraryId = backStackEntry.arguments?.getString("libraryId")?.toIntOrNull(), - ).route, - ) + navController.navigate(Route.ItemDetail(contentId).route) }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt index 472835f702..4f79071bf2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionDetailViewModel.kt @@ -69,8 +69,6 @@ class CollectionDetailViewModel( org.siloserver.silo.model.shuffle.ShuffleScopeKind.USER_COLLECTION } - /** The library the collection was opened from, carried into shuffled playback. */ - val browseLibraryId: Int? get() = libraryId private val pageSize = 40 fun initialize(id: String) { diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt index 1c3516e76c..d3930e6820 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/CollectionRouteTest.kt @@ -69,4 +69,21 @@ class CollectionRouteTest { assertEquals(1, Regex("navArgument\\(\"source\"\\)").findAll(collectionDestination).count()) } + + @Test + fun collectionItemsAndShufflesOpenWithoutALibraryScope() { + val collectionDestination = appNavigationSource.substringAfter( + "route = Route.CollectionDetail.ROUTE", + ).substringBefore( + "// ---- Detail screens ----", + ) + + // A library-scoped read 404s for an item filed in another library. + assertEquals( + 0, + Regex("Route\\.ItemDetail\\([^)]*libraryId").findAll(collectionDestination).count(), + ) + // A collection shuffle draws from the whole collection. + assertEquals(1, Regex("shufflePlayerRoute\\(shuffle\\)").findAll(collectionDestination).count()) + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 3959d19758..5ad7756b61 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -1674,12 +1674,15 @@ fun TvAppNavigation( collectionId = collectionId, title = title, libraryType = libraryType, + // Collection items can live in other libraries, and a + // library-scoped item read 404s for those. Open them, and + // shuffle picks, unscoped, like the web client. onItemClick = { contentId -> - navController.navigateToTvItemDetail(contentId, libraryId = libraryId) + navController.navigateToTvItemDetail(contentId) }, onBack = { navController.popBackStack() }, onShuffleStarted = { shuffle -> - navController.navigateToTvShufflePlayback(shuffle, libraryId, lastPlaybackNavigation) + navController.navigateToTvShufflePlayback(shuffle, null, lastPlaybackNavigation) }, ) } From 7f5e0588fcfa8b7341d7a2556619ca1b2e8520b0 Mon Sep 17 00:00:00 2001 From: Suspense <rhainland@gmail.com> Date: Sat, 10 Oct 2026 05:52:28 +0200 Subject: [PATCH 73/87] fix(player): consume and reconcile file-specific markers (#527) * fix(player): consume and reconcile recap and credits markers * fix(player): keep markers bound to the file being played The marker reconcile read watch detail without naming the playing file, so the server filled on-demand markers for the default version and the reconcile cleared the playing version's ranges. It now sends file_id. A markers_updated event is applied only when its file_id matches the playing file; the server also delivers a file's update to sessions that requested that file but play another. Recovery and subtitle replans that land on another file in the same session now take that file's markers and reconcile them, since no new realtime connection opens to do it. Download playback reads the downloaded version's markers instead of the root ranges. Also moves the applyUpdatedMarkers KDoc back onto its function and uses imports in place of fully qualified names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(player): name the file and room library in marker reads Download playback now sends the downloaded file's id with its watch detail read, so the server fills that version's on-demand markers; local playback has no control session to reconcile them later. The TV marker reconcile reads watch detail in the same library playback started from. A Watch Party can move to a title in another library while the player stays open, and the launch library then pointed elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): hide the marker skip pill when playback fails The TV overlays still draw over the error screen, so a failure inside a recap or credits range left the Skip pill visible and focusable beside Retry. The marker target is now off while playback is loading or failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../player/video/MarkerReconciliationFence.kt | 9 +++ .../common/player/video/MarkerSkipTarget.kt | 18 +++++ .../video/MarkerReconciliationFenceTest.kt | 22 ++++++ .../player/video/MarkerSkipTargetTest.kt | 26 +++++++ .../ui/screens/player/IntroAutoSkipBanner.kt | 6 ++ .../ui/screens/player/LocalWatchMetadata.kt | 4 +- .../player/MobileVideoPlaybackStarter.kt | 8 +- .../player/PlaybackRealtimeController.kt | 10 +-- .../ui/screens/player/PlayerOverlay.kt | 16 ++++ .../ui/screens/player/PlayerViewModel.kt | 73 ++++++++++++++++--- .../src/androidMain/res/values/strings.xml | 2 + .../screens/player/TvIntroAutoSkipBanner.kt | 5 ++ .../player/TvPlaybackRealtimeController.kt | 10 +-- .../tv/ui/screens/player/TvPlayerScreen.kt | 68 +++++++++++++---- .../tv/ui/screens/player/TvPlayerViewModel.kt | 70 +++++++++++++++++- .../screens/player/TvVideoPlaybackStarter.kt | 8 +- .../ui/screens/player/TvWatchPartyPlayer.kt | 16 ++++ .../src/androidMain/res/values/strings.xml | 2 + .../screens/player/TvWatchPartyPlayerTest.kt | 19 +++++ .../silo/model/catalog/CatalogModels.kt | 4 + .../siloserver/silo/network/api/CatalogApi.kt | 2 +- .../silo/network/apiv2/WatchDetailV2Api.kt | 24 +++--- .../silo/playback/PlaybackMarkersUpdate.kt | 7 +- .../silo/repository/CatalogRepository.kt | 2 +- .../network/apiv2/WatchDetailMarkerV2Test.kt | 37 ++++++++++ .../playback/PlaybackMarkersUpdateTest.kt | 6 ++ 26 files changed, 416 insertions(+), 58 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFence.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTarget.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFenceTest.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTargetTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/WatchDetailMarkerV2Test.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFence.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFence.kt new file mode 100644 index 0000000000..f713d77013 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFence.kt @@ -0,0 +1,9 @@ +package org.siloserver.silo.common.player.video + +/** A late read must not replace a newer marker event or reconnect snapshot. */ +class MarkerReconciliationFence { + private var generation = 0L + fun begin(): Long = ++generation + fun invalidate() { generation++ } + fun isCurrent(ticket: Long): Boolean = ticket == generation +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTarget.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTarget.kt new file mode 100644 index 0000000000..31772b9058 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTarget.kt @@ -0,0 +1,18 @@ +package org.siloserver.silo.common.player.video + +import org.siloserver.silo.model.catalog.TimeRange + +enum class ManualMarkerKind { Recap, Credits } +data class MarkerSkipTarget(val kind: ManualMarkerKind, val endSeconds: Double) + +/** The active range is end-exclusive. Recap wins if provider ranges overlap. */ +fun markerSkipTarget(position: Double, recap: TimeRange?, credits: TimeRange?): MarkerSkipTarget? { + if (!position.isFinite()) return null + fun TimeRange.active() = start.isFinite() && end.isFinite() && start >= 0 && + end > start && position >= start && position < end + return when { + recap?.active() == true -> MarkerSkipTarget(ManualMarkerKind.Recap, recap.end) + credits?.active() == true -> MarkerSkipTarget(ManualMarkerKind.Credits, credits.end) + else -> null + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFenceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFenceTest.kt new file mode 100644 index 0000000000..da57d0b627 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerReconciliationFenceTest.kt @@ -0,0 +1,22 @@ +package org.siloserver.silo.common.player.video + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkerReconciliationFenceTest { + @Test fun newerEventRejectsPendingRead() { + val fence = MarkerReconciliationFence() + val read = fence.begin() + fence.invalidate() + assertFalse(fence.isCurrent(read)) + } + + @Test fun reconnectRejectsPreviousConnectionRead() { + val fence = MarkerReconciliationFence() + val first = fence.begin() + val reconnect = fence.begin() + assertFalse(fence.isCurrent(first)) + assertTrue(fence.isCurrent(reconnect)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTargetTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTargetTest.kt new file mode 100644 index 0000000000..4de6ae68e3 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MarkerSkipTargetTest.kt @@ -0,0 +1,26 @@ +package org.siloserver.silo.common.player.video + +import org.siloserver.silo.model.catalog.TimeRange +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MarkerSkipTargetTest { + @Test fun currentRangeControlsTheSeekAndDeletion() { + val recap = TimeRange(40.0, 60.0) + val credits = TimeRange(150.0, 180.0) + assertNull(markerSkipTarget(39.9, recap, credits)) + assertEquals(MarkerSkipTarget(ManualMarkerKind.Recap, 60.0), markerSkipTarget(40.0, recap, credits)) + assertNull(markerSkipTarget(60.0, recap, credits)) + assertEquals(MarkerSkipTarget(ManualMarkerKind.Credits, 180.0), markerSkipTarget(160.0, recap, credits)) + assertNull(markerSkipTarget(160.0, recap, null)) + assertEquals(185.0, markerSkipTarget(160.0, recap, TimeRange(150.0,185.0))?.endSeconds) + assertEquals(ManualMarkerKind.Recap, markerSkipTarget(45.0, recap, TimeRange(30.0,70.0))?.kind) + } + @Test fun invalidRangesCannotOfferASeek() { + for (range in listOf(TimeRange(-1.0, 10.0), TimeRange(10.0, 10.0), TimeRange(20.0, 10.0), TimeRange(0.0, Double.POSITIVE_INFINITY))) { + assertNull(markerSkipTarget(5.0, range, range)) + } + assertNull(markerSkipTarget(Double.NaN, TimeRange(0.0,10.0), null)) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/IntroAutoSkipBanner.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/IntroAutoSkipBanner.kt index c96f0ac48e..9e6bd8660e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/IntroAutoSkipBanner.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/IntroAutoSkipBanner.kt @@ -190,3 +190,9 @@ private fun IntroPromptPill( } } } + +/** Manual recap/credits action uses the intro pill's existing visual treatment. */ +@Composable +fun MarkerSkipPill(label: String, onClick: () -> Unit) { + IntroPromptPill(label = label, icon = Icons.Filled.SkipNext, progress = 0f, onClick = onClick) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/LocalWatchMetadata.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/LocalWatchMetadata.kt index f612f63c23..e5d58a0b4d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/LocalWatchMetadata.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/LocalWatchMetadata.kt @@ -15,6 +15,8 @@ internal suspend fun loadLocalWatchMetadata( profileId: String, contentId: String, libraryId: Int? = null, + /** The downloaded file, so the server fills that version's on-demand markers. */ + fileId: Int? = null, stillCurrent: () -> Boolean, ): WatchDetail? { if (owner == null || owner.serverId != serverId || owner.profileId != profileId) return null @@ -24,7 +26,7 @@ internal suspend fun loadLocalWatchMetadata( return valid && stillCurrent() } if (!current()) return null - val result = repository.getWatchDetail(contentId, owner, libraryId) + val result = repository.getWatchDetail(contentId, owner, libraryId, fileId) if (!current()) return null return (result as? ApiResult.Success)?.data } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt index f33a892d7f..ed910411d2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileVideoPlaybackStarter.kt @@ -501,10 +501,10 @@ internal class MobileVideoPlaybackStarter( showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles ?: activeProfile?.showForcedSubtitles ?: true, - intro = watchDetail.intro, - credits = watchDetail.credits, - recap = watchDetail.recap, - preview = watchDetail.preview, + intro = effectiveVersion?.intro, + credits = effectiveVersion?.credits, + recap = effectiveVersion?.recap, + preview = effectiveVersion?.preview, chapters = effectiveVersion?.chapters.orEmpty(), seriesId = watchDetail.seriesId, seriesTitle = watchDetail.seriesTitle, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt index 4797886470..27c039d461 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlaybackRealtimeController.kt @@ -50,7 +50,10 @@ class PlaybackRealtimeController( try { client.connect(sessionId).collect { event -> when (event) { - is PlaybackRealtimeEvent.Opened -> client.sendHello(sessionId) + is PlaybackRealtimeEvent.Opened -> { + client.sendHello(sessionId) + viewModel.reconcileMarkers(sessionId) + } is PlaybackRealtimeEvent.Command -> handleCommand(event) is PlaybackRealtimeEvent.ServerEvent -> handleServerEvent(event) is PlaybackRealtimeEvent.Closed -> { /* fall through to reconnect */ } @@ -109,10 +112,7 @@ class PlaybackRealtimeController( viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) "subtitle_sync_updated" -> viewModel.applySubtitleSyncUpdated(decodePlaybackSubtitleSyncUpdated(event)) - "markers_updated" -> { - val markers = decodeMarkersUpdate(event) - viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) - } + "markers_updated" -> viewModel.applyMarkersUpdate(decodeMarkersUpdate(event)) // chapter_thumbnail_ready: no scrubber-thumbnail UI yet → nothing to update. else -> { /* ignore */ } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt index 624c30b03e..bb256d0ce9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerOverlay.kt @@ -49,6 +49,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import org.siloserver.silo.common.ui.LanguageNames +import org.siloserver.silo.common.player.video.markerSkipTarget +import org.siloserver.silo.common.player.video.ManualMarkerKind +import androidx.compose.ui.res.stringResource +import org.siloserver.silo.android.R import org.siloserver.silo.common.player.SessionState import org.siloserver.silo.common.player.SleepTimerState import org.siloserver.silo.model.playback.PlaybackQualityOption @@ -486,6 +490,18 @@ fun PlayerOverlay( } } + if (!state.showUpNext && !introSkipState.isVisible && seekEnabled && !state.isLoading) { + val target = markerSkipTarget(state.position, state.recap, state.credits) + if (target != null) { + Box(modifier = bottomEndSlotModifier, contentAlignment = Alignment.BottomEnd) { + MarkerSkipPill( + label = stringResource(if (target.kind == ManualMarkerKind.Recap) R.string.marker_skip_recap else R.string.marker_skip_credits), + onClick = { gatedSeek(target.endSeconds) }, + ) + } + } + } + var retainedUpNextInfo by remember { mutableStateOf<PlayerViewModel.NextEpisodeInfo?>(null) } LaunchedEffect(state.showUpNext, state.nextEpisode) { state.nextEpisode?.let { retainedUpNextInfo = it } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index ac1bdd3f4b..65192e6490 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -46,6 +46,7 @@ import org.siloserver.silo.common.player.seek.replanMountPositionForSource import org.siloserver.silo.common.player.seek.sourcePositionForPlayer import org.siloserver.silo.common.player.video.VideoPlaybackSessionCoordinator import org.siloserver.silo.common.player.video.VideoPlaybackStartRequest +import org.siloserver.silo.common.player.video.MarkerReconciliationFence import org.siloserver.silo.common.player.video.NextUpTransitionGate import org.siloserver.silo.common.player.video.VideoPlayerRouteArgs import org.siloserver.silo.common.player.video.VideoPlayerUiState @@ -76,6 +77,7 @@ import org.siloserver.silo.model.playback.mergeDownloadedSubtitles import org.siloserver.silo.model.playback.rebaseDownloadedSubtitleUrl import org.siloserver.silo.model.playback.resolvedSelectedSubtitleIndex import org.siloserver.silo.model.playback.resolvePlaybackStartPosition +import org.siloserver.silo.playback.PlaybackMarkersUpdate import org.siloserver.silo.playback.PlaybackSubtitleReady import org.siloserver.silo.playback.PlaybackSubtitleTimingChanged import org.siloserver.silo.playback.PlaybackSubtitleSyncUpdated @@ -2219,6 +2221,7 @@ class PlayerViewModel( contentId = _uiState.value.contentId, mountToken = mountGeneration, ) + val previousFileId = _uiState.value.mediaFileId _uiState.update { current -> current.copy( error = null, @@ -2250,6 +2253,7 @@ class PlayerViewModel( position = remountPosition.sourcePositionSeconds, ) } + adoptFileMarkersAfterReplan(previousFileId, effectiveVersion) Log.i( TAG, "replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + @@ -3669,13 +3673,60 @@ class PlayerViewModel( if (index == -1 || index in _uiState.value.subtitleTracks.indices) onSelectSubtitle(index) } + private val markerReconciliationFence = MarkerReconciliationFence() + private var markerReconcileJob: Job? = null + + /** + * Read the playing file's markers again: after every control connection, + * including on-demand mode, and after a replan lands on another file. + * Naming the file makes the server fill that version's on-demand markers. + */ + fun reconcileMarkers(expectedSessionId: String) { + val state = _uiState.value + val fileId = state.mediaFileId ?: return + if (state.sessionId != expectedSessionId) return + val ticket = markerReconciliationFence.begin() + markerReconcileJob?.cancel() + markerReconcileJob = viewModelScope.launch { + val owner = catalogRepository.captureWatchAuthority() ?: return@launch + val detail = (catalogRepository.getWatchDetail(state.contentId, owner, browseLibraryId, fileId) as? ApiResult.Success) + ?.data ?: return@launch + if (!catalogRepository.isWatchAuthorityCurrent(owner)) return@launch + val current = _uiState.value + if (!markerReconciliationFence.isCurrent(ticket) || current.sessionId != expectedSessionId || + current.contentId != state.contentId || current.mediaFileId != fileId) return@launch + val version = detail.versions.firstOrNull { it.fileId == fileId } ?: return@launch + applyUpdatedMarkers(version.intro, version.credits, + version.recap, version.preview) + } + } + + /** + * A `markers_updated` event. The server also sends a file's update to + * sessions that requested that file but play another, so check the file. + */ + fun applyMarkersUpdate(update: PlaybackMarkersUpdate) { + if (update.fileId != null && update.fileId != _uiState.value.mediaFileId) return + applyUpdatedMarkers(update.intro, update.credits, update.recap, update.preview) + } + + /** A replan that lands on another file takes that file's markers, then reads them again. */ + private fun adoptFileMarkersAfterReplan(previousFileId: Int?, version: FileVersion?) { + val state = _uiState.value + if (state.mediaFileId == previousFileId) return + applyUpdatedMarkers(version?.intro, version?.credits, version?.recap, version?.preview) + state.sessionId?.let(::reconcileMarkers) + } + /** - * Adopt server-recomputed marker ranges (a `markers_updated` event). - * The intro auto-skip observer and the credits-based F2 trigger read these - * from UiState, so updating them takes effect immediately. Passing `null` - * clears a marker the server says no longer applies. + * Adopt the playing file's marker ranges (a `markers_updated` event or a + * reconcile read). The intro auto-skip observer and the credits-based F2 + * trigger read these from UiState, so updating them takes effect + * immediately. Passing `null` clears a marker the server says no longer + * applies. */ - fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + private fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + markerReconciliationFence.invalidate() _uiState.update { it.copy(intro = intro, credits = credits, recap = recap, preview = preview) } } @@ -3861,6 +3912,7 @@ class PlayerViewModel( subtitleRefreshNonce = 0, ) } + adoptFileMarkersAfterReplan(predecessorFileId, effectiveVersion) Log.i( TAG, "subtitle_replan_mount restored_source_seconds=$sourcePosition " + @@ -5483,6 +5535,7 @@ class PlayerViewModel( val watchDetail = loadLocalWatchMetadata( catalogRepository, watchOwner, media.serverId, media.profileId, contentId, libraryId = browseLibraryId, + fileId = fileId, ) { ownsLoad(loadOwner) } if (!ownsLoad(loadOwner)) return false val title = watchDetail?.title ?: sidecar.title @@ -5493,6 +5546,8 @@ class PlayerViewModel( ) val selectedIndex = versions.indexOfFirst { it.fileId == fileId } .coerceAtLeast(0) + // Markers belong to one file; never borrow another version's. + val localVersion = versions.firstOrNull { it.fileId == fileId } // Offline-safe resume: the server's watchDetail may be stale or absent in // airplane mode, so fold in the locally-recorded position and take the // furthest of the two (matches the server's GREATEST semantics). @@ -5582,10 +5637,10 @@ class PlayerViewModel( localSubtitleMountIdentity = null, subtitleApplying = false, offlineAudioByPosition = offlineAudioByPosition, - intro = watchDetail?.intro, - credits = watchDetail?.credits, - recap = watchDetail?.recap, - preview = watchDetail?.preview, + intro = localVersion?.intro, + credits = localVersion?.credits, + recap = localVersion?.recap, + preview = localVersion?.preview, chapters = versions[selectedIndex].chapters.orEmpty().ifEmpty { sidecar.chapters.orEmpty() }, seriesId = watchDetail?.seriesId, seasonNumber = watchDetail?.seasonNumber, diff --git a/androidApp/src/androidMain/res/values/strings.xml b/androidApp/src/androidMain/res/values/strings.xml index 494e340f5a..c089c86b80 100644 --- a/androidApp/src/androidMain/res/values/strings.xml +++ b/androidApp/src/androidMain/res/values/strings.xml @@ -137,4 +137,6 @@ <string name="companion_close">Close</string> <string name="companion_done">Done</string> <string name="companion_try_again">Try again</string> + <string name="marker_skip_recap">Skip Recap</string> + <string name="marker_skip_credits">Skip Credits</string> </resources> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt index ddc9e23eee..28d49bb555 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvIntroAutoSkipBanner.kt @@ -232,3 +232,8 @@ private fun TvIntroPromptPill( } } } + +@Composable +fun TvMarkerSkipPill(label: String, onSelect: () -> Unit) { + TvIntroPromptPill(label = label, progress = 0f, onSelect = onSelect, autoFocus = false) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt index adae1bd9a8..5261f2b44d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlaybackRealtimeController.kt @@ -51,7 +51,10 @@ class TvPlaybackRealtimeController( try { client.connect(sessionId).collect { event -> when (event) { - is PlaybackRealtimeEvent.Opened -> client.sendHello(sessionId) + is PlaybackRealtimeEvent.Opened -> { + client.sendHello(sessionId) + viewModel.reconcileMarkers(sessionId) + } is PlaybackRealtimeEvent.Command -> handleCommand(event) is PlaybackRealtimeEvent.ServerEvent -> handleServerEvent(event) is PlaybackRealtimeEvent.Closed -> { /* fall through to reconnect */ } @@ -115,10 +118,7 @@ class TvPlaybackRealtimeController( viewModel.applySubtitleTimingChanged(decodePlaybackSubtitleTimingChanged(event)) "subtitle_sync_updated" -> viewModel.applySubtitleSyncUpdated(decodePlaybackSubtitleSyncUpdated(event)) - "markers_updated" -> { - val markers = decodeMarkersUpdate(event) - viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) - } + "markers_updated" -> viewModel.applyMarkersUpdate(decodeMarkersUpdate(event)) // chapter_thumbnail_ready: no scrubber-thumbnail UI yet → nothing to update. else -> { /* ignore */ } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index 1d3b280855..c04a934eb7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog @@ -117,6 +118,9 @@ import org.siloserver.silo.common.player.PlayWhenReadyReconciliationGate import org.siloserver.silo.common.player.PlaybackCapabilityDetector import org.siloserver.silo.common.player.PlaybackPreflightListener import org.siloserver.silo.common.player.PlayerNotice +import org.siloserver.silo.common.player.video.markerSkipTarget +import org.siloserver.silo.common.player.video.MarkerSkipTarget +import org.siloserver.silo.common.player.video.ManualMarkerKind import org.siloserver.silo.common.player.SessionState import org.siloserver.silo.common.player.SiloPlaybackService import org.siloserver.silo.common.player.SleepTimerState @@ -353,6 +357,7 @@ fun TvPlayerScreen( } val sessionState by viewModel.sessionState.collectAsState() val introSkipState by viewModel.introSkipState.collectAsState() + val activeMarkerSkipTarget by viewModel.manualMarkerSkipTarget.collectAsState() val introSkipCountdownRun by viewModel.introSkipCountdownRun.collectAsState() val introSkipTimerRunning by viewModel.introSkipTimerRunning.collectAsState() val subtitleAppearance by viewModel.subtitleAppearance.collectAsState() @@ -586,6 +591,24 @@ fun TvPlayerScreen( roomTransportAuthorized(roomSnapshot, RoomTransportIntent.PlayPause) val visibleIntroSkipState = if (canSeekInRoom) introSkipState else IntroAutoSkipState.Hidden val latestIntroSkipState by rememberUpdatedState(visibleIntroSkipState) + // After repeated stalls in a room: a one-line offer to step down the same + // file's quality ladder, taken with OK while the transport is hidden. + val qualityOffer = remember { TvWatchPartyQualityOfferState() } + val lowerQuality = tvWatchPartyLowerQuality( + watchParty = watchParty, + offerState = qualityOffer, + videoQualities = state.videoQualities, + selectedFileResolution = state.selectedFileResolution, + ) + val latestLowerQuality by rememberUpdatedState(lowerQuality) + + val qualityOfferVisible = lowerQuality != null && + !state.showControls && !state.hudOpen && !state.showNextUp + val manualSkipTarget = tvManualMarkerSkipTarget( + activeMarkerSkipTarget, canSeekInRoom, visibleIntroSkipState.isVisible, + state.isLoading || state.error != null, qualityOfferVisible, + ) + val latestManualSkipTarget by rememberUpdatedState(manualSkipTarget) val latestRoomSnapshot by rememberUpdatedState(roomSnapshot) val latestShowPartyPanel by rememberUpdatedState(showPartyPanel) val latestShowQuickSubtitlePicker by rememberUpdatedState(showQuickSubtitlePicker) @@ -643,6 +666,18 @@ fun TvPlayerScreen( return true } + fun handleManualMarkerSelect() { + val current = viewModel.uiState.value + val target = markerSkipTarget(current.position, current.recap, current.credits) ?: return + if (watchParty != null) { + if (!watchParty.canSeek()) return + watchParty.seek(target.endSeconds) + } else { + viewModel.seekImmediate(target.endSeconds) + } + if (current.showControls) requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Scrubber) + } + fun armQuickSkipCapture() { quickSkipCaptureJob?.cancel() quickSkipCaptureGeneration = if (quickSkipCaptureGeneration == Long.MAX_VALUE) { @@ -888,17 +923,6 @@ fun TvPlayerScreen( } } - // After repeated stalls in a room: a one-line offer to step down the same - // file's quality ladder, taken with OK while the transport is hidden. - val qualityOffer = remember { TvWatchPartyQualityOfferState() } - val lowerQuality = tvWatchPartyLowerQuality( - watchParty = watchParty, - offerState = qualityOffer, - videoQualities = state.videoQualities, - selectedFileResolution = state.selectedFileResolution, - ) - val latestLowerQuality by rememberUpdatedState(lowerQuality) - // More-specific overlays register their own BackHandlers later in the // composition and therefore run first. This screen callback owns the // remaining player-state ladder on Android 16, where KEYCODE_BACK is no @@ -1135,6 +1159,14 @@ fun TvPlayerScreen( return@handler handleIntroPromptSelect() } + if (latestManualSkipTarget != null && !playerState.showControls && + !playerState.isScrubbing && cleanSeekRate == 0 && + event.keyCode in setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER) + ) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) handleManualMarkerSelect() + return@handler true + } + // The lower-quality offer owns OK and Back while the transport is // hidden, like the intro pill. val offeredQuality = latestLowerQuality @@ -2427,6 +2459,8 @@ fun TvPlayerScreen( nextUpCountdownTotalSeconds = state.nextUpCountdownTotalSeconds, autoPlayNextEnabled = autoPlayNextEnabled, introSkipState = visibleIntroSkipState, + manualSkipTarget = manualSkipTarget, + onManualMarkerSelect = { handleManualMarkerSelect() }, introSkipCountdownRun = introSkipCountdownRun, introSkipTimerRunning = introSkipTimerRunning, introSkipTotalSeconds = viewModel.introSkipTotalSeconds, @@ -2453,9 +2487,7 @@ fun TvPlayerScreen( onExitPlayback = { stopPlaybackAndExit() }, onNextUpVideoBoundsChanged = { nextUpVideoBounds = it }, onIntroPromptSelect = { handleIntroPromptSelect() }, - qualityOfferLabel = lowerQuality?.label?.takeIf { - !state.showControls && !state.hudOpen && !state.showNextUp - }, + qualityOfferLabel = lowerQuality?.label?.takeIf { qualityOfferVisible }, ) if (watchParty != null) { TvWatchPartyPlayerOverlays( @@ -3695,6 +3727,8 @@ private fun TvPlayerOverlays( shuffle: TvShuffleUiState?, autoPlayNextEnabled: Boolean, introSkipState: IntroAutoSkipState, + manualSkipTarget: MarkerSkipTarget?, + onManualMarkerSelect: () -> Unit, /** Bumps when the pill's timer (re)starts, so its fill re-anchors. */ introSkipCountdownRun: Int, /** False while the pill is up but its timer is frozen by a pause. */ @@ -3929,6 +3963,12 @@ private fun TvPlayerOverlays( .padding(bottom = introSkipBottomInset, end = 32.dp), contentAlignment = Alignment.BottomEnd, ) { + if (manualSkipTarget != null) { + TvMarkerSkipPill( + label = stringResource(if (manualSkipTarget.kind == ManualMarkerKind.Recap) R.string.marker_skip_recap else R.string.marker_skip_credits), + onSelect = onManualMarkerSelect, + ) + } TvIntroAutoSkipBanner( state = introSkipState, onSelect = onIntroPromptSelect, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index d5d844802d..533d7d78f5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -56,7 +56,10 @@ import org.siloserver.silo.common.player.seek.playerPositionForSource import org.siloserver.silo.common.player.seek.replanMountPositionForSource import org.siloserver.silo.common.player.seek.sourcePositionForPlayer import org.siloserver.silo.common.network.ServerReachabilityMonitor +import org.siloserver.silo.common.player.video.MarkerReconciliationFence +import org.siloserver.silo.common.player.video.MarkerSkipTarget import org.siloserver.silo.common.player.video.VideoPlaybackSessionCoordinator +import org.siloserver.silo.common.player.video.markerSkipTarget import org.siloserver.silo.common.player.video.VideoPlaybackStartRequest import org.siloserver.silo.common.player.video.EpisodeAudioIntent import org.siloserver.silo.common.player.video.EpisodeAudioMode @@ -105,6 +108,7 @@ import org.siloserver.silo.model.playback.SubtitleMediaIdentity import org.siloserver.silo.model.playback.enrichAuthoritativePlaybackSubtitleChoices import org.siloserver.silo.model.playback.resolvedSelectedSubtitleIndex import org.siloserver.silo.model.playback.mergeDownloadedSubtitles +import org.siloserver.silo.playback.PlaybackMarkersUpdate import org.siloserver.silo.playback.PlaybackSubtitleReady import org.siloserver.silo.playback.PlaybackSubtitleTimingChanged import org.siloserver.silo.playback.PlaybackSubtitleSyncUpdated @@ -1190,6 +1194,12 @@ class TvPlayerViewModel( started = SharingStarted.Eagerly, initialValue = _uiState.value.toPlaybackClock(), ) + // Marker controls use source time; presentationState deliberately omits the clock. + val manualMarkerSkipTarget: StateFlow<MarkerSkipTarget?> = uiState + .map { markerSkipTarget(it.position, it.recap, it.credits) } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + private var subtitleMountGeneration = 0L private var lastAdapterMountIdentity: SubtitleIdentity? = null private var lastAdapterMountGeneration: Long? = null @@ -1881,6 +1891,7 @@ class TvPlayerViewModel( position = remountPosition.sourcePositionSeconds, ) } + adoptFileMarkersAfterReplan(before.mediaFileId, version) Log.i( TAG, "subtitle_replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + @@ -2815,6 +2826,7 @@ class TvPlayerViewModel( coroutineContext.ensureActive() if (recoveryContentGeneration != contentLoadGeneration) return@launch val transportMountNonce = nextTypedSubtitleMountNonce(returnedSubtitleIdentity) + val previousFileId = _uiState.value.mediaFileId _uiState.update { it.copy( error = null, @@ -2858,6 +2870,7 @@ class TvPlayerViewModel( committedIdentity = returnedSubtitleIdentity, ) subtitleTransactions.restoreCommittedLocalMount() + adoptFileMarkersAfterReplan(previousFileId, effectiveVersion) Log.i( TAG, "replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + @@ -4339,12 +4352,61 @@ class TvPlayerViewModel( _pendingRemoteSubtitleIndex.value?.let(::remoteSelectSubtitle) } + private val markerReconciliationFence = MarkerReconciliationFence() + private var markerReconcileJob: Job? = null + + /** + * Read the playing file's markers again: after every control connection, + * including on-demand mode, and after a replan lands on another file. + * Naming the file makes the server fill that version's on-demand markers. + */ + fun reconcileMarkers(expectedSessionId: String) { + val state = _uiState.value + val fileId = state.mediaFileId ?: return + if (state.sessionId != expectedSessionId) return + // The same library the start used: a room may have moved to another library's title. + val libraryId = roomContext?.libraryId ?: launchArgs.libraryId + val ticket = markerReconciliationFence.begin() + markerReconcileJob?.cancel() + markerReconcileJob = viewModelScope.launch { + val owner = catalogRepository.captureWatchAuthority() ?: return@launch + val detail = (catalogRepository.getWatchDetail(state.contentId, owner, libraryId, fileId) as? ApiResult.Success) + ?.data ?: return@launch + if (!catalogRepository.isWatchAuthorityCurrent(owner)) return@launch + val current = _uiState.value + if (!markerReconciliationFence.isCurrent(ticket) || current.sessionId != expectedSessionId || + current.contentId != state.contentId || current.mediaFileId != fileId) return@launch + val version = detail.versions.firstOrNull { it.fileId == fileId } ?: return@launch + applyUpdatedMarkers(version.intro, version.credits, + version.recap, version.preview) + } + } + + /** + * A `markers_updated` event. The server also sends a file's update to + * sessions that requested that file but play another, so check the file. + */ + fun applyMarkersUpdate(update: PlaybackMarkersUpdate) { + if (update.fileId != null && update.fileId != _uiState.value.mediaFileId) return + applyUpdatedMarkers(update.intro, update.credits, update.recap, update.preview) + } + + /** A replan that lands on another file takes that file's markers, then reads them again. */ + private fun adoptFileMarkersAfterReplan(previousFileId: Int?, version: FileVersion?) { + val state = _uiState.value + if (state.mediaFileId == previousFileId) return + applyUpdatedMarkers(version?.intro, version?.credits, version?.recap, version?.preview) + state.sessionId?.let(::reconcileMarkers) + } + /** - * Adopt server-recomputed intro/credits ranges (a `markers_updated` event). - * Skip-intro and the credits-based F2 trigger read these from UiState, so the - * update takes effect immediately; `null` clears a marker the server dropped. + * Adopt the playing file's marker ranges (a `markers_updated` event or a + * reconcile read). Skip-intro and the credits-based F2 trigger read these + * from UiState, so the update takes effect immediately; `null` clears a + * marker the server dropped. */ - fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + private fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + markerReconciliationFence.invalidate() _uiState.update { it.copy(intro = intro, credits = credits, recap = recap, preview = preview) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt index 4cea4bc5a4..a3ce804ef8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -412,10 +412,10 @@ class TvVideoPlaybackStarter( showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles ?: activeProfile?.showForcedSubtitles ?: true, - intro = watchDetail.intro, - credits = watchDetail.credits, - recap = watchDetail.recap, - preview = watchDetail.preview, + intro = effectiveVersion?.intro, + credits = effectiveVersion?.credits, + recap = effectiveVersion?.recap, + preview = effectiveVersion?.preview, chapters = effectiveVersion?.chapters.orEmpty(), seriesId = watchDetail.seriesId, seriesTitle = watchDetail.seriesTitle, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt index 21941d2cd1..74f19526b3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayer.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.screens.player +import org.siloserver.silo.common.player.video.MarkerSkipTarget import android.os.Handler import android.os.Looper import android.os.SystemClock @@ -268,6 +269,21 @@ internal fun TvPlaybackSpeedEffect( } } +/** + * Keep the marker pill and hidden-controls OK action behind the visible quality + * offer, and off while playback is loading or failed: the overlays still draw + * over the error screen. + */ +internal fun tvManualMarkerSkipTarget( + active: MarkerSkipTarget?, + canSeek: Boolean, + introVisible: Boolean, + playbackUnavailable: Boolean, + qualityOfferVisible: Boolean, +): MarkerSkipTarget? = active?.takeIf { + canSeek && !introVisible && !playbackUnavailable && !qualityOfferVisible +} + /** Whether the viewer dismissed the current lower-quality offer. */ @Stable internal class TvWatchPartyQualityOfferState { diff --git a/androidTvApp/src/androidMain/res/values/strings.xml b/androidTvApp/src/androidMain/res/values/strings.xml index 1c930322c0..98c8dee372 100644 --- a/androidTvApp/src/androidMain/res/values/strings.xml +++ b/androidTvApp/src/androidMain/res/values/strings.xml @@ -169,4 +169,6 @@ <string name="tv_setup_phone_pill">RECOMMENDED · USE YOUR PHONE</string> <string name="tv_setup_phone_looking">Looking for a phone or tablet…</string> <string name="tv_setup_phone_detail">Open Silo on a phone or tablet on the same Wi‑Fi. Accept the setup card and Silo will securely bring over the server and account.</string> + <string name="marker_skip_recap">Skip Recap</string> + <string name="marker_skip_credits">Skip Credits</string> </resources> diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt index f4b1e9da27..b469cfa1a5 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerTest.kt @@ -1,6 +1,8 @@ package org.siloserver.silo.tv.ui.screens.player import androidx.media3.common.Player +import org.siloserver.silo.common.player.video.markerSkipTarget +import org.siloserver.silo.model.catalog.TimeRange import org.siloserver.silo.watchtogether.RoomPlayerState import org.siloserver.silo.watchtogether.WatchPartyPlaybackContext import java.io.File @@ -11,6 +13,23 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class TvWatchPartyPlayerTest { + @Test + fun `visible quality offer owns select throughout recap and credits`() { + val recap = TimeRange(80.0, 160.0) + val credits = TimeRange(160.0, 240.0) + for (position in listOf(100.0, 200.0)) { + val target = markerSkipTarget(position, recap, credits) + assertTrue(target != null) + assertNull(tvManualMarkerSkipTarget(target, true, false, false, true)) + // Opening controls hides the quality offer and restores the focused + // marker button; dismissing it restores clean-playback marker OK. + assertEquals(target, tvManualMarkerSkipTarget(target, true, false, false, false)) + assertNull(tvManualMarkerSkipTarget(target, false, false, false, false)) + assertNull(tvManualMarkerSkipTarget(target, true, true, false, false)) + assertNull(tvManualMarkerSkipTarget(target, true, false, true, false)) + } + } + private val room = WatchPartyPlaybackContext( roomId = "room-1", selectionRevision = 7L, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt index b7910c858b..3252ac0de0 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt @@ -316,6 +316,10 @@ data class FileVersion( @SerialName("presentation_part_total") val presentationPartTotal: Int? = null, @SerialName("edition_raw") val editionRaw: String? = null, @SerialName("edition_key") val editionKey: String? = null, + val intro: TimeRange? = null, + val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt index bbd75c4130..ab8ebca9c9 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt @@ -77,7 +77,7 @@ class CatalogApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api suspend fun captureWatchAuthority() = watchDetail.capture() suspend fun isWatchAuthorityCurrent(owner: AuthScopeSnapshot) = watchDetail.current(owner) - suspend fun getWatchDetail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null) = watchDetail.detail(id, owner, libraryId) + suspend fun getWatchDetail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null, fileId: Int? = null) = watchDetail.detail(id, owner, libraryId, fileId) /** Captures the current viewer at call time when the caller has no retained owner. */ suspend fun getWatchDetail(id: String, libraryId: Int? = null): ApiResult<WatchDetail> { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/WatchDetailV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/WatchDetailV2Api.kt index 65c01cdbe6..7c797de108 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/WatchDetailV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/WatchDetailV2Api.kt @@ -11,18 +11,20 @@ import org.siloserver.silo.network.* class WatchDetailV2Api(private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate) { suspend fun capture(): AuthScopeSnapshot? = tokens.captureProfileScope() suspend fun current(owner: AuthScopeSnapshot): Boolean = owner.stillOwns(tokens, OwnerPolicy.FULL) - suspend fun detail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null): ApiResult<WatchDetail> = + /** [fileId] names the file being played, so the server fills that version's on-demand markers. */ + suspend fun detail(id: String, owner: AuthScopeSnapshot, libraryId: Int? = null, fileId: Int? = null): ApiResult<WatchDetail> = ownedV2Call<JsonObject, WatchDetail>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> client.get("/api/v2/watch/${id.encodeURLPathPart()}") { authScope(scope!!) requireSiloAuth() libraryId?.let { parameter("library_id", it) } + fileId?.let { parameter("file_id", it) } } }) { decodeWatchDetail(it, id) } } /** Adapt only fields consumed by WatchDetail; do not change the legacy model wire contract. */ -private fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { +internal fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { val content = body["content_id"] as? JsonPrimitive check(content?.isString == true && content.content == id) fun numericId(value: JsonElement): JsonPrimitive { @@ -30,6 +32,15 @@ private fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { check(text.isString) return JsonPrimitive(checkedPositiveId(text.content)) } + fun adaptMarkers(row: MutableMap<String, JsonElement>) { + for (key in listOf("intro", "credits", "recap", "preview")) { + val marker = row[key]?.takeUnless { it is JsonNull }?.jsonObject ?: continue + row[key] = buildJsonObject { + put("start", marker.getValue("start_seconds")) + put("end", marker.getValue("end_seconds")) + } + } + } val versions = body["versions"] as? JsonArray ?: error("Missing versions") val adapted = body.toMutableMap() adapted["versions"] = JsonArray(versions.map { value -> @@ -38,15 +49,10 @@ private fun decodeWatchDetail(body: JsonObject, id: String): WatchDetail { val duration = row["duration_seconds"] as? JsonPrimitive ?: error("Missing duration") check(!duration.isString && duration.double.isFinite() && duration.double >= 0) row["duration"] = duration + adaptMarkers(row) JsonObject(row) }) - for (key in listOf("intro", "credits", "recap", "preview")) { - val marker = body[key]?.takeUnless { it is JsonNull }?.jsonObject ?: continue - adapted[key] = buildJsonObject { - put("start", marker.getValue("start_seconds")) - put("end", marker.getValue("end_seconds")) - } - } + adaptMarkers(adapted) body["user_data"]?.takeUnless { it is JsonNull }?.jsonObject?.let { data -> val row = data.toMutableMap() row["last_file_id"]?.takeUnless { it is JsonNull }?.let { row["last_file_id"] = numericId(it) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdate.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdate.kt index b1c6aee5cd..f4129f2774 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdate.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdate.kt @@ -5,6 +5,7 @@ import org.siloserver.silo.network.PlaybackRealtimeEvent import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull /** * Parsed `markers_updated` server event: the server recomputed marker ranges @@ -12,13 +13,16 @@ import kotlinx.serialization.json.doubleOrNull * the fresh values so skip-intro, the credits-based auto-advance, and the * timeline marker bands use them. All four marker kinds are surfaced; only * [intro] drives auto-skip and only [credits] drives auto-advance. A `null` - * range means "no such marker" (the server clears it). + * range means "no such marker" (the server clears it). [fileId] names the file + * the ranges belong to; the server also sends a file's update to sessions that + * requested it but are playing another file, so players must check it. */ data class PlaybackMarkersUpdate( val intro: TimeRange?, val credits: TimeRange?, val recap: TimeRange?, val preview: TimeRange?, + val fileId: Int? = null, ) /** @@ -41,6 +45,7 @@ fun decodeMarkersUpdate(payload: JsonObject): PlaybackMarkersUpdate { credits = range("credits"), recap = range("recap"), preview = range("preview"), + fileId = (payload["file_id"] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull?.takeIf { it > 0 }, ) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt index 3af09b0da9..fdc566b16d 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt @@ -199,7 +199,7 @@ class CatalogRepository( /** Fetches playback-oriented detail (versions, user progress, intro/credits markers). */ suspend fun captureWatchAuthority() = catalogApi.captureWatchAuthority() suspend fun isWatchAuthorityCurrent(owner: org.siloserver.silo.network.AuthScopeSnapshot) = catalogApi.isWatchAuthorityCurrent(owner) - suspend fun getWatchDetail(contentId: String, owner: org.siloserver.silo.network.AuthScopeSnapshot, libraryId: Int? = null) = catalogApi.getWatchDetail(contentId, owner, libraryId) + suspend fun getWatchDetail(contentId: String, owner: org.siloserver.silo.network.AuthScopeSnapshot, libraryId: Int? = null, fileId: Int? = null) = catalogApi.getWatchDetail(contentId, owner, libraryId, fileId) suspend fun getWatchDetail(contentId: String, libraryId: Int? = null): ApiResult<WatchDetail> = catalogApi.getWatchDetail(contentId, libraryId) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/WatchDetailMarkerV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/WatchDetailMarkerV2Test.kt new file mode 100644 index 0000000000..74ea0c4d2f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/WatchDetailMarkerV2Test.kt @@ -0,0 +1,37 @@ +package org.siloserver.silo.network.apiv2 + +import kotlinx.serialization.json.jsonObject +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.model.catalog.TimeRange +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchDetailMarkerV2Test { + @Test fun selectedFileRangesSurviveWatchProjection() { + val body = SiloJson.parseToJsonElement("""{ + "content_id":"m1","type":"movie","title":"Film", + "recap":{"start_seconds":10,"end_seconds":20}, + "versions":[{"file_id":"1","duration_seconds":100,"recap":{"start_seconds":10,"end_seconds":20}}, + {"file_id":"2","duration_seconds":100,"intro":{"start_seconds":1,"end_seconds":9},"recap":{"start_seconds":30,"end_seconds":40},"credits":{"start_seconds":50,"end_seconds":60},"preview":{"start_seconds":70,"end_seconds":80}}] + }""").jsonObject + val detail = decodeWatchDetail(body, "m1") + val selected = detail.versions.last() + assertEquals(TimeRange(10.0, 20.0), detail.recap) + assertEquals(TimeRange(1.0, 9.0), selected.intro) + assertEquals(TimeRange(30.0, 40.0), selected.recap) + assertEquals(TimeRange(50.0, 60.0), selected.credits) + assertEquals(TimeRange(70.0, 80.0), selected.preview) + } + + @Test fun selectedFileClearDoesNotAdoptOtherVersionRange() { + val body = SiloJson.parseToJsonElement("""{"content_id":"m1","type":"movie","title":"Film", + "recap":{"start_seconds":10,"end_seconds":20}, + "versions":[{"file_id":"2","duration_seconds":100,"marker_segments":[]}]}""").jsonObject + val selected = decodeWatchDetail(body, "m1").versions.single() + assertNull(selected.intro) + assertNull(selected.recap) + assertNull(selected.credits) + assertNull(selected.preview) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdateTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdateTest.kt index bbba03857f..9d03bd946e 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdateTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/PlaybackMarkersUpdateTest.kt @@ -52,4 +52,10 @@ class PlaybackMarkersUpdateTest { val m = decodeMarkersUpdate(payload("""{"credits":{"start":"10","end":"20"}}""")) assertNull(m.credits) } + + @Test fun keepsTheFileTheRangesBelongTo() { + assertEquals(7, decodeMarkersUpdate(payload("""{"file_id":7}""")).fileId) + assertNull(decodeMarkersUpdate(payload("""{"intro":null}""")).fileId) + assertNull(decodeMarkersUpdate(payload("""{"file_id":"7"}""")).fileId) + } } From 74490e9d3777454a2eedcb40515798a856de69b0 Mon Sep 17 00:00:00 2001 From: amokster88 <69913373+amokster88@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:34:01 +0530 Subject: [PATCH 74/87] fix(libraries): tell an empty library apart from filters that match nothing (#472) * fix(libraries): tell an empty library apart from filters that match nothing An empty library and a filter that matched nothing showed the same kind of message, so a viewer could not tell whether to clear filters or wait for media. On phone the copy depended on whether sort or filters had changed; on TV every empty browse said "No titles match the current filters." When a library browse comes back empty, the screen now checks whether the library holds anything at all. With no filter or letter applied, the empty page already answers that. Otherwise it reads one unfiltered item without a total (CatalogRepository.libraryHasItems), straight from the API so a one-item page never replaces the cached offline page. An empty library says "This library is empty", as on the web; filters that hide everything keep the filter message. If the check fails, the existing copy stays. This covers the phone library and browse screens and the TV library Browse tab. Refs #451 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(libraries): scope the empty-library check to the mixed-library type In a mixed library's Movies or Series view, the one-item check asked the whole library, so a Series view with no series reported "No matches" once a filter was set, while the same view without a filter said "This library is empty". Ask within the view's type on phone and TV, as Apple does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- .../android/ui/screens/browse/BrowseScreen.kt | 8 ++++-- .../ui/screens/browse/BrowseViewModel.kt | 12 ++++++++ .../ui/screens/libraries/LibrariesScreen.kt | 28 +++++++++++++++++-- .../screens/library/TvLibraryDetailScreen.kt | 8 +++++- .../library/TvLibraryDetailViewModel.kt | 22 +++++++++++++-- .../siloserver/silo/network/api/CatalogApi.kt | 4 ++- .../silo/repository/CatalogRepository.kt | 14 ++++++++++ 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt index 83046c5a2d..824c2b8171 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseScreen.kt @@ -228,13 +228,17 @@ fun BrowseScreen( modifier = Modifier.size(64.dp), ) Text( - text = "No items found", + text = if (state.libraryHasItems == false) "This library is empty" else "No items found", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Text( - text = "Try adjusting your filters", + text = if (state.libraryHasItems == false) { + "There is nothing in this library yet." + } else { + "Try adjusting your filters" + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt index 0dacd4a5af..4fa9158419 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/BrowseViewModel.kt @@ -32,6 +32,8 @@ data class BrowseUiState( val items: List<BrowseItem> = emptyList(), val hasMore: Boolean = false, val total: Int = 0, + /** For an empty library browse: false when the library itself is empty, true when filters hid everything, null when unknown (#451). */ + val libraryHasItems: Boolean? = null, val filterState: CatalogFilterState = CatalogFilterState(), val mediaType: BrowseFacetMediaType = BrowseFacetMediaType.Video, val preserveFilters: Boolean = true, @@ -220,6 +222,15 @@ class BrowseViewModel( // Overlay local optimistic watched/favorite so an offline mutation // shows immediately on the cached grid (mirrors Home). val overlaid = overlayLocalState(response.items) + // An empty first page of a library only says this view matched + // nothing (#451): with nothing narrowing it, the page was the + // whole library; otherwise one unfiltered item decides. + val libraryId = currentState.libraryId + val libraryHasItems = when { + !reset || overlaid.isNotEmpty() || libraryId == null -> null + !filters.hasActiveFilters && currentState.selectedNamePrefix == null -> false + else -> catalogRepository.libraryHasItems(libraryId) + } kotlin.coroutines.coroutineContext.ensureActive() continuation = response.continuation // Audiobook libraries surface book-native facets @@ -239,6 +250,7 @@ class BrowseViewModel( items = if (reset) overlaid else it.items + overlaid, hasMore = response.hasMore, total = response.total, + libraryHasItems = if (reset) libraryHasItems else it.libraryHasItems, title = response.title ?: "Browse", mediaType = mediaType ?: it.mediaType, error = null, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index 8d22a57c48..1eb50000a3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -192,6 +192,8 @@ data class LibrariesUiState( val catalogItems: List<BrowseItem> = emptyList(), val catalogTotal: Int = 0, val catalogHasMore: Boolean = false, + /** For an empty grid: false when the library itself is empty, true when filters hid everything, null when unknown (#451). */ + val catalogLibraryHasItems: Boolean? = null, // Full filter model shared with the standalone Browse screen: facets // (genre/decade/rating/studio/language/series/...), match-all/any, and the // available-filter vocabulary from the server. Genre is a Categories facet @@ -701,7 +703,7 @@ class LibrariesViewModel( if (!isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { it } else if (reset) { - it.copy(isLoadingCatalog = true, isLoadingMoreCatalog = false, catalogError = null) + it.copy(isLoadingCatalog = true, isLoadingMoreCatalog = false, catalogError = null, catalogLibraryHasItems = null) } else { it.copy(isLoadingMoreCatalog = true, catalogError = null) } @@ -727,6 +729,17 @@ class LibrariesViewModel( // Overlay local optimistic watched/favorite (mirrors Home/Browse). val overlaid = overlayLocalState(result.data.items) if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch + // An empty first page only says this view matched nothing (#451). + // With nothing narrowing it, the page was the whole library; + // otherwise one unfiltered item decides. A Movies/Series view + // of a mixed library asks within that type, as Apple does. + // Loading stays up meanwhile, so the wrong message never flashes. + val libraryHasItems = when { + !reset || overlaid.isNotEmpty() -> null + !requestState.filterState.hasActiveFilters && requestState.selectedNamePrefix == null -> false + else -> catalogRepository.libraryHasItems(libraryId, requestState.mediaScope) + } + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch catalogContinuation = result.data.continuation // Audiobook libraries expose book-native facets // (author/narrator/series) — detected from the first item. @@ -745,6 +758,7 @@ class LibrariesViewModel( catalogItems = if (reset) overlaid else it.catalogItems + overlaid, catalogTotal = result.data.total, catalogHasMore = result.data.hasMore, + catalogLibraryHasItems = if (reset) libraryHasItems else it.catalogLibraryHasItems, browseMediaType = detectedMediaType ?: it.browseMediaType, catalogError = null, ) @@ -1288,8 +1302,16 @@ private fun BrowseTabContent( Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) { Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { controlsHeader() } EmptyStateView( - title = if (isCustomised) "No matches" else "No items found", - subtitle = if (isCustomised) "No titles match the current sort or filters." else "Try switching libraries", + title = when (state.catalogLibraryHasItems) { + false -> "This library is empty" + true -> "No matches" + null -> if (isCustomised) "No matches" else "No items found" + }, + subtitle = when (state.catalogLibraryHasItems) { + false -> "There is nothing in this library yet." + true -> "No titles match the current filters." + null -> if (isCustomised) "No titles match the current sort or filters." else "Try switching libraries" + }, icon = libraryIcon(state.libraries.firstOrNull { it.id == state.selectedLibraryId }?.type.orEmpty()), modifier = Modifier.weight(1f), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt index 7cd742d0dc..868e689825 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -641,7 +641,13 @@ private fun LibraryGrid( } } else if (state.browseItems.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }, key = "empty") { - TvCatalogEmptyState(message = "No titles match the current filters.") + TvCatalogEmptyState( + message = if (state.browseLibraryHasItems == false) { + "This library is empty." + } else { + "No titles match the current filters." + }, + ) } } else { itemsIndexed( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt index e2cbbddf5d..29080ff3e3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -180,6 +180,8 @@ class TvLibraryDetailViewModel( val filtersLoading: Boolean = true, val browseItems: List<BrowseItem> = emptyList(), val browseHasMore: Boolean = false, + /** For an empty grid: false when the library itself is empty, true when filters hid everything, null when unknown (#451). */ + val browseLibraryHasItems: Boolean? = null, val browseLoading: Boolean = false, val browseLoadingMore: Boolean = false, val browseError: String? = null, @@ -594,9 +596,10 @@ class TvLibraryDetailViewModel( } val facetGroups = filter.facetSelection.toQueryGroups() + val browseMediaType = mediaScope ?: mediaTypeFor(libraryType) val result = catalogRepository.browse( source = "query", - mediaType = mediaScope ?: mediaTypeFor(libraryType), + mediaType = browseMediaType, libraryId = libraryId, genre = filter.genre, sort = filter.sort, @@ -619,6 +622,21 @@ class TvLibraryDetailViewModel( when (result) { is ApiResult.Success -> { val response = result.data + val visibleItems = response.items.visibleOnTv() + // An empty first page only says this view matched nothing (#451): + // with nothing narrowing it, the page was the whole library; + // otherwise one unfiltered item decides, within the Movies/Series + // scope of a mixed library as Apple does. Loading stays up + // meanwhile, so the wrong message never flashes. + val libraryHasItems = when { + !reset || visibleItems.isNotEmpty() -> null + // Titles the TV does not show (isTvHiddenMediaType) still + // mean the library is not empty. + response.items.isNotEmpty() -> true + filter == TvLibraryBrowseFilter(sort = filter.sort, order = filter.order) -> false + else -> catalogRepository.libraryHasItems(libraryId, browseMediaType) + } + if (generation != browseGeneration) return@launch browseContinuation = response.continuation if (browseSnapshot == null) { browseSnapshot = response.snapshot @@ -629,7 +647,6 @@ class TvLibraryDetailViewModel( browseRawLoaded + response.items.size } _uiState.update { - val visibleItems = response.items.visibleOnTv() it.copy( // distinctBy contentId is a belt-and-suspenders guard // against duplicate keys crashing LazyVerticalGrid if a @@ -640,6 +657,7 @@ class TvLibraryDetailViewModel( (it.browseItems + visibleItems).distinctBy { item -> item.contentId } }, browseHasMore = response.hasMore, + browseLibraryHasItems = if (reset) libraryHasItems else it.browseLibraryHasItems, browseLoading = false, browseLoadingMore = false, browseError = null, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt index ab8ebca9c9..0f66d60b9b 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/CatalogApi.kt @@ -21,6 +21,7 @@ class CatalogApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api continuation: CatalogContinuationV2? = null, limit: Int? = null, namePrefix: String? = null, yearMin: Int? = null, yearMax: Int? = null, queryGroups: List<CatalogQueryGroup> = emptyList(), match: String? = null, + skipTotal: Boolean = false, ): ApiResult<CatalogResponse> { val groups = queryGroups.toV2Groups().toMutableList() val implicit = buildList { @@ -38,7 +39,8 @@ class CatalogApi(client: HttpClient, private val v2: CatalogV2Api = CatalogV2Api } return v2.browse(CatalogQueryV2(source = source ?: "query", q = query, type = mediaType, libraryId = libraryId?.toString(), sort = sort, order = order, limit = limit ?: 50, - namePrefix = namePrefix, groups = groups, match = match), continuation).map { it.toCatalogResponse() } + namePrefix = namePrefix, groups = groups, match = match, skipTotal = skipTotal), continuation) + .map { it.toCatalogResponse() } } suspend fun getAudiobookGroups(libraryId: Int, groupBy: String, sort: String = "name", diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt index fdc566b16d..9d28573042 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt @@ -56,6 +56,20 @@ class CatalogRepository( private val episodesInFlight = mutableMapOf<EpisodesRequestKey, Deferred<ApiResult<EpisodesResponse>>>() + /** + * Whether a library holds any item at all, whatever the viewer's filters: + * one unfiltered item, no total. A screen asks only once its own view came + * back empty, to tell an empty library from filters that match nothing + * (#451). Read straight from the API so a one-item page never replaces the + * cached default page [browse] keeps for offline. Null when the server + * could not answer. + */ + suspend fun libraryHasItems(libraryId: Int, mediaType: String? = null): Boolean? = + when (val result = catalogApi.getCatalog(libraryId = libraryId, mediaType = mediaType, limit = 1, skipTotal = true)) { + is ApiResult.Success -> result.data.items.isNotEmpty() + else -> null + } + /** Browse the catalog with optional filters, sorting, and pagination. */ suspend fun browse( source: String? = null, From e535ddceb5bedd22b75d0e53abca27b3640982df Mon Sep 17 00:00:00 2001 From: Suspense <rhainland@gmail.com> Date: Sat, 10 Oct 2026 06:23:39 +0200 Subject: [PATCH 75/87] fix(profiles): recover from access changes without failed loads (#440) * feat(profiles): send stale PIN profiles back to profile selection When an administrator changes an account's access, the server stops accepting PIN profile tokens minted before the change and answers 403 profile_verification_required. Android had no general handling, so every screen showed a failed load, and the phone picker failed too because it still sent the stale token. The v2 error decoder now reports that refusal with the profile id and token the request presented. A recovery clears the profile selection only when that is still the active profile on the active server, never the sign-in, and phone and TV open the profile picker. Reports for an identity that has since changed, a remote-playback overlay, or household-management calls (which use the same problem type for a missing permission) are ignored, so refusals cannot loop. The prompt waits behind playback and sign-in screens, and the picker reloads in place when it is already open. * fix(notifications): resume realtime after the profile is re-verified A 401 or 403 on the events ticket stopped the notifications realtime loop for the rest of the session. After an access change that is the normal path for a PIN profile: the ticket is refused until the user enters the PIN again, and the unread badge then never updated live. The loop now waits for the next identity change (PIN re-entry, profile switch, account or server change) and reconnects with a new ticket. A socket the server closes with 4001 access_changed already ends cleanly and reconnects like any other close. * feat(realtime): refresh access-dependent screens when access changes When an administrator changes an account's access, the events socket sends {"type":"access_changed"} and closes with code 4001. Android reconnected, but only Home could refetch, so libraries, search, item details, and collections kept showing what the old policy allowed. The events socket now reports the frame and the 4001 close once per connection. Home, the library lists and TV tabs, search, item details, and collections refetch through their existing quiet refresh paths on phone and TV, coalesced so both sockets seeing the same change cause one refresh. The sockets reconnect with a new ticket as before. A server that never sends the signal leaves all of this idle. * test(auth): cover the quiet refresh on token_refresh_required After a role change the server rejects older access tokens with 401 token_refresh_required while the refresh token keeps working. The auth plugin already refreshes and retries once on any 401; these tests pin that for the active and the pinned background path and check that the session and profile selection survive. * fix(realtime): refresh once when both events sockets report one access change The Home and notifications sockets each detect an access change on their own server-side check, which runs every 15 seconds from each connection's start. Their reports are usually seconds apart, so the 750 ms debounce let both through and screens such as search refetched twice. The first report now refreshes at once, and reports within 20 seconds of it count as the same change. * fix(profiles): log only the exception class when recovery init fails * fix(detail): show the unavailable state when an access change hides the title The access-change refresh reused the quiet return refresh, which ignored every error, so a title the viewer lost access to stayed on screen. A 403 (other than a stale profile proof), 404, or 410 now replaces the detail with the error the initial load shows; network failures and 5xx still keep the current detail. * fix(profiles): re-check a deferred profile prompt when remote playback ends A prompt deferred behind a remote-playback overlay was only decided again on the next navigation. The recovery now re-emits the pending prompt after each completed identity transition, and both navigation hosts collect that, so ending the overlay sends the user to profile selection without another navigation. * fix(tv): refresh library pages when the viewer's access changes TvLibraryDetailViewModel kept its one-time loaded flags across an access change, so recommended sections, browse results, audiobook groups, facets, and collections stayed stale. The screen now reloads the visible tab and marks the rest for reload when next opened. * fix(tv): re-probe library collections after an access change The collections probe was keyed only on the library list, which can stay equal while collection visibility changes, so the Collections pill kept its old answer. * fix(detail): reload a refused title when access changes again After an access change refused a title, the detail was cleared, and the next access-change refresh returned early because there was no detail to refresh. A title the viewer regained access to stayed on the error until they tapped Retry. With no detail on screen, an access change now runs the full load, as Retry does. * fix(detail): keep a refused title hidden when the full load runs again The full detail load paints the durable cached copy before the live request returns, and a refusal does not evict that cache. When the live request was refused, the error was set but the cached detail stayed, and the screen shows detail ahead of the error. After an access change refused a title, the next access change, or Retry, brought the refused title back with all its actions. On phone and TV, a refusal (404, 410, or a 403 other than profile_verification_required) during the full load now clears the cached detail, so the screen shows the error. Network errors and other failures keep it as before. Adds phone and TV tests for both access-change paths: a title the viewer regains comes back, and a title still refused stays on the error. * fix(realtime): apply access changes a screen missed while away Screens refreshed for an access change only while composed, so a retained ViewModel whose screen was behind another destination (TV library behind a detail page, detail behind the player, phone Libraries while another tab showed) kept the old policy's data when the user came back. Each of those ViewModels now keeps an AccessChangeCursor with the last revision it applied, and its screen refreshes on return for a change reported while it was away, under the same coalescing window. Related gaps from review: - A detail page whose first load was still in flight ignored an access change, so the old policy's answer stayed. The change now replaces the unfinished load on phone and TV. - Phone Libraries reloaded only the visible subtab; it now forgets which subtabs and facets were loaded, as TV does. - Home dropped a realtime signal that arrived during a running fetch; it now runs one follow-up refresh after it. - After profile recovery cleared the selection, Back on the phone picker returned to Main with no profile. Back now leaves the app instead. - A refusal no longer clears a phone detail page whose title has a completed download, so its local play and delete actions stay. - The TV collections probe rethrows cancellation instead of publishing a partial set when an access change restarts it. * fix(detail): reload past a pre-change warm-up after an access change Replacing an in-flight detail load did not help when the load had joined a Home warm-up for the same title: the new load joined the same request, answered under the old policy. Detail reloads and quiet refreshes after an access change now send their own request on phone and TV. The phone Libraries hub and library collections screen also let an older library-list or collections load land after the refresh an access change triggered. A newer load now cancels the unfinished one. * fix(tv): skip a pre-change warm-up when an access change reruns the full detail load * fix(realtime): check the library list for an access change after the app returns to the foreground The events sockets close while the app is in the background, and the server compares each new connection with the policy its own ticket was minted under, so a change made in that time was never reported. Returning to the foreground now fetches the library list and reports an access change when its set of libraries differs from the cached list the screens were built from. * fix(realtime): refresh for a change reported by a socket opened after the last refresh Access-change reports within 20 seconds of a refresh were all treated as the second socket's report of the same change. A socket that reconnects after reporting compares against its new ticket, so its report inside that window is a second edit, and it was dropped for good. Reports now carry the time their connection opened, and a report from a connection opened after the last refresh refreshes again. * fix(detail): keep an older quiet refresh from overwriting a newer one A return or watched-change refresh still in flight when an access change refreshes the page could land last and restore a title the newer read found refused. Each quiet detail read now replaces the unfinished one, and keeps its access-change refusal handling and, on TV, the player's saved position. * fix(tv): let only the newest library-list, filters, and collections load publish An access change reloads search media types and library facets and collections while an earlier load may still be running. The newer load now cancels the older one, so the pre-change answer cannot land after it. * fix(tv): keep a watched change from restoring an older resume position A watched-change refresh that replaces an unfinished playback-return refresh no longer re-applies that return's saved position, which the watched write has cleared. Search also declares its library-load job before init, which starts the first load, and a test covers a reload after an access change superseding that first load. * fix(profiles): return to the server list from a picker opened while adding a server The picker's Back handler closed the app whenever no profile was selected and another screen sat below it. Adding a server from the server list leaves that list below the picker with no profile yet, so Back closed the app instead of returning to it. Close the app only when the screen below belongs to the signed-in shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(search): keep an older search from overwriting the access-change re-run An access change re-runs the current query while the earlier request for the same query can still be in flight. Both published, so a pre-change answer that finished last restored titles the viewer had lost. Each new search now takes a generation, and answers from older ones are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(collections): let only the newest collections load publish Collections refreshes on an access change, but the initial load or an earlier refresh could finish later and restore collections the viewer can no longer open, on phone and TV. Loads now share one job; a new load cancels the previous one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(libraries): keep a pending facet load from refilling facets after an access change refreshAfterAccessChange() cleared the Browse facets but left the query generation alone, so a facet load still in flight stored the old policy's facets, and Browse then skipped fetching them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): declare the library page's load jobs before init init starts the first Recommended load, but recommendedGeneration and recommendedJob were declared below it, so their initializers ran afterward: the job was dropped and the generation reset to 0. An access-change reload then could not cancel the first load and reused its generation, so both answers published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../silo/common/ui/ViewerAccessEffects.kt | 49 +++ .../network/EventsSocketAccessChangeTest.kt | 123 ++++++ .../silo/android/SiloApplication.kt | 10 + .../silo/android/di/AndroidModule.kt | 2 +- .../home/HomeRealtimeForegroundStarter.kt | 10 + .../android/ui/navigation/AppNavigation.kt | 95 +++++ .../silo/android/ui/screens/MainScreen.kt | 13 + .../screens/collections/CollectionsScreen.kt | 4 + .../collections/LibraryCollectionsScreen.kt | 17 +- .../ui/screens/detail/ItemDetailScreen.kt | 6 + .../ui/screens/detail/ItemDetailViewModel.kt | 94 ++++- .../ui/screens/libraries/LibrariesScreen.kt | 21 + .../profiles/ProfileSelectionViewModel.kt | 15 + .../android/ui/screens/search/SearchScreen.kt | 9 + .../ui/screens/search/SearchViewModel.kt | 9 +- .../screens/detail/MobileDetailActionsTest.kt | 214 +++++++++- .../libraries/LibrariesViewModelTest.kt | 33 ++ .../siloserver/silo/tv/SiloTvApplication.kt | 10 + .../siloserver/silo/tv/di/AndroidTvModule.kt | 2 +- .../tv/home/HomeRealtimeForegroundStarter.kt | 10 + .../silo/tv/ui/navigation/TvAppNavigation.kt | 75 ++++ .../collections/TvCollectionsScreen.kt | 4 + .../ui/screens/detail/TvItemDetailScreen.kt | 6 + .../screens/detail/TvItemDetailViewModel.kt | 100 ++++- .../ui/screens/libraries/TvLibrariesScreen.kt | 4 + .../screens/libraries/TvLibrariesViewModel.kt | 2 + .../screens/library/TvLibraryDetailScreen.kt | 4 + .../library/TvLibraryDetailViewModel.kt | 74 +++- .../profiles/TvProfileSelectionViewModel.kt | 15 + .../tv/ui/screens/search/TvSearchScreen.kt | 3 + .../tv/ui/screens/search/TvSearchViewModel.kt | 21 +- .../silo/tv/ui/shell/TvMainShell.kt | 20 +- .../detail/TvItemDetailAccessChangeTest.kt | 260 ++++++++++++ .../TvLibrarySubdestinationViewModelTest.kt | 36 +- .../search/TvSearchAccessChangeTest.kt | 92 +++++ .../org/siloserver/silo/di/NetworkModule.kt | 22 +- .../siloserver/silo/di/RepositoryModule.kt | 16 +- .../silo/network/AccessChangeSignals.kt | 220 ++++++++++ .../silo/network/AuthInterceptorImpl.kt | 7 + .../silo/network/SiloHttpClientImpl.kt | 2 + .../silo/network/apiv2/ApiV2Call.kt | 7 +- .../silo/network/apiv2/EventsSocketV2Api.kt | 54 ++- .../silo/repository/CatalogRepository.kt | 13 +- .../silo/repository/ForegroundAccessCheck.kt | 26 ++ .../repository/HomeRealtimeCoordinator.kt | 12 +- .../repository/NotificationsRepository.kt | 29 +- .../silo/repository/PersonalDataRepository.kt | 14 + .../silo/repository/ProfileRepository.kt | 55 +++ .../repository/ProfileVerificationRecovery.kt | 180 +++++++++ .../silo/viewmodel/CollectionsViewModel.kt | 29 +- .../silo/viewmodel/HomeViewModel.kt | 23 +- .../silo/network/AccessChangeCursorTest.kt | 87 ++++ .../SiloAuthPluginTokenRefreshRequiredTest.kt | 124 ++++++ .../silo/network/StaleProfileDetectionTest.kt | 147 +++++++ .../CatalogRepositoryDetailCacheTest.kt | 24 ++ .../repository/HiddenLibrariesStoreTest.kt | 19 + .../HomeRealtimeAccessChangeTest.kt | 145 +++++++ .../repository/NotificationsRepositoryTest.kt | 80 +++- .../PersonalDataRepositoryCacheTest.kt | 45 +++ .../ProfileVerificationRecoveryTest.kt | 378 ++++++++++++++++++ .../CollectionsViewModelLoadOrderTest.kt | 68 ++++ .../HomeViewModelCacheIdentityTest.kt | 41 ++ 62 files changed, 3248 insertions(+), 81 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ViewerAccessEffects.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/EventsSocketAccessChangeTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailAccessChangeTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchAccessChangeTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/network/AccessChangeSignals.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/ForegroundAccessCheck.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileVerificationRecovery.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/AccessChangeCursorTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginTokenRefreshRequiredTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/StaleProfileDetectionTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/repository/HomeRealtimeAccessChangeTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileVerificationRecoveryTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModelLoadOrderTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ViewerAccessEffects.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ViewerAccessEffects.kt new file mode 100644 index 0000000000..91a08923c7 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/ViewerAccessEffects.kt @@ -0,0 +1,49 @@ +package org.siloserver.silo.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import org.siloserver.silo.network.AccessChangeCursor +import org.siloserver.silo.network.AccessChangeSignals + +/** + * Runs [onChanged] each time the server reports that the viewer's access + * changed (access group, permissions, or playback-quality override) while this + * composable is on screen, or, with a [AccessChangeCursor], since it was last + * on screen. Screens use it to refetch libraries, search results, + * details, and collections the new policy may have added or removed. + * + * Inert against a server that never reports access changes. + */ +@Composable +fun OnViewerAccessChanged( + signals: AccessChangeSignals, + /** + * The retained ViewModel's cursor. With it, a change reported while this + * screen was out of composition (another destination in front, another + * tab showing) runs [onChanged] as soon as the screen returns. + */ + cursor: AccessChangeCursor? = null, + onChanged: () -> Unit, +) { + val latest by rememberUpdatedState(onChanged) + LaunchedEffect(signals, cursor) { + val changes = if (cursor != null) signals.changes(cursor) else signals.changes + changes.collect { latest() } + } +} + +/** + * A key that moves on each reported access change, for `produceState` and + * `LaunchedEffect` loaders that should re-run under the new policy. + */ +@Composable +fun rememberViewerAccessKey(signals: AccessChangeSignals): Int { + var key by remember(signals) { mutableIntStateOf(0) } + OnViewerAccessChanged(signals) { key++ } + return key +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/EventsSocketAccessChangeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/EventsSocketAccessChangeTest.kt new file mode 100644 index 0000000000..43f8dff43e --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/EventsSocketAccessChangeTest.kt @@ -0,0 +1,123 @@ +package org.siloserver.silo.common.network + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.siloserver.silo.network.AccessChangeSignals +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.CleartextOriginConsent +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.EventsSocketV2Api +import org.siloserver.silo.network.canonicalHttpOrigin +import org.siloserver.silo.network.createSiloClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The events socket's access-change notice (a frame, then close code 4001) + * reaches [AccessChangeSignals] once per connection, and a server that never + * sends it leaves the signals untouched. + */ +class EventsSocketAccessChangeTest { + + @Test + fun `access_changed frame and 4001 close report one access change`() = runBlocking { + val frames = connectOnce { socket -> + socket.send("""{"type":"access_changed"}""") + socket.close(4001, "access_changed") + } + + assertEquals(1L, frames.signals.revision.value) + assertTrue(frames.received.any { it.contains("access_changed") }) + } + + @Test + fun `a 4001 close alone reports the access change`() = runBlocking { + val frames = connectOnce { socket -> socket.close(4001, "access_changed") } + + assertEquals(1L, frames.signals.revision.value) + } + + @Test + fun `an ordinary close reports nothing`() = runBlocking { + val frames = connectOnce { socket -> + socket.send("""{"type":"subscribed","channel":"catalog"}""") + socket.close(1000, "bye") + } + + assertEquals(0L, frames.signals.revision.value) + } + + private class Outcome(val signals: AccessChangeSignals, val received: List<String>) + + /** [afterSubscribe] runs once the client's subscribe frame arrives, as the server's recheck would. */ + private suspend fun connectOnce(afterSubscribe: (WebSocket) -> Unit): Outcome { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody("""{"ticket":"opaque-proof","expires_in":20,"max_connection_seconds":300,"protocol":"silo.events.v2"}"""), + ) + server.enqueue( + MockResponse() + .withWebSocketUpgrade( + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) = afterSubscribe(webSocket) + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + }, + ) + .addHeader("Sec-WebSocket-Protocol", "silo.events.v2"), + ) + server.start() + val signals = AccessChangeSignals() + val tokens = scopedTokens(server.url("/").toString()) + val approvedOrigin = canonicalHttpOrigin(server.url("/").toString()) + val httpClient = createSiloClient( + tokenManager = tokens, + cleartextOriginConsent = object : CleartextOriginConsent { + override suspend fun isApproved(origin: String): Boolean = origin == approvedOrigin + }, + accessChangeSignals = signals, + ) + try { + val received = withTimeout(10_000) { + EventsSocketV2Api(httpClient, tokens, ApiV2Gate.Unrestricted, signals) + .frames(listOf("catalog")) + .toList() + } + return Outcome(signals, received) + } finally { + httpClient.close() + server.shutdown() + } + } + + private suspend fun scopedTokens(serverUrl: String): TokenManager { + val delegate = TokenManagerImpl().apply { + setServerUrl(serverUrl) + saveTokens("ACCESS", "refresh", 3_600) + setProfileIdentity("profile-1", "PROFILE_TOKEN") + } + val active = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = serverUrl, + profileToken = "PROFILE_TOKEN", + ) + return object : TokenManager by delegate { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = active + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = + "ACCESS".takeIf { scope == active } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt index 9c36897a54..274b2d618d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/SiloApplication.kt @@ -48,6 +48,15 @@ class SiloApplication : Application(), Configuration.Provider, SingletonImageLoa koinApp.koin.get<org.siloserver.silo.repository.ImageCapabilitiesSession>().start( kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO), ) + // Profile-verification recovery: clears a profile whose PIN token the + // server stopped accepting (after an access change), including when + // background work hits it first. Guarded — never load-bearing. + runCatching { + koinApp.koin.get<org.siloserver.silo.repository.ProfileVerificationRecovery>() + }.onFailure { + // Class name only: the exception chain can carry auth or server data. + android.util.Log.w("SiloApplication", "Profile verification recovery init failed: ${it::class.simpleName}") + } // Drive notifications realtime off the app foreground lifecycle. Guarded: // it's a foreground accelerator, never load-bearing for cold start. runCatching { @@ -99,6 +108,7 @@ class SiloApplication : Application(), Configuration.Provider, SingletonImageLoa org.siloserver.silo.android.home.HomeRealtimeForegroundStarter( coordinator = koinApp.koin.get(), profileRepository = koinApp.koin.get(), + accessCheck = koinApp.koin.getOrNull(), ).register() }.onFailure { android.util.Log.w("SiloApplication", "Home realtime starter init failed", it) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index ba51e25acc..01ca331ed9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -549,7 +549,7 @@ val androidModule = module { viewModel { SignupViewModel(get()) } viewModel { InviteClaimViewModel(get(), get()) } viewModel { OnboardingTourViewModel(get(), get(), get(), get(), get()) } - viewModel { ProfileSelectionViewModel(get(), get()) } + viewModel { ProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull()) } viewModel { CreateProfileViewModel(get()) } viewModel { EditProfileViewModel(get()) } viewModel { ServerListViewModel(get(), get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/home/HomeRealtimeForegroundStarter.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/home/HomeRealtimeForegroundStarter.kt index a5b118d764..b11a32973a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/home/HomeRealtimeForegroundStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/home/HomeRealtimeForegroundStarter.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import org.siloserver.silo.repository.ForegroundAccessCheck import org.siloserver.silo.repository.HomeRealtimeCoordinator import org.siloserver.silo.repository.ProfileRepository @@ -18,14 +19,18 @@ import org.siloserver.silo.repository.ProfileRepository * (ProfileRepository.profileSwitches) * reconnects so the coordinator's profile filter tracks the new session. * REST stays the source of truth; a dead socket just means pull behavior. + * Returning from the background runs [accessCheck], because a socket that was + * closed while stopped cannot report an access change made in that time. */ class HomeRealtimeForegroundStarter( private val coordinator: HomeRealtimeCoordinator, private val profileRepository: ProfileRepository, + private val accessCheck: ForegroundAccessCheck? = null, ) : DefaultLifecycleObserver { private var realtimeScope: CoroutineScope? = null private var foregroundScope: CoroutineScope? = null + private var stoppedSinceStart = false fun register() { ProcessLifecycleOwner.get().lifecycle.addObserver(this) @@ -34,9 +39,14 @@ class HomeRealtimeForegroundStarter( override fun onStart(owner: LifecycleOwner) { startRealtime() observeProfileSwitches() + if (stoppedSinceStart) { + stoppedSinceStart = false + foregroundScope?.launch { accessCheck?.afterReturnToForeground() } + } } override fun onStop(owner: LifecycleOwner) { + stoppedSinceStart = true stopRealtime() foregroundScope?.cancel() foregroundScope = null diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index fa9862a9ba..95a4320cb8 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -126,6 +126,8 @@ import org.siloserver.silo.common.player.video.VideoPlayerRouteArgs import org.siloserver.silo.common.settings.CardPresentationStore import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.repository.ProfilePromptAction +import org.siloserver.silo.repository.ProfileVerificationRecovery import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -135,6 +137,33 @@ import org.koin.compose.viewmodel.koinViewModel */ private const val DisplayedDetailContentIdKey = "displayedDetailContentId" +/** The profile picker and its screens, where a prompt has nothing to add. */ +private val ProfilePromptPickerRoutes: Set<String> = setOf( + Route.ProfileSelection.route, + Route.CreateProfile.route, + Route.EditProfile.ROUTE, +) + +/** + * Destinations a profile-verification prompt waits behind: playback and reading + * keep going on the session they started with, and the sign-in and server + * screens have no profile in use. + */ +private val ProfilePromptDeferredRoutes: Set<String> = setOf( + Route.Player.ROUTE, + Route.AudiobookPlayer.ROUTE, + Route.BookReader.ROUTE, + Route.SiloCastRemote.route, + Route.Login.route, + Route.ServerSetup.route, + Route.ServerSetupPrefilled.ROUTE, + Route.ServerList.route, + Route.Setup.route, + Route.Signup.route, + Route.PairDevice.ROUTE, + Route.InviteClaim.ROUTE, +) + /** Page-to-page cross-fade duration (ms). Snappier than Compose Nav's 700ms default. */ private const val PageFadeDurationMs = 200 @@ -266,6 +295,55 @@ fun AppNavigation( } } + // The server stopped accepting the active profile's PIN proof (an admin + // changed the account's access). The recovery has already cleared the + // stale profile selection, but not the sign-in; send the user to the + // profile picker to choose a profile and enter its PIN again. Playback and + // the sign-in screens are not interrupted: the prompt waits until the user + // leaves them (or a remote-playback overlay ends), and is dropped after a + // sign-out, a server switch, or a profile pick. + val profileVerificationRecovery: ProfileVerificationRecovery = koinInject() + LaunchedEffect(Unit) { + // promptChecks re-emits when a remote-playback overlay ends, so a + // prompt deferred behind it does not wait for the next navigation. + kotlinx.coroutines.flow.combine( + profileVerificationRecovery.promptChecks, + navController.currentBackStackEntryFlow, + ) { prompt, entry -> prompt to entry.destination.route } + .collect { (prompt, route) -> + if (prompt == null) return@collect + when ( + profileVerificationRecovery.actionFor( + prompt = prompt, + currentRoute = route, + deferRoutes = ProfilePromptDeferredRoutes, + pickerRoutes = ProfilePromptPickerRoutes, + ) + ) { + ProfilePromptAction.Defer -> Unit + // Already on the picker: it reloads itself, and a PIN + // being typed is kept. + ProfilePromptAction.AlreadyThere, + ProfilePromptAction.Drop, + -> profileVerificationRecovery.consume(prompt) + ProfilePromptAction.Navigate -> { + profileVerificationRecovery.consume(prompt) + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + // Same per-profile teardown as Switch Profile, after + // leaving the shell so it does not repaint first. + overlayPrefsStore.clear() + activeProfileStore.reset() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + } + } + } + } + fun navigateExternalRoute(route: String) { // An external link to a TAB (silo://downloads) must switch tabs, // not push a second copy of that tab. A duplicate tab entry also @@ -827,6 +905,23 @@ fun AppNavigation( // ---- Profile selection ---- composable(Route.ProfileSelection.route) { + // Switch Profile opens the picker above Main so Back can cancel it. + // Once profile recovery has cleared the selection (a stale PIN + // proof), Main has no profile to return to and every request there + // would fail, so Back leaves the app, as it does when the picker is + // the first screen. A picker opened from the server list or a + // sign-in screen (adding a server) has no profile yet either; Back + // there still returns to that screen. + val pickerActivity = androidx.activity.compose.LocalActivity.current + val selectedProfileId = serverRegistry.activeEntry.collectAsState().value?.profileId + val previousRoute = navController.previousBackStackEntry?.destination?.route + androidx.activity.compose.BackHandler( + enabled = selectedProfileId.isNullOrBlank() && + previousRoute != null && + previousRoute !in ProfilePromptDeferredRoutes, + ) { + pickerActivity?.finish() + } ProfileSelectionScreen( onNavigateToHome = { // The switch-profile paths dropped the per-profile card diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt index e8927917bc..9d6817f7bb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt @@ -83,6 +83,9 @@ import org.siloserver.silo.viewmodel.HomeViewModel import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.common.ui.rememberViewerAccessKey +import org.siloserver.silo.network.AccessChangeSignals /** * Main scaffold that hosts the bottom navigation bar and tab content. @@ -173,6 +176,15 @@ fun MainScreen( LaunchedEffect(activeEntry?.id, activeEntry?.profileId) { headerViewModel.refresh() } + // An access change can add or remove whole libraries: re-derive the tabs + // and reload the Libraries hub under the new policy. The hub's ViewModel + // outlives tab switches and pushed routes, so its cursor catches up on a + // change reported while another tab or route was showing. + val accessChangeSignals: AccessChangeSignals = koinInject() + val viewerAccessKey = rememberViewerAccessKey(accessChangeSignals) + librariesViewModel?.let { viewModel -> + OnViewerAccessChanged(accessChangeSignals, viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } + } val mediaCapabilities by produceState( initialValue = MediaModeCapabilities( listOf( @@ -182,6 +194,7 @@ fun MainScreen( ), ), personalDataRepository, + viewerAccessKey, ) { value = when (val result = personalDataRepository.listUserLibraries()) { is ApiResult.Success -> result.data.mobileMediaModeCapabilities() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionsScreen.kt index f03e53c0db..84e9e8d9bd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/CollectionsScreen.kt @@ -31,7 +31,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals import org.siloserver.silo.android.ui.components.EmptyStateView import org.siloserver.silo.android.ui.components.ErrorView import org.siloserver.silo.android.ui.components.LoadingIndicator @@ -48,6 +51,7 @@ fun CollectionsScreen( viewModel: CollectionsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refresh() } Scaffold( topBar = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/LibraryCollectionsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/LibraryCollectionsScreen.kt index 00bbc84d88..973b83f838 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/LibraryCollectionsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/collections/LibraryCollectionsScreen.kt @@ -56,7 +56,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals /** * One named or anonymous section as it should appear on screen, in the @@ -81,10 +84,17 @@ class LibraryCollectionsViewModel( private val sectionRepository: SectionRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() + private val libraryId: Int? = savedStateHandle.get<String>("libraryId")?.toIntOrNull() private val _uiState = MutableStateFlow(LibraryCollectionsUiState()) val uiState: StateFlow<LibraryCollectionsUiState> = _uiState.asStateFlow() + // A newer load or refresh supersedes an unfinished one, so an answer from + // before an access change can't land after the refresh it triggered. + private var loadJob: kotlinx.coroutines.Job? = null + init { loadCollections() } @@ -101,7 +111,8 @@ class LibraryCollectionsViewModel( return } - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { _uiState.update { it.copy( isLoading = true, @@ -115,7 +126,8 @@ class LibraryCollectionsViewModel( fun refresh() { val currentLibraryId = libraryId ?: return - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true, error = null) } applyResult(sectionRepository.getLibraryCollectionsGrouped(currentLibraryId)) } @@ -199,6 +211,7 @@ fun LibraryCollectionsScreen( viewModel: LibraryCollectionsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refresh() } androidx.compose.material3.Scaffold( topBar = { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index f3941f2bf6..e2b89a14a2 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -86,6 +86,8 @@ import org.siloserver.silo.model.download.labelFor import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.network.ServerRegistry import org.koin.compose.koinInject +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals import org.siloserver.silo.metadata.DescriptionTranslationPhase import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.metadata.MetadataAiOnView @@ -186,6 +188,10 @@ fun ItemDetailScreen( // effect-local "skip the first" flag would reset and swallow exactly the // resume we care about. refreshOnReturn() no-ops while detail is still // null, which covers the initial load. + // The same quiet refresh when the server reports an access change, so + // availability, versions, and quality limits follow the new policy; a + // title the viewer can no longer see shows the unavailable state. + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt index e4643cc816..20a201fcdc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt @@ -16,9 +16,11 @@ import org.siloserver.silo.model.catalog.initialSeasonDisplayPlan import org.siloserver.silo.model.catalog.sortedForDisplay import org.siloserver.silo.model.download.DownloadCapability import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadStatus import org.siloserver.silo.model.download.statusEnum import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.errorMessage +import org.siloserver.silo.network.isAccessRefusal import org.siloserver.silo.model.catalog.isBookLikeItemType import org.siloserver.silo.metadata.DescriptionTranslationController import org.siloserver.silo.metadata.DescriptionTranslationPhase @@ -143,9 +145,15 @@ class ItemDetailViewModel( private val userItemState: org.siloserver.silo.repository.port.UserItemStatePort = org.siloserver.silo.repository.port.NoOpUserItemStatePort, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() private var similarGeneration = 0L private var similarJob: kotlinx.coroutines.Job? = null + private var detailLoadJob: Job? = null + private var quietDetailJob: Job? = null + /** Whether [quietDetailJob] clears the page on a refusal (an access-change refresh). */ + private var quietDetailShowsRefusal = false private val libraryId: Int? = savedStateHandle.get<String>("libraryId")?.toIntOrNull() private val contentId: String = savedStateHandle.get<String>("contentId") ?: "" private val initialSeasonNumber: Int? = @@ -387,17 +395,29 @@ class ItemDetailViewModel( } } - fun loadDetail() { + fun loadDetail() = loadDetail(afterAccessChange = false) + + /** + * [afterAccessChange] skips joining a Home warm-up still in flight, whose + * answer may predate the change. + */ + private fun loadDetail(afterAccessChange: Boolean) { val similarRun = ++similarGeneration similarJob?.cancel() + // A newer load replaces an unfinished one, so a response the server + // gave under an older access policy never lands after it. + detailLoadJob?.cancel() + quietDetailJob?.cancel() _uiState.update { it.copy(similarItems = emptyList()) } - viewModelScope.launch { + detailLoadJob = viewModelScope.launch { val similarOwner = recommendationRepository.captureSimilarAuthority() _uiState.update { it.copy(isLoading = true, error = null) } // Start the live request immediately. The durable cache read can // still paint an instant first frame, but it no longer delays the // network request that supplies fresh movie/series metadata. - val liveDetail = async { catalogRepository.getItemDetail(contentId, libraryId = libraryId) } + val liveDetail = async { + catalogRepository.getItemDetail(contentId, libraryId = libraryId, joinWarmup = !afterAccessChange) + } seedCachedDetail() when (val result = liveDetail.await()) { @@ -446,6 +466,10 @@ class ItemDetailViewModel( _uiState.update { it.copy( isLoading = false, + // The durable cache still holds a title the server + // now refuses; drop the copy seedCachedDetail painted + // so the screen shows the error, not stale actions. + detail = if (dropsDetailOn(result)) null else it.detail, error = result.message.ifBlank { "Failed to load details" }, ) } @@ -476,23 +500,67 @@ class ItemDetailViewModel( * Deliberately NOT [loadDetail] — no loading flashes, and the user's * season selection is preserved. */ - fun refreshOnReturn() = refreshOnReturn(afterWatchedChange = false) + fun refreshOnReturn() = quietRefresh() + + /** + * [refreshOnReturn] after the server reports an access change, except that + * a refusal ([isAccessRefusal]) replaces the detail with the error the + * initial load shows. Transient failures still keep the current detail. + * With no detail on screen (an earlier change refused it, or the first + * load failed), it runs the full load again, as Retry does, so a title the + * viewer regains access to comes back. A load still in flight is replaced + * the same way, because its answer may predate the change. + */ + fun refreshAfterAccessChange() { + val state = _uiState.value + // A load still in flight may have been answered under the old policy: + // replace it rather than letting the change pass unapplied. + if (state.isLoading || state.detail == null) { + loadDetail(afterAccessChange = true) + } else { + quietRefresh(showAccessRefusal = true) + } + } + + /** + * Whether a failed read should clear the page: an access refusal, unless + * the title has a completed download. The page's local play and delete + * actions stay usable for a file the server has since deleted or renamed. + */ + private fun dropsDetailOn(result: ApiResult.Error): Boolean = + result.isAccessRefusal() && downloads.value.none { record -> + record.statusEnum() == DownloadStatus.Completed && + (record.contentId == contentId || record.episodeId == contentId) + } /** * [afterWatchedChange] reads the season list and episodes fresh from the * server: a coalesced request or cached fallback could still hold the * state from before the write. + * [showAccessRefusal]: see [refreshAfterAccessChange]. */ - private fun refreshOnReturn(afterWatchedChange: Boolean) { + private fun quietRefresh(afterWatchedChange: Boolean = false, showAccessRefusal: Boolean = false) { val current = _uiState.value.detail ?: return - viewModelScope.launch { + // A newer detail read replaces an unfinished one, so an older answer + // cannot land after it, for example restoring a title the newer read + // found refused. The replacement inherits an access-change refresh's + // refusal handling, so a return refresh cannot drop it. + val showRefusal = showAccessRefusal || (quietDetailShowsRefusal && quietDetailJob?.isActive == true) + quietDetailJob?.cancel() + quietDetailShowsRefusal = showRefusal + quietDetailJob = viewModelScope.launch { // Local overlay first: the player's final position write is already // on disk, so the label corrects before the server round-trip. val overlaid = withLocalProgress(current) if (overlaid != current) { _uiState.update { it.copy(detail = overlaid) } } - when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId)) { + when (val result = catalogRepository.getItemDetail( + contentId, + libraryId = libraryId, + // An access-change refresh must not reuse a pre-change warm-up. + joinWarmup = !showRefusal, + )) { is ApiResult.Success -> { val detail = withLocalProgress(result.data) _uiState.update { @@ -502,8 +570,16 @@ class ItemDetailViewModel( ) } } + is ApiResult.Error -> if (showRefusal && dropsDetailOn(result)) { + _uiState.update { + it.copy( + isLoading = false, + detail = null, + error = result.message.ifBlank { "Failed to load details" }, + ) + } + } // Quiet refresh: on failure keep showing what we have. - is ApiResult.Error, is ApiResult.NetworkError -> Unit } } @@ -1380,7 +1456,7 @@ class ItemDetailViewModel( ) } // Re-reads the series hero, the season list, and the visible episodes. - refreshOnReturn(afterWatchedChange = true) + quietRefresh(afterWatchedChange = true) } /** diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index 1eb50000a3..a048479043 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -221,6 +221,9 @@ class LibrariesViewModel( private val playerSettingsStore: org.siloserver.silo.common.settings.PlayerSettingsStore? = null, private val browsePrefs: BrowsePrefsStore? = null, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() + private val _uiState = MutableStateFlow(LibrariesUiState()) val uiState: StateFlow<LibrariesUiState> = _uiState.asStateFlow() // iOS AppNavPreferences.showAudiobooks parity: audiobook libraries are @@ -395,6 +398,24 @@ class LibrariesViewModel( } } + /** + * The server reported an access change: the library list, sections, + * titles, facets, and collections may all differ under the new policy. + * Forget which subtabs are loaded and the cached facets, then reload the + * library list and the visible subtab; the others reload when opened. + */ + fun refreshAfterAccessChange() { + recommendedLoadedLibraryId = null + browseLoadedLibraryId = null + collectionsLoadedLibraryId = null + // A facet load still in flight was answered under the old policy; + // without a new query generation it would refill the facets cleared + // here, and Browse would not fetch them again. + ++catalogQueryGeneration + _uiState.update { it.copy(availableFilters = null) } + refresh() + } + fun selectLibrary(libraryId: Int) { if (_uiState.value.selectedLibraryId == libraryId) return val library = _uiState.value.libraries.firstOrNull { it.id == libraryId } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt index a63cb990d2..3d8bffacd3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt @@ -11,6 +11,7 @@ import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.ProfileCommitResult import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.repository.ProfileVerificationRecovery import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -51,6 +52,7 @@ class ProfileSelectionViewModel( private val profileRepository: ProfileRepository, private val authRepository: AuthRepository? = null, private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, + private val profileVerificationRecovery: ProfileVerificationRecovery? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileSelectionUiState()) @@ -74,9 +76,22 @@ class ProfileSelectionViewModel( init { loadProfiles() reloadWhenUpdateRequiredLifts() + reloadWhenStaleProfileCleared() leaveManageModeWhenSessionEnds() } + /** + * A load sent with a profile token the server no longer accepts fails with + * `profile_verification_required`. The recovery then clears that profile, + * and the grid reloads without it instead of leaving the error up. + */ + private fun reloadWhenStaleProfileCleared() { + val recovery = profileVerificationRecovery ?: return + viewModelScope.launch { + recovery.profileCleared.collect { loadProfiles() } + } + } + /** Cancel on a re-prompt for the primary's PIN ends the session; manage mode goes with it. */ private fun leaveManageModeWhenSessionEnds() { viewModelScope.launch { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt index 9a990823cc..1929c2cca4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchScreen.kt @@ -41,6 +41,9 @@ import org.siloserver.silo.model.feature.RequestsFeatureStore import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.PersonalDataRepository import org.koin.compose.koinInject +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.common.ui.rememberViewerAccessKey +import org.siloserver.silo.network.AccessChangeSignals /** * The search screen with a search bar and results grid. @@ -69,6 +72,11 @@ fun SearchScreen( val personalDataRepository: PersonalDataRepository = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() + // An access change can add or remove libraries and titles: re-derive the + // media modes and re-run the current query under the new policy. + val accessChangeSignals: AccessChangeSignals = koinInject() + val viewerAccessKey = rememberViewerAccessKey(accessChangeSignals) + OnViewerAccessChanged(accessChangeSignals, viewModel.accessChanges) { viewModel.retry() } val availableModes by produceState( initialValue = MediaModeCapabilities( listOf( @@ -78,6 +86,7 @@ fun SearchScreen( ), ).mobileModes(), personalDataRepository, + viewerAccessKey, ) { value = when (val result = personalDataRepository.listUserLibraries()) { is ApiResult.Success -> result.data.mobileMediaModeCapabilities().mobileModes() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchViewModel.kt index 9fc67f4418..8ed5701649 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchViewModel.kt @@ -128,6 +128,8 @@ data class SearchUiState( class SearchViewModel( private val catalogRepository: CatalogRepository, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() private val _uiState = MutableStateFlow(SearchUiState()) val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow() @@ -136,6 +138,7 @@ class SearchViewModel( private var continuation: CatalogContinuationV2? = null private var peopleJob: Job? = null + private var searchGeneration = 0L private val pageSize = 60 /** @@ -304,6 +307,10 @@ class SearchViewModel( } private suspend fun performSearch(query: String, reset: Boolean) { + // A new search makes every earlier one's answer obsolete, even for the + // same query: a re-run after an access change must not be overwritten + // by the older request's results. Loading more continues the newest. + val generation = if (reset) ++searchGeneration else searchGeneration val currentState = _uiState.value val requestedMediaType = currentState.mediaType var offset = if (reset) 0 else currentState.nextOffset @@ -325,7 +332,7 @@ class SearchViewModel( limit = pageSize, ) val latest = _uiState.value - if (latest.query != query || latest.mediaType != requestedMediaType) return + if (generation != searchGeneration || latest.query != query || latest.mediaType != requestedMediaType) return when (result) { is ApiResult.Success -> { diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt index 85bd88318e..f865415da5 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt @@ -5,12 +5,14 @@ import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.LeafItemUserData import org.siloserver.silo.model.catalog.Season +import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadsListResponse import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.repository.port.CatalogCachePort import org.siloserver.silo.repository.port.NoOpUserItemStatePort import org.siloserver.silo.repository.port.PersonalWrite import org.siloserver.silo.repository.port.PersonalWriteHandle @@ -489,6 +491,208 @@ class MobileDetailActionsTest { assertFalse(state.hasExplicitSubtitleSelection) } + @Test + fun accessChangeReloadsARefusedTitleOnceAccessReturns() = runItemDetailTest { + var available = true + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository(available = { available }), + contentId = "movie-1", + ) + advanceUntilIdle() + assertEquals("movie-1", viewModel.uiState.value.detail?.contentId) + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.detail) + + available = true + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals("movie-1", viewModel.uiState.value.detail?.contentId) + assertEquals(null, viewModel.uiState.value.error) + } + + @Test + fun accessChangeOnAStillRefusedTitleDoesNotRepaintItsCachedDetail() = runItemDetailTest { + var available = true + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { available }, + cached = ItemDetail(contentId = "movie-1", type = "movie", title = "Cached"), + ), + contentId = "movie-1", + ) + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + // A second, unrelated access change runs the full load, which paints + // the durable cached copy before the live request is refused again. + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun accessChangeDuringTheFirstLoadReplacesIt() = runItemDetailTest { + var available = true + val firstResponse = CompletableDeferred<Unit>() + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository(available = { available }, holdFirst = firstResponse), + contentId = "movie-1", + ) + // The first request is answered under the old policy but held back. + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isLoading) + + available = false + viewModel.refreshAfterAccessChange() + firstResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun aRefusedTitleWithACompletedDownloadKeepsItsCachedDetail() = runItemDetailTest { + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { false }, + cached = ItemDetail(contentId = "movie-1", type = "movie", title = "Cached"), + ), + contentId = "movie-1", + downloads = listOf( + DownloadRecord( + id = "download-1", + contentId = "movie-1", + mediaFileId = 7, + kind = "original", + status = "completed", + createdAt = "2026-01-01T00:00:00Z", + ), + ), + ) + advanceUntilIdle() + + // The server deleted the title; its local play and delete actions stay. + assertEquals("movie-1", viewModel.uiState.value.detail?.contentId) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun anOlderQuietRefreshDoesNotRestoreATitleAnAccessChangeRefused() = runItemDetailTest { + var available = true + val returnResponse = CompletableDeferred<Unit>() + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { available }, + holdFirst = returnResponse, + holdRequest = 2, + ), + contentId = "movie-1", + ) + advanceUntilIdle() + // Back from the player: this read is answered under the old policy but held back. + viewModel.refreshOnReturn() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + returnResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun aQuietRefreshReplacingAnAccessChangeRefreshStillShowsTheRefusal() = runItemDetailTest { + var available = true + val accessResponse = CompletableDeferred<Unit>() + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = switchableCatalogRepository( + available = { available }, + holdFirst = accessResponse, + holdRequest = 2, + ), + contentId = "movie-1", + ) + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + // A return refresh replaces the unfinished access-change read. + viewModel.refreshOnReturn() + advanceUntilIdle() + accessResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + /** + * Serves `movie-1` while [available] is true and a 404 problem otherwise. + * [cached] stands in for the durable detail cache, which a refusal does + * not evict. With [holdFirst], detail request number [holdRequest] reads + * [available] when it arrives but answers only once [holdFirst] completes. + */ + private fun kotlinx.coroutines.test.TestScope.switchableCatalogRepository( + available: () -> Boolean, + cached: ItemDetail? = null, + holdFirst: CompletableDeferred<Unit>? = null, + holdRequest: Int = 1, + ): CatalogRepository { + val dispatcher = StandardTestDispatcher(testScheduler) + var detailRequests = 0 + val client = HttpClient( + MockEngine( + MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val isDetail = request.url.encodedPath == "/api/v2/catalog/items/movie-1" + val allowed = isDetail && available() + if (isDetail && ++detailRequests == holdRequest) holdFirst?.await() + when { + !isDetail -> respond("{}") + allowed -> respond( + """{"content_id":"movie-1","type":"movie","title":"Movie","cast":[],"crew":[],"versions":[],"subtitles":[]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond( + """{"type":"about:blank","title":"not_found","status":404,"detail":"Item not found"}""", + HttpStatusCode.NotFound, + headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + } + } + }, + ), + ) { install(ContentNegotiation) { json(SiloJson) } } + return CatalogRepository( + CatalogApi(client), + catalogCache = object : CatalogCachePort { + override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = cached + }, + requestDispatcher = dispatcher, + ) + } + private val viewModels = mutableListOf<ViewModel>() /** @@ -528,12 +732,13 @@ class MobileDetailActionsTest { personalDataRepository: RecordingPersonalDataRepository, catalogRepository: CatalogRepository? = null, contentId: String? = null, + downloads: List<DownloadRecord> = emptyList(), ): ItemDetailViewModel { val dispatcher = StandardTestDispatcher(testScheduler) return ItemDetailViewModel( catalogRepository = catalogRepository ?: CatalogRepository(CatalogApi(dummyHttpClient(dispatcher))), personalDataRepository = personalDataRepository, - downloadsRepository = DownloadsRepository(EmptyDownloadsApi(dispatcher)), + downloadsRepository = DownloadsRepository(EmptyDownloadsApi(dispatcher, downloads)), downloadEnqueuer = unsafeInstance(), ebookReaderRepository = dummyEbookReaderRepository(dispatcher), recommendationRepository = RecommendationRepository(RecommendationApi(dummyHttpClient(dispatcher))), @@ -686,14 +891,17 @@ class MobileDetailActionsTest { override suspend fun current(): org.siloserver.silo.network.SiloDeviceMetadata? = null } - private class EmptyDownloadsApi(dispatcher: CoroutineDispatcher) : DownloadsApi( + private class EmptyDownloadsApi( + dispatcher: CoroutineDispatcher, + private val records: List<DownloadRecord> = emptyList(), + ) : DownloadsApi( registry = org.siloserver.silo.network.apiv2.DownloadRegistryV2Api(dummyHttpClient(dispatcher), org.siloserver.silo.network.TokenManagerImpl(), NoDevices, org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted), tokens = org.siloserver.silo.network.TokenManagerImpl(), creation = org.siloserver.silo.network.apiv2.DownloadCreationV2Api(dummyHttpClient(dispatcher), org.siloserver.silo.network.TokenManagerImpl(), NoDevices, org.siloserver.silo.network.apiv2.DownloadRegistryV2Api(dummyHttpClient(dispatcher), org.siloserver.silo.network.TokenManagerImpl(), NoDevices, org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted), org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted), ) { override suspend fun list(scope: org.siloserver.silo.network.AuthScopeSnapshot?): ApiResult<DownloadsListResponse> = - ApiResult.Success(DownloadsListResponse()) + ApiResult.Success(DownloadsListResponse(downloads = records)) } private fun dummyEbookReaderRepository(dispatcher: CoroutineDispatcher): EbookReaderRepository { diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt index 337bbe05bd..0d5a707438 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -665,6 +665,39 @@ class LibrariesViewModelTest { } } + @Test + fun accessChangeReloadsSubtabsThatWereAlreadyLoaded() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("filters") + fixture.awaitRequest("catalog:1:added_at:desc") + viewModel.uiState.first { it.catalogItems.isNotEmpty() && it.availableFilters != null } + viewModel.selectTab(LibrariesSubtab.Recommended) + + viewModel.refreshAfterAccessChange() + fixture.awaitRequest("libraries") + fixture.awaitRequest("sections:1") + + // Browse was loaded under the old policy: opening it again must + // refetch its titles and facets instead of returning early (the + // request never arrives without the fix, and runTest times out). + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("filters") + fixture.awaitRequest("catalog:1:added_at:desc") + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + private suspend fun LibrariesViewModel.onlyActiveRequest(): Job = withTimeout(5_000) { while (true) { val activeRequests = viewModelScope.coroutineContext[Job] diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt index cd39d37d48..291d61a8d4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/SiloTvApplication.kt @@ -43,12 +43,22 @@ class SiloTvApplication : Application(), Configuration.Provider, SingletonImageL koinApp.koin.get<org.siloserver.silo.repository.ImageCapabilitiesSession>().start( kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.IO), ) + // Profile-verification recovery: clears a profile whose PIN token the + // server stopped accepting (after an access change), including when + // background work hits it first. Guarded — never load-bearing. + runCatching { + koinApp.koin.get<org.siloserver.silo.repository.ProfileVerificationRecovery>() + }.onFailure { + // Class name only: the exception chain can carry auth or server data. + android.util.Log.w("SiloTvApplication", "Profile verification recovery init failed: ${it::class.simpleName}") + } // Live-home socket (Apple realtime-updates spec). Guarded — a dead // socket just means Home refreshes on open only. runCatching { org.siloserver.silo.tv.home.HomeRealtimeForegroundStarter( coordinator = koinApp.koin.get(), profileRepository = koinApp.koin.get(), + accessCheck = koinApp.koin.getOrNull(), ).register() }.onFailure { android.util.Log.w("SiloTvApplication", "Home realtime starter init failed", it) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 4ca793e4ce..ae0ebf5a31 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -426,7 +426,7 @@ val androidTvModule = module { advertisesSignIn = BuildConfig.DEBUG, ) } - viewModel { TvProfileSelectionViewModel(get(), get()) } + viewModel { TvProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull()) } viewModel { org.siloserver.silo.tv.ui.screens.profiles.TvCreateProfileViewModel(get()) } viewModel { params -> org.siloserver.silo.tv.ui.screens.profiles.TvEditProfileViewModel( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/home/HomeRealtimeForegroundStarter.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/home/HomeRealtimeForegroundStarter.kt index 2f626c424f..c9c59e8f37 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/home/HomeRealtimeForegroundStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/home/HomeRealtimeForegroundStarter.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import org.siloserver.silo.repository.ForegroundAccessCheck import org.siloserver.silo.repository.HomeRealtimeCoordinator import org.siloserver.silo.repository.ProfileRepository @@ -18,14 +19,18 @@ import org.siloserver.silo.repository.ProfileRepository * (ProfileRepository.profileSwitches) * reconnects so the coordinator's profile filter tracks the new session. * REST stays the source of truth; a dead socket just means pull behavior. + * Returning from the background runs [accessCheck], because a socket that was + * closed while stopped cannot report an access change made in that time. */ class HomeRealtimeForegroundStarter( private val coordinator: HomeRealtimeCoordinator, private val profileRepository: ProfileRepository, + private val accessCheck: ForegroundAccessCheck? = null, ) : DefaultLifecycleObserver { private var realtimeScope: CoroutineScope? = null private var foregroundScope: CoroutineScope? = null + private var stoppedSinceStart = false fun register() { ProcessLifecycleOwner.get().lifecycle.addObserver(this) @@ -34,9 +39,14 @@ class HomeRealtimeForegroundStarter( override fun onStart(owner: LifecycleOwner) { startRealtime() observeProfileSwitches() + if (stoppedSinceStart) { + stoppedSinceStart = false + foregroundScope?.launch { accessCheck?.afterReturnToForeground() } + } } override fun onStop(owner: LifecycleOwner) { + stoppedSinceStart = true stopRealtime() foregroundScope?.cancel() foregroundScope = null diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 5ad7756b61..2e048a9029 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -31,7 +31,9 @@ import androidx.navigation.navArgument import org.siloserver.silo.common.player.video.VideoPlayerRouteArgs import org.siloserver.silo.network.TokenManager import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.ProfilePromptAction import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.repository.ProfileVerificationRecovery import org.siloserver.silo.tv.MainTvActivity import org.siloserver.silo.tv.ui.components.TvSelectToShowImeHost import org.siloserver.silo.tv.ui.shell.TvMainShell @@ -424,6 +426,29 @@ private val tvMarqueeRoutes: Set<String> = setOf( TvRoute.ProfileSelection.route, ) +/** The profile picker and its screens, where a prompt has nothing to add. */ +private val TvProfilePromptPickerRoutes: Set<String> = setOf( + TvRoute.ProfileSelection.route, + TvRoute.CreateProfile.route, + TvRoute.EditProfile.ROUTE, +) + +/** + * Destinations a profile-verification prompt waits behind: playback keeps + * going on the session it started with, and the sign-in and server screens + * have no profile in use. + */ +private val TvProfilePromptDeferredRoutes: Set<String> = setOf( + TvRoute.Player.ROUTE, + TvRoute.AudiobookPlayer.ROUTE, + TvRoute.ServerSetup.route, + TvRoute.Setup.route, + TvRoute.Signup.route, + TvRoute.Login.ROUTE, + TvRoute.ServerList.route, + TvRoute.PairDevice.ROUTE, +) + /** * Page-to-page cross-fade duration (ms). A middle ground between Compose Nav's * sluggish 700ms default and a phone-snappy 200ms — a touch more deliberate for @@ -750,6 +775,56 @@ fun TvAppNavigation( } } + // The server stopped accepting the active profile's PIN proof (an admin + // changed the account's access). The recovery has already cleared the + // stale profile selection, but not the sign-in; send the viewer to the + // profile picker to choose a profile and enter its PIN again. Playback and + // the sign-in screens are not interrupted: the prompt waits until the + // viewer leaves them (or a remote-playback overlay ends), and is dropped + // after a sign-out, a server switch, or a profile pick. + val profileVerificationRecovery: ProfileVerificationRecovery = koinInject() + LaunchedEffect(Unit) { + // promptChecks re-emits when a remote-playback overlay ends, so a + // prompt deferred behind it does not wait for the next navigation. + kotlinx.coroutines.flow.combine( + profileVerificationRecovery.promptChecks, + navController.currentBackStackEntryFlow, + ) { prompt, entry -> prompt to entry.destination.route } + .collect { (prompt, route) -> + if (prompt == null) return@collect + when ( + profileVerificationRecovery.actionFor( + prompt = prompt, + currentRoute = route, + deferRoutes = TvProfilePromptDeferredRoutes, + pickerRoutes = TvProfilePromptPickerRoutes, + ) + ) { + ProfilePromptAction.Defer -> Unit + // Already on the picker: it reloads itself, and a PIN + // being typed is kept. + ProfilePromptAction.AlreadyThere, + ProfilePromptAction.Drop, + -> profileVerificationRecovery.consume(prompt) + ProfilePromptAction.Navigate -> { + profileVerificationRecovery.consume(prompt) + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + // Same per-profile teardown as Switch Profile; the + // picked profile re-seeds Watch Next on selection. + libraryPlaybackPrefsStore.clear() + overlayPrefsStore.clear() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + watchNextSeeder.clear() + } + } + } + } + // Re-read the authenticated profile id whenever the destination changes // (Login → ProfileSelection → Main). Drives card-overlay hydration off the // authenticated identity instead of a one-shot at app start, where the user diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionsScreen.kt index 08fa8783b0..7e46924ca9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/collections/TvCollectionsScreen.kt @@ -41,7 +41,10 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals import org.siloserver.silo.model.personal.Collection import org.siloserver.silo.tv.ui.focus.rememberTvContentInitialFocus import org.siloserver.silo.tv.ui.components.TvErrorScreen @@ -60,6 +63,7 @@ fun TvCollectionsScreen( viewModel: CollectionsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refresh() } val firstCollectionFocusRequester = remember { FocusRequester() } val firstCollectionId = state.sections.firstNotNullOfOrNull { it.collections.firstOrNull()?.id } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index 04714fd6bd..155779dcb7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -99,6 +99,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import org.siloserver.silo.audiobook.AudioPlaybackTrack @@ -266,6 +268,10 @@ fun TvItemDetailScreen( // any effect-local "skip the first" flag would reset and swallow exactly // the resume we care about. refreshOnReturn() no-ops while detail is still // null, which covers the initial load. + // The same quiet refresh when the server reports an access change, so + // availability, versions, and quality limits follow the new policy; a + // title the viewer can no longer see shows the unavailable state. + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt index fa577c66b6..fca351e896 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -39,6 +39,7 @@ import org.siloserver.silo.playback.resolveSubtitleTrackOrdinal import org.siloserver.silo.playback.subtitleTrackFingerprint import org.siloserver.silo.model.section.SectionItem import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.isAccessRefusal import org.siloserver.silo.network.IdentityTransitionBarrier import org.siloserver.silo.network.IdentityTransitionPhase import org.siloserver.silo.network.TokenManager @@ -373,6 +374,9 @@ class TvItemDetailViewModel( private val identityTransitions: IdentityTransitionBarrier, private val capabilityDetector: PlaybackCapabilityDetector? = null, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() + private var similarGeneration = 0L private val _uiState = MutableStateFlow(TvItemDetailUiState()) @@ -517,7 +521,8 @@ class TvItemDetailViewModel( } } - fun loadAll() { + /** [afterAccessChange] is passed to [loadDetail]. */ + fun loadAll(afterAccessChange: Boolean = false) { viewModelScope.launch { runCatching { playerSettingsStore.refreshFromServer() } } @@ -529,7 +534,7 @@ class TvItemDetailViewModel( // the detail must succeed before we render, but favorite/watchlist // state can trickle in afterward. loadUserState() - loadDetail() + loadDetail(afterAccessChange) } } @@ -600,13 +605,21 @@ class TvItemDetailViewModel( return true } - private fun loadDetail() { + /** + * [afterAccessChange] skips joining a Home warm-up still in flight, whose + * answer may predate the change. + */ + private fun loadDetail(afterAccessChange: Boolean = false) { val similarRun = ++similarGeneration moreLikeThisJob?.cancel() + // A newer load replaces an unfinished one, so a response the server + // gave under an older access policy never lands after it. + detailLoadJob?.cancel() + quietDetailJob?.cancel() _uiState.update { it.copy(moreLikeThis = emptyList(), moreLikeThisLoading = false) } - viewModelScope.launch { + detailLoadJob = viewModelScope.launch { val similarOwner = recommendationRepository?.captureSimilarAuthority() - when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId)) { + when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId, joinWarmup = !afterAccessChange)) { is ApiResult.Success -> { val detail = withLocalProgress(result.data) if (isTvHiddenMediaType(detail.type)) { @@ -655,6 +668,10 @@ class TvItemDetailViewModel( is ApiResult.Error -> _uiState.update { it.copy( isLoading = false, + // The durable cache still holds a title the server now + // refuses; drop the copy seedCachedDetail painted so the + // screen shows the error, not stale actions. + detail = if (result.isAccessRefusal()) null else it.detail, error = result.message.ifBlank { "Failed to load details" }, ) } @@ -687,17 +704,38 @@ class TvItemDetailViewModel( * ended. Deliberately NOT [loadAll] — no loading flashes, and the user's * season selection is preserved. */ - fun refreshOnReturn() = refreshOnReturn(afterWatchedChange = false) + fun refreshOnReturn() = quietRefresh() + + /** + * [refreshOnReturn] after the server reports an access change, except that + * a refusal ([isAccessRefusal]) replaces the detail with the error the + * initial load shows. Transient failures still keep the current detail. + * With no detail on screen (an earlier change refused it, or the first + * load failed), it runs the full load again, as Retry does, so a title the + * viewer regains access to comes back. A load still in flight is replaced + * the same way, because its answer may predate the change. + */ + fun refreshAfterAccessChange() { + val state = _uiState.value + when { + // A load still in flight may have been answered under the old + // policy: replace its request rather than letting the change pass. + state.isLoading -> loadDetail(afterAccessChange = true) + state.detail == null -> loadAll(afterAccessChange = true) + else -> quietRefresh(showAccessRefusal = true) + } + } /** * [afterWatchedChange] reads the season list and episodes fresh from the * server and skips repainting the rail from the on-disk catalog cache: * the cache, and any coalesced request, still hold the pre-write state. + * [showAccessRefusal]: see [refreshAfterAccessChange]. */ - private fun refreshOnReturn(afterWatchedChange: Boolean) { + private fun quietRefresh(afterWatchedChange: Boolean = false, showAccessRefusal: Boolean = false) { val current = _uiState.value.detail ?: return - val playbackReturn = TvDetailTrackSelectionSession.consumePlaybackReturn(contentId) - playbackReturn?.let { saved -> + val consumedPlaybackReturn = TvDetailTrackSelectionSession.consumePlaybackReturn(contentId) + consumedPlaybackReturn?.let { saved -> _uiState.update { val returnedDetail = it.detail?.withPlaybackReturn(saved) val resolvedFileId = returnedDetail?.let { detail -> @@ -712,7 +750,20 @@ class TvItemDetailViewModel( ) } } - viewModelScope.launch { + // A newer detail read replaces an unfinished one, so an older answer + // cannot land after it, for example restoring a title the newer read + // found refused. The replacement inherits an access-change refresh's + // refusal handling, so a return refresh cannot drop it. + val replacing = quietDetailJob?.isActive == true + val showRefusal = showAccessRefusal || (replacing && quietDetailShowsRefusal) + // The replaced read may still owe the player's saved position to its + // answer, unless a watched change has since replaced that position. + val playbackReturn = consumedPlaybackReturn + ?: quietPlaybackReturn.takeIf { replacing && !afterWatchedChange } + quietDetailJob?.cancel() + quietDetailShowsRefusal = showRefusal + quietPlaybackReturn = playbackReturn + quietDetailJob = viewModelScope.launch { // Local overlay first: the player's final position write is already // on disk, so the label corrects before the server round-trip. val overlaid = withLocalProgress(current) @@ -720,7 +771,12 @@ class TvItemDetailViewModel( if (overlaid != current) { _uiState.update { it.copy(detail = overlaid) } } - when (val result = catalogRepository.getItemDetail(contentId, libraryId = libraryId)) { + when (val result = catalogRepository.getItemDetail( + contentId, + libraryId = libraryId, + // An access-change refresh must not reuse a pre-change warm-up. + joinWarmup = !showRefusal, + )) { is ApiResult.Success -> { val detail = withLocalProgress(result.data) .let { refreshed -> playbackReturn?.let(refreshed::withPlaybackReturn) ?: refreshed } @@ -734,8 +790,16 @@ class TvItemDetailViewModel( } } } + is ApiResult.Error -> if (showRefusal && result.isAccessRefusal()) { + _uiState.update { + it.copy( + isLoading = false, + detail = null, + error = result.message.ifBlank { "Failed to load details" }, + ) + } + } // Quiet refresh: on failure keep showing what we have. - is ApiResult.Error, is ApiResult.NetworkError -> Unit } } @@ -860,7 +924,7 @@ class TvItemDetailViewModel( } // Re-read server-resolved state (including series/season episode // resolution) without flashing the full detail loading screen. - refreshOnReturn(afterWatchedChange = isSeries) + quietRefresh(afterWatchedChange = isSeries) if (isSeries && previousDetail != null) { // Seasons outside the carousel are read after the visible refresh starts. val loaded = (_uiState.value.carouselEpisodes + _uiState.value.episodes) @@ -933,7 +997,7 @@ class TvItemDetailViewModel( val episodeIds = known.ifEmpty { seasonEpisodeIds(detail.contentId, seasonNumber) } userItemState.clearLocalPlaybackProgressBefore(episodeIds, admittedAtMs, writeIntent.identityGeneration) // Re-reads the series hero, the season list, and the selected season's episodes. - refreshOnReturn(afterWatchedChange = true) + quietRefresh(afterWatchedChange = true) return@launch } if (seriesWatchMutationGeneration != seriesGenerationAtStart && @@ -941,7 +1005,7 @@ class TvItemDetailViewModel( ) { // A series write since this one began also covers this season; // the snapshot is stale, so re-read the server instead. - refreshOnReturn(afterWatchedChange = true) + quietRefresh(afterWatchedChange = true) return@launch } // Restore by id: carousel moves may have republished the optimistic @@ -1444,6 +1508,12 @@ class TvItemDetailViewModel( */ private var favoritesRevalidatedThrough: Long = TvFavoriteRevalidationSession.currentVersion() private var moreLikeThisJob: Job? = null + private var detailLoadJob: Job? = null + private var quietDetailJob: Job? = null + /** Whether [quietDetailJob] clears the page on a refusal (an access-change refresh). */ + private var quietDetailShowsRefusal = false + /** The playback return [quietDetailJob] overlays on its answer. */ + private var quietPlaybackReturn: TvDetailTrackSelectionSession.Saved? = null private var nextUpDetailJob: Job? = null private var activeSeriesEpisodeContentId: String? = null // A seasons refresh may complete after the viewer has already changed the diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt index 0025400385..115535a0da 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesScreen.kt @@ -12,7 +12,10 @@ import org.siloserver.silo.tv.ui.components.TvCatalogEmptyState import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvLoadingScreen import org.siloserver.silo.tv.ui.screens.library.TvLibraryDetailScreen +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals @Composable fun TvLibrariesScreen( @@ -32,6 +35,7 @@ fun TvLibrariesScreen( viewModel: TvLibrariesViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.load() } val selectedLibrary = state.libraries.firstOrNull { it.id == state.selectedLibraryId } ?: state.libraries.firstOrNull() // A library hidden or shown on another device, picked up on foreground. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt index 0e3e56272c..2e3cb38740 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/libraries/TvLibrariesViewModel.kt @@ -33,6 +33,8 @@ class TvLibrariesViewModel( /** Server reachability; each reachable probe retries a failed load. */ reachability: Flow<ServerReachabilityState> = emptyFlow(), ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() data class UiState( val isLoading: Boolean = true, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt index 868e689825..ad7f42fd02 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -85,8 +85,11 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.offset import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals /** * Android TV library detail surface, with tvOS as the master. @@ -140,6 +143,7 @@ fun TvLibraryDetailScreen( } else { null } + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refreshAfterAccessChange() } // Apply the committed cascade section on entry / whenever the commit // changes it. Keyed on sectionRequestNonce (bumped on every commit) AND the diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt index 29080ff3e3..0adfc857f4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -16,8 +16,8 @@ import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.repository.SectionRepository import org.siloserver.silo.tv.ui.util.tvCatalogMediaTypeFor import org.siloserver.silo.tv.ui.util.visibleOnTv -import kotlinx.coroutines.async import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow @@ -166,6 +166,8 @@ class TvLibraryDetailViewModel( private val mediaScope: String? = null, private val browsePrefs: BrowsePrefsStore? = null, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() data class UiState( val title: String, @@ -227,6 +229,13 @@ class TvLibraryDetailViewModel( private var browseRawLoaded = 0 private var loadedAudiobookGroupBy: String? = null private var audiobookGroupsGeneration = 0 + // Declared before init, which starts the first Recommended load: an + // initializer below it would reset this generation and drop that load's + // job afterward, so a reload could neither cancel nor outrank it. + private var recommendedGeneration = 0L + private var filtersJob: Job? = null + private var collectionsJob: Job? = null + private var recommendedJob: Job? = null init { // Only the default Recommended tab loads eagerly. Filters (the genre @@ -383,6 +392,11 @@ class TvLibraryDetailViewModel( browseFilter = it.browseFilter.forTab(it.selectedTab), ) } + // The group list went stale while a group was open (an access change). + val groupBy = _uiState.value.selectedTab.audiobookGroupBy + if (groupBy != null && loadedAudiobookGroupBy != groupBy) { + loadAudiobookGroups(groupBy = groupBy, reset = true) + } } fun retryRecommended() { @@ -402,6 +416,44 @@ class TvLibraryDetailViewModel( loadAudiobookGroups(groupBy = groupBy, reset = true) } + /** + * The server reported an access change: titles, sections, facets, and + * collections in this library may differ under the new policy. Reload the + * visible tab now and let the others reload when next opened, instead of + * trusting their one-time loaded flags. + */ + fun refreshAfterAccessChange() { + loadedRecommended = false + loadedBrowse = false + loadedCollections = false + loadedFilters = false + loadedAudiobookGroupBy = null + val state = _uiState.value + when (state.selectedTab) { + TvLibraryTab.Recommended -> loadRecommended() + TvLibraryTab.Browse, + TvLibraryTab.Genres, + TvLibraryTab.Alphabet, + TvLibraryTab.RecentlyAdded -> { + loadFilters() + loadBrowse(reset = true) + } + TvLibraryTab.Authors, + TvLibraryTab.Series -> { + val groupBy = state.selectedTab.audiobookGroupBy ?: return + // A selected author or series shows its titles; keep the + // selection and reload those. The group list reloads when the + // selection is cleared (onAudiobookGroupCleared). + if (state.selectedAudiobookGroup != null) { + loadBrowse(reset = true) + } else { + loadAudiobookGroups(groupBy = groupBy, reset = true) + } + } + TvLibraryTab.Collections -> loadCollections() + } + } + /** * Turning preserve off clears the saved state; turning it on saves the * current Browse sort and filters at once (tvOS `setPreserveEnabled`). @@ -431,9 +483,6 @@ class TvLibraryDetailViewModel( browsePrefs?.saveState(libraryId, state.browseFilter.toSavedState(), mediaScope) } - private var recommendedGeneration = 0L - private var recommendedJob: Job? = null - private fun loadRecommended() { val run = ++recommendedGeneration loadedRecommended = true @@ -543,12 +592,17 @@ class TvLibraryDetailViewModel( private fun loadFilters() { loadedFilters = true - viewModelScope.launch { + // A reload after an access change replaces an unfinished load, so the + // older answer cannot land after it. + filtersJob?.cancel() + filtersJob = viewModelScope.launch { _uiState.update { it.copy(filtersLoading = true) } // include_technical adds the resolution / audio-language / // subtitle-language vocabularies the filter panel offers (tvOS // FacetLoader always requests them). - when (val filters = catalogRepository.getFilters(libraryId, includeTechnical = true)) { + val filters = catalogRepository.getFilters(libraryId, includeTechnical = true) + ensureActive() + when (filters) { is ApiResult.Success -> _uiState.update { it.copy( genres = filters.data.genres.sorted(), @@ -768,9 +822,13 @@ class TvLibraryDetailViewModel( private fun loadCollections() { loadedCollections = true - viewModelScope.launch { + // As in loadFilters: the newest load is the only one that publishes. + collectionsJob?.cancel() + collectionsJob = viewModelScope.launch { _uiState.update { it.copy(collectionsLoading = true, collectionsError = null) } - when (val result = sectionRepository.getLibraryCollectionsGrouped(libraryId)) { + val result = sectionRepository.getLibraryCollectionsGrouped(libraryId) + ensureActive() + when (result) { is ApiResult.Success -> { val sections = buildCollectionSections(result.data) _uiState.update { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt index 6d2296a5bd..bdd471f4f1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt @@ -11,6 +11,7 @@ import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.ProfileCommitResult import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.repository.ProfileVerificationRecovery import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,6 +53,7 @@ class TvProfileSelectionViewModel( private val profileRepository: ProfileRepository, private val authRepository: AuthRepository? = null, private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, + private val profileVerificationRecovery: ProfileVerificationRecovery? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(TvProfileSelectionUiState()) @@ -75,9 +77,22 @@ class TvProfileSelectionViewModel( init { loadProfiles() reloadWhenUpdateRequiredLifts() + reloadWhenStaleProfileCleared() leaveManageModeWhenSessionEnds() } + /** + * A load sent with a profile token the server no longer accepts fails with + * `profile_verification_required`. The recovery then clears that profile, + * and the grid reloads without it instead of leaving the error up. + */ + private fun reloadWhenStaleProfileCleared() { + val recovery = profileVerificationRecovery ?: return + viewModelScope.launch { + recovery.profileCleared.collect { loadProfiles() } + } + } + /** Cancel on a re-prompt for the primary's PIN ends the session; manage mode goes with it. */ private fun leaveManageModeWhenSessionEnds() { viewModelScope.launch { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt index f52d52b379..cdb1f93242 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchScreen.kt @@ -94,6 +94,8 @@ import org.siloserver.silo.tv.ui.theme.Spacing import org.siloserver.silo.viewmodel.RequestSearchViewModel import kotlinx.coroutines.delay import org.koin.compose.koinInject +import org.siloserver.silo.common.ui.OnViewerAccessChanged +import org.siloserver.silo.network.AccessChangeSignals import org.koin.compose.viewmodel.koinViewModel internal fun shouldFocusSearchField( @@ -116,6 +118,7 @@ fun TvSearchScreen( requestSearchViewModel: RequestSearchViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + OnViewerAccessChanged(koinInject<AccessChangeSignals>(), viewModel.accessChanges) { viewModel.refreshForAccessChange() } val requestState by requestSearchViewModel.uiState.collectAsState() val requestsFeatureStore: RequestsFeatureStore = koinInject() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchViewModel.kt index f9565dc3bd..3ff00c80c8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,6 +53,8 @@ class TvSearchViewModel( private val personalDataRepository: PersonalDataRepository, private val libraryScopeStore: TvLibraryScopeStore, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() data class UiState( val query: String = "", @@ -78,6 +81,9 @@ class TvSearchViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() + // Declared before init, which starts the first load and stores its job here. + private var mediaTypesJob: Job? = null + init { loadAvailableMediaTypes() } /** @@ -87,13 +93,17 @@ class TvSearchViewModel( * unavailable it falls back to All. */ private fun loadAvailableMediaTypes() { - viewModelScope.launch { + // A reload after an access change replaces an unfinished one, so the + // older library list cannot land after it. + mediaTypesJob?.cancel() + mediaTypesJob = viewModelScope.launch { val libraries = when (val result = personalDataRepository.listUserLibraries()) { is ApiResult.Success -> result.data else -> return@launch } val caps = libraries.tvMediaModeCapabilities() val showAudiobooks = libraryScopeStore.getShowAudiobooksTab() + ensureActive() // The "Audiobooks" chip sends type=audiobook, so gate it on an // actual audiobook-like library — hasAudio also covers music, which // wouldn't match the audiobook filter. @@ -184,6 +194,15 @@ class TvSearchViewModel( } } + /** + * The server reported an access change: libraries (and so the media-type + * chips) and the titles a query can match may differ under the new policy. + */ + fun refreshForAccessChange() { + loadAvailableMediaTypes() + if (_uiState.value.query.isNotBlank()) submitSearch() + } + fun submitSearch() { // Bump the generation (mirroring onQueryChanged) so a cancelled in-flight // load-more's response fails the stale-generation guard instead of diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt index c41f3f73b3..ef373e6346 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt @@ -154,6 +154,8 @@ import org.siloserver.silo.tv.ui.theme.TvSkyline import org.siloserver.silo.tv.ui.util.visibleOnTv import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.siloserver.silo.common.ui.rememberViewerAccessKey +import org.siloserver.silo.network.AccessChangeSignals import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved import org.siloserver.silo.viewmodel.HomeViewModel @@ -244,6 +246,9 @@ fun TvMainShell( // `visibleRoots` is only Home + Calendar, so a restored/deep-linked // `main/movies` route must NOT be treated as "type has no libraries" yet. var librariesLoaded by remember { mutableStateOf(false) } + // An access change can add or remove whole libraries, so the tab set is + // re-derived under the new policy (the previous list stays until then). + val viewerAccessKey = rememberViewerAccessKey(koinInject<AccessChangeSignals>()) // The list leaves out libraries the profile hid; hiding or showing one // on another device re-loads it. val hiddenLibrariesRevision by personalDataRepository.hiddenLibrariesRevision.collectAsState() @@ -260,6 +265,7 @@ fun TvMainShell( personalDataRepository, hiddenLibrariesRevision, librariesRetry, + viewerAccessKey, ) { // Pending means the last load failed and none is running, so a probe // never cancels a load still in flight. @@ -665,11 +671,21 @@ fun TvMainShell( // (QA 2026-07-08: Movies → Collections → black 'No collections' page). // null = unknown (still loading) → pill stays visible. var librariesWithCollections by remember { mutableStateOf<Set<Int>?>(null) } - LaunchedEffect(libraries) { + // Keyed on the access key too: an access change can alter which libraries + // have collections while the library list itself stays equal. + LaunchedEffect(libraries, viewerAccessKey) { if (libraries.isEmpty()) return@LaunchedEffect val ids = mutableSetOf<Int>() libraries.forEach { lib -> - val result = runCatching { sectionRepository.getLibraryCollections(lib.id) }.getOrNull() + // Rethrow cancellation: a restarted probe must stop here, not + // finish the loop and publish a partial set. + val result = try { + sectionRepository.getLibraryCollections(lib.id) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } if (result is ApiResult.Success && result.data.isNotEmpty()) ids += lib.id } librariesWithCollections = ids diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailAccessChangeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailAccessChangeTest.kt new file mode 100644 index 0000000000..fd3b4683fc --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailAccessChangeTest.kt @@ -0,0 +1,260 @@ +package org.siloserver.silo.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.MockEngineConfig +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.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.siloserver.silo.domain.settings.ProfileSettingsController +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.CatalogApi +import org.siloserver.silo.network.api.DefaultMetadataAiApi +import org.siloserver.silo.network.api.PersonalDataApi +import org.siloserver.silo.network.api.ProfileApi +import org.siloserver.silo.network.api.SettingsApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.SettingsV2Api +import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.repository.MetadataAiRepository +import org.siloserver.silo.repository.PersonalDataRepository +import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.repository.SettingsRepository +import org.siloserver.silo.repository.port.CatalogCachePort +import org.siloserver.silo.tv.testing.FakePlayerSettingsStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * An access change refreshes an open TV detail page. A refusal replaces the + * page with the error; a later change must bring back a title the viewer + * regains, and must not bring back the durable cached copy of one the server + * still refuses. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvItemDetailAccessChangeTest { + + @Test + fun `an access change reloads a refused title once access returns`() = runDetailTest { + var available = true + val viewModel = createViewModel(available = { available }) + viewModel.loadAll() + advanceUntilIdle() + assertEquals(CONTENT_ID, viewModel.uiState.value.detail?.contentId) + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.detail) + + available = true + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals(CONTENT_ID, viewModel.uiState.value.detail?.contentId) + assertEquals(null, viewModel.uiState.value.error) + } + + @Test + fun `an access change on a still refused title does not repaint its cached detail`() = runDetailTest { + var available = true + val viewModel = createViewModel( + available = { available }, + cached = ItemDetail(contentId = CONTENT_ID, type = "movie", title = "Cached"), + ) + viewModel.loadAll() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + // A second, unrelated access change runs the full load, which paints + // the durable cached copy before the live request is refused again. + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun `an access change during the first load replaces it`() = runDetailTest { + var available = true + val firstResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = firstResponse) + viewModel.loadAll() + // The first request is answered under the old policy but held back. + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isLoading) + + available = false + viewModel.refreshAfterAccessChange() + firstResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun `an access change during the first load brings back a restored title`() = runDetailTest { + var available = false + val firstResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = firstResponse) + viewModel.loadAll() + advanceUntilIdle() + + available = true + viewModel.refreshAfterAccessChange() + firstResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(CONTENT_ID, viewModel.uiState.value.detail?.contentId) + assertEquals(null, viewModel.uiState.value.error) + } + + @Test + fun `an older quiet refresh does not restore a title an access change refused`() = runDetailTest { + var available = true + val returnResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = returnResponse, holdRequest = 2) + viewModel.loadAll() + advanceUntilIdle() + // A return refresh answered under the old policy but held back. + viewModel.refreshOnReturn() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + returnResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + @Test + fun `a quiet refresh replacing an access change refresh still shows the refusal`() = runDetailTest { + var available = true + val accessResponse = CompletableDeferred<Unit>() + val viewModel = createViewModel(available = { available }, holdFirst = accessResponse, holdRequest = 2) + viewModel.loadAll() + advanceUntilIdle() + + available = false + viewModel.refreshAfterAccessChange() + advanceUntilIdle() + viewModel.refreshOnReturn() + advanceUntilIdle() + accessResponse.complete(Unit) + advanceUntilIdle() + + assertEquals(null, viewModel.uiState.value.detail) + assertTrue(viewModel.uiState.value.error != null) + } + + // ------------------------------------------------------------------ + + private val createdViewModels = mutableListOf<androidx.lifecycle.ViewModel>() + + private fun runDetailTest(block: suspend TestScope.() -> Unit) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + block() + } finally { + // Cancel viewModelScope work and let the cancellations finish + // while Main is still the test dispatcher. + createdViewModels.forEach { it.viewModelScope.cancel() } + createdViewModels.clear() + advanceUntilIdle() + Dispatchers.resetMain() + } + } + + /** + * With [holdFirst], detail request number [holdRequest] reads [available] + * when it arrives but answers only once [holdFirst] completes. + */ + private fun TestScope.createViewModel( + available: () -> Boolean, + cached: ItemDetail? = null, + holdFirst: CompletableDeferred<Unit>? = null, + holdRequest: Int = 1, + ): TvItemDetailViewModel { + val dispatcher = StandardTestDispatcher(testScheduler) + var detailRequests = 0 + val client = HttpClient( + MockEngine( + MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { request -> + val isDetail = request.url.encodedPath == "/api/v2/catalog/items/$CONTENT_ID" + val allowed = isDetail && available() + if (isDetail && ++detailRequests == holdRequest) holdFirst?.await() + when { + !isDetail -> respond( + """{"type":"about:blank","title":"not_found","status":404,"detail":"not found"}""", + HttpStatusCode.NotFound, + PROBLEM_HEADERS, + ) + allowed -> respond( + """{"content_id":"$CONTENT_ID","type":"movie","title":"Movie","cast":[],"crew":[],"versions":[],"subtitles":[]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond( + """{"type":"about:blank","title":"not_found","status":404,"detail":"Item not found"}""", + HttpStatusCode.NotFound, + PROBLEM_HEADERS, + ) + } + } + }, + ), + ) { install(ContentNegotiation) { json(SiloJson) } } + val tokenManager = TokenManagerImpl() + return TvItemDetailViewModel( + catalogRepository = CatalogRepository( + CatalogApi(client), + catalogCache = object : CatalogCachePort { + override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = cached + }, + requestDispatcher = dispatcher, + ), + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + playerSettingsStore = FakePlayerSettingsStore(), + profileRepository = ProfileRepository(ProfileApi(client, ApiV2Gate.Unrestricted), tokenManager), + profileSettings = ProfileSettingsController( + SettingsRepository(SettingsApi(SettingsV2Api(client, tokenManager, ApiV2Gate.Unrestricted))), + ), + metadataAiRepository = MetadataAiRepository(DefaultMetadataAiApi(client, gate = ApiV2Gate.Unrestricted)), + contentId = CONTENT_ID, + tokenManager = tokenManager, + identityTransitions = DefaultIdentityTransitionBarrier(), + ).also { createdViewModels += it } + } + + private companion object { + const val CONTENT_ID = "movie-1" + val PROBLEM_HEADERS = headersOf(HttpHeaders.ContentType, "application/problem+json") + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt index 9a7b8b8858..a826cd6424 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt @@ -12,6 +12,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -120,6 +121,36 @@ class TvLibrarySubdestinationViewModelTest { assertEquals(requestsBeforeReentry, requests.catalogRequestCount()) } + @Test + fun anEarlierFiltersLoadDoesNotOverwriteTheAccessChangeReload() = runLibraryTest { + val requests = mutableListOf<RequestRecord>() + val firstFilters = CompletableDeferred<Unit>() + val viewModel = viewModelFor(requests, libraryType = "movies") { request -> + if (request == 1) { + firstFilters.await() + filtersJson("Before") + } else { + filtersJson("After") + } + } + + viewModel.onTabSelected(TvLibraryTab.Browse) + awaitState { requests.filtersRequestCount() == 1 } + viewModel.refreshAfterAccessChange() + awaitState { viewModel.uiState.value.genres == listOf("After") } + // The pre-change answer arrives last. + firstFilters.complete(Unit) + settle() + + assertEquals(listOf("After"), viewModel.uiState.value.genres) + } + + private fun filtersJson(genre: String) = + """{"genres":["$genre"],"studios":[],"networks":[],"countries":[],"content_ratings":[],"original_languages":[],"authors":[],"narrators":[],"series":[]}""" + + private fun MutableList<RequestRecord>.filtersRequestCount(): Int = + synchronized(this) { count { it.path == "/api/v2/catalog/filters" } } + private val createdViewModels = mutableListOf<androidx.lifecycle.ViewModel>() private fun runLibraryTest(block: suspend () -> Unit) = runTest { @@ -183,10 +214,13 @@ class TvLibrarySubdestinationViewModelTest { lastOrNull { it.path == "/api/v2/catalog/audiobook-groups" } } + /** [filters] answers the Nth filters request (1-based); null serves one genre. */ private fun viewModelFor( requests: MutableList<RequestRecord>, libraryType: String, + filters: (suspend (request: Int) -> String)? = null, ): TvLibraryDetailViewModel { + val filterRequests = java.util.concurrent.atomic.AtomicInteger() val client = HttpClient( MockEngine { request -> val record = RequestRecord( @@ -201,7 +235,7 @@ class TvLibrarySubdestinationViewModelTest { "/api/v1/library/7/sections" -> respondJson("""{"sections":[]}""") "/api/v2/library/7/collections" -> respondJson("""{"library_id":"7","collections":[],"groups":[]}""") "/api/v2/catalog/filters" -> respondJson( - """{"genres":["Drama"],"studios":[],"networks":[],"countries":[],"content_ratings":[],"original_languages":[],"authors":[],"narrators":[],"series":[]}""", + filters?.invoke(filterRequests.incrementAndGet()) ?: """{"genres":["Drama"],"studios":[],"networks":[],"countries":[],"content_ratings":[],"original_languages":[],"authors":[],"narrators":[],"series":[]}""", ) "/api/v2/catalog/audiobook-groups" -> respondJson( """ diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchAccessChangeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchAccessChangeTest.kt new file mode 100644 index 0000000000..9707daf16d --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/search/TvSearchAccessChangeTest.kt @@ -0,0 +1,92 @@ +package org.siloserver.silo.tv.ui.screens.search + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +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 java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +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 org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.CatalogApi +import org.siloserver.silo.network.api.PersonalDataApi +import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.repository.PersonalDataRepository +import org.siloserver.silo.tv.data.preferences.TvLibraryScopeStore +import kotlin.test.Test +import kotlin.test.assertEquals + +/** An access change reloads the media-type chips; the first load must not land after it. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class TvSearchAccessChangeTest { + + @Test + fun `the first library load does not overwrite the access change reload`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val firstLoad = CompletableDeferred<Unit>() + val requests = AtomicInteger() + val client = HttpClient( + MockEngine { request -> + check(request.url.encodedPath == "/api/v2/user/libraries") { "Unexpected ${request.url}" } + // Before the change the viewer only had an ebook library; after it, movies too. + val body = if (requests.incrementAndGet() == 1) { + firstLoad.await() + """{"items":[{"id":"1","name":"Books","type":"ebooks","sort_order":0}],"page":{"has_more":false}}""" + } else { + """{"items":[{"id":"2","name":"Movies","type":"movies","sort_order":0}],"page":{"has_more":false}}""" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }, + ) { install(ContentNegotiation) { json(SiloJson) } } + val tokens = TokenManagerImpl() + val viewModel = TvSearchViewModel( + catalogRepository = CatalogRepository(CatalogApi(client)), + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + libraryScopeStore = TvLibraryScopeStore(ApplicationProvider.getApplicationContext(), tokens), + ) + try { + awaitState { requests.get() == 1 } + viewModel.refreshForAccessChange() + awaitState { TvSearchMediaType.Movies in viewModel.uiState.value.availableMediaTypes } + firstLoad.complete(Unit) + withContext(Dispatchers.IO) { delay(200) } + + assertEquals( + listOf(TvSearchMediaType.All, TvSearchMediaType.Movies, TvSearchMediaType.Series), + viewModel.uiState.value.availableMediaTypes, + ) + } finally { + viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() + Dispatchers.resetMain() + } + } + + private suspend fun awaitState(predicate: () -> Boolean) { + withContext(Dispatchers.IO) { + withTimeout(30_000) { while (!predicate()) delay(10) } + } + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt index fe33468db6..849950c754 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/NetworkModule.kt @@ -14,7 +14,16 @@ import org.koin.dsl.module val networkModule = module { single<IdentityTransitionBarrier> { DefaultIdentityTransitionBarrier() } single<TokenManager> { TokenManagerImpl(get()) } - single { createSiloClient(get(), getOrNull(), getOrNull(), getOrNull()) } + single { org.siloserver.silo.network.AccessChangeSignals() } + single { + createSiloClient( + tokenManager = get(), + deviceMetadataProvider = getOrNull(), + diagnosticsObserver = getOrNull(), + cleartextOriginConsent = getOrNull(), + accessChangeSignals = get(), + ) + } single { ApiV2Gate(getOrNull()) } single { MembershipV2Api(get(), get(), get()) } single { ApiV2Probe(get()) } @@ -44,7 +53,7 @@ val networkModule = module { single { RecommendationApi(get(), get(), get(), get()) } single<RequestsApi> { DefaultRequestsApi(get(), get(), get()) } single<MetadataAiApi> { DefaultMetadataAiApi(get(), get(), get()) } - single { EventsSocketV2Api(get(), get(), get()) } + single { EventsSocketV2Api(get(), get(), get(), getOrNull()) } single<HomeRealtimeClient> { DefaultHomeRealtimeClient(get()) } single<CalendarApi> { DefaultCalendarApi(get(), get(), get()) } single { HealthApi(get()) } @@ -71,7 +80,14 @@ val networkModule = module { gate = get(), tokens = get(), // Start, select, and promote must never be resent by the engine. - nonReplayingClient = createSiloClient(get(), getOrNull(), getOrNull(), getOrNull(), retryOnConnectionFailure = false), + nonReplayingClient = createSiloClient( + tokenManager = get(), + deviceMetadataProvider = getOrNull(), + diagnosticsObserver = getOrNull(), + cleartextOriginConsent = getOrNull(), + retryOnConnectionFailure = false, + accessChangeSignals = get(), + ), ) } single<DiagnosticsApi> { DefaultDiagnosticsApi(get(), gate = get()) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index ac80338c1b..76fce116bc 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -89,6 +89,20 @@ val repositoryModule = module { } single { org.siloserver.silo.repository.HiddenLibrariesStore(get(), get()) } single { ProfileRepository(get(), get(), getOrNull(), get(), get(), get()) } + // Resolved by each Application right after startKoin (guarded there, like + // the other starters) so a stale-profile refusal from background work + // (downloads, outbox replay) is handled before any screen exists. Clears + // only the stale profile selection; navigation acts on its pending prompt. + single { + org.siloserver.silo.repository.ProfileVerificationRecovery( + signals = get(), + tokenManager = get(), + profileRepository = get(), + identityTransitions = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + ) + } + single { org.siloserver.silo.repository.ForegroundAccessCheck(get(), get()) } single { CollectionRepository(get()) } single { SectionRepository( @@ -113,7 +127,7 @@ val repositoryModule = module { single { org.siloserver.silo.repository.MetadataAiRepository(get()) } single { org.siloserver.silo.model.feature.MetadataAiFeatureStore(get()) } single { org.siloserver.silo.model.feature.ShuffleFeatureStore(get()) } - single { org.siloserver.silo.repository.HomeRealtimeCoordinator(get(), get()) } + single { org.siloserver.silo.repository.HomeRealtimeCoordinator(get(), get(), getOrNull()) } single { SettingsRepository(get()) } // Profile-scoped canonical settings, shared by the phone and TV screens so // one platform cannot grow a behavior the other lacks. diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AccessChangeSignals.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AccessChangeSignals.kt new file mode 100644 index 0000000000..2dcf3de7e4 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AccessChangeSignals.kt @@ -0,0 +1,220 @@ +package org.siloserver.silo.network + +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.util.AttributeKey +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlin.time.TimeSource + +/** v2 problem code for a profile header the server no longer accepts. */ +const val PROFILE_VERIFICATION_REQUIRED = "profile_verification_required" + +/** + * Whether a failed read says the viewer may no longer see the resource: 404, + * 410, or a 403 other than [PROFILE_VERIFICATION_REQUIRED] (a stale profile + * proof, which profile recovery handles). Transport failures, 401, and 5xx are + * transient and say nothing about access. + */ +fun ApiResult<*>.isAccessRefusal(): Boolean = + this is ApiResult.Error && + (code == 404 || code == 410 || (code == 403 && error != PROFILE_VERIFICATION_REQUIRED)) + +/** + * How the client learns that the server changed what the signed-in viewer may + * access, without the session ending. + * + * An administrator can move an account to another access group, or change its + * permissions or playback-quality override, while the user is signed in. The + * server then raises its access-policy revision: PIN profile tokens minted + * before the change stop working (403 `profile_verification_required`), and the + * events socket sends `{"type":"access_changed"}` and closes with code 4001. + * + * This type only carries those observations. [reportStaleProfile] feeds the + * profile recovery in the repository layer; [changes] tells screens to refetch + * access-dependent data. Both are inert when the server never produces them. + */ +class AccessChangeSignals( + /** Milliseconds on a monotonic clock; tests substitute virtual time. */ + private val nowMillis: () -> Long = monotonicMillis(), +) { + private val _staleProfileReports = MutableSharedFlow<StaleProfileReport>( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Every request the server refused because its profile proof is stale. */ + val staleProfileReports: SharedFlow<StaleProfileReport> = _staleProfileReports.asSharedFlow() + + /** Recent reports, newest last; [revision] moves only after a report is logged here. */ + private val reports = MutableStateFlow(emptyList<AccessChangeReport>()) + private val _revision = MutableStateFlow(0L) + + /** Raised once per events-socket connection that reports an access change. */ + val revision: StateFlow<Long> = _revision.asStateFlow() + + /** + * Access changes reported after collection starts, coalesced. The frame and + * close of one connection count once. The Home and notifications sockets + * each detect the same change on their own server-side check, which runs + * every 15 seconds from each connection's start, so their reports can be + * that far apart: the first report refreshes at once, and reports within + * [ACCESS_CHANGE_COALESCE_MS] of it are taken as the same change. The + * exception is a report from a connection opened after that refresh. Each + * connection reports once and then reconnects, and a new connection + * compares against the policy its own ticket was minted under, so its + * report is a change the refresh did not load, and it refreshes again. + */ + val changes: Flow<Unit> = flow { emitAll(changes(AccessChangeCursor())) } + + /** + * [changes] for a holder that outlives its screen, such as a ViewModel + * whose screen leaves composition while another destination is in front. + * The [cursor] remembers which revision the holder has applied, so when + * collection resumes, a change reported while nothing was collecting + * refreshes at once instead of being skipped. Coalescing follows the + * cursor as well, so the second socket's report of a change already + * applied does not refresh again on return. + */ + fun changes(cursor: AccessChangeCursor): Flow<Unit> = flow { + revision.collect { current -> + val applied = cursor.appliedRevision + if (applied != null && current <= applied) return@collect + cursor.appliedRevision = current + // The first collection starts from whatever the holder loaded. + if (applied == null) return@collect + val now = nowMillis() + val last = cursor.lastRefreshAt + val pending = reports.value.filter { it.revision in (applied + 1)..current } + // Reports older than the log reach are unknown; refresh for them. + val complete = pending.size.toLong() == current - applied + val covered = last != null && complete && now - last < ACCESS_CHANGE_COALESCE_MS && + pending.all { report -> report.connectedAt == null || report.connectedAt < last } + if (!covered) { + cursor.lastRefreshAt = now + emit(Unit) + } + } + } + + fun reportStaleProfile(report: StaleProfileReport) { + _staleProfileReports.tryEmit(report) + } + + /** The time to pass to [reportAccessChanged] for a connection whose ticket was just minted. */ + fun connectionTime(): Long = nowMillis() + + /** + * Records one access-change report. [connectedAt] is the [connectionTime] + * taken after the reporting socket's ticket was minted; null for a report + * that does not come from an events socket, which coalesces by time only. + */ + fun reportAccessChanged(connectedAt: Long? = null) { + var logged = 0L + reports.update { log -> + logged = (log.lastOrNull()?.revision ?: 0L) + 1 + (log + AccessChangeReport(logged, connectedAt)).takeLast(REPORT_LOG_SIZE) + } + _revision.update { maxOf(it, logged) } + } + + companion object { + /** Longer than the server's 15-second socket access check. */ + const val ACCESS_CHANGE_COALESCE_MS = 20_000L + + private const val REPORT_LOG_SIZE = 32 + + /** Close code the events socket uses after an `access_changed` frame. */ + const val ACCESS_CHANGED_CLOSE_CODE: Short = 4001 + + /** Frame `type` of the access-change notice on the events socket. */ + const val ACCESS_CHANGED_FRAME_TYPE = "access_changed" + } +} + +/** + * The access changes one holder has already applied; see + * [AccessChangeSignals.changes]. Keep one per ViewModel and collect it from one + * place at a time (the screen's main-thread effect). + */ +class AccessChangeCursor { + internal var appliedRevision: Long? = null + internal var lastRefreshAt: Long? = null +} + +private class AccessChangeReport(val revision: Long, val connectedAt: Long?) + +private fun monotonicMillis(): () -> Long { + val start = TimeSource.Monotonic.markNow() + return { start.elapsedNow().inWholeMilliseconds } +} + +/** + * One request refused with 403 `profile_verification_required`, described by + * the identity it actually presented. The recovery acts only when that identity + * is still the active one, which is what keeps a stale background request (or a + * second report for an identity already cleared) from doing anything. + */ +data class StaleProfileReport( + val requestUrl: String, + val profileId: String, + val profileToken: String?, + /** The scope a pinned (background/outbox) request was bound to, if any. */ + val pinnedScope: AuthScopeSnapshot?, +) { + override fun toString(): String = + "StaleProfileReport(requestUrl=<redacted>, profileId=<redacted>, " + + "profileToken=<redacted>, pinned=${pinnedScope != null})" +} + +/** Client attribute through which the v2 error decoder finds the signals. */ +internal val AccessChangeSignalsKey: AttributeKey<AccessChangeSignals> = + AttributeKey("SiloAccessChangeSignals") + +/** + * Report this response if it is a stale-profile refusal for a request that ran + * on a profile identity. Called by the v2 error decoder, so it sees every v2 + * call without the auth plugin having to read response bodies. + */ +internal fun HttpResponse.reportStaleProfileIfRefused(problemCode: String) { + if (status != HttpStatusCode.Forbidden || problemCode != PROFILE_VERIFICATION_REQUIRED) return + val signals = call.client.attributes.getOrNull(AccessChangeSignalsKey) ?: return + val request = call.request + // Diagnostics uploads choose their profile headers themselves. + if (request.attributes.contains(DiagnosticsUploadAuthorizationKey) || + request.attributes.contains(DiagnosticsRequestScopeKey) + ) return + if (isHouseholdManagementRequest(request.method, request.url.encodedPath)) return + val profileId = request.headers["X-Profile-Id"]?.takeIf { it.isNotBlank() } ?: return + signals.reportStaleProfile( + StaleProfileReport( + requestUrl = request.url.toString(), + profileId = profileId, + profileToken = request.headers["X-Profile-Token"]?.takeIf { it.isNotBlank() }, + pinnedScope = request.attributes.getOrNull(AuthScopeAttributeKey), + ), + ) +} + +/** + * Household management (creating, editing, or deleting profiles, avatars, and + * the household session list) answers `profile_verification_required` when the + * active profile is not the verified primary profile. That is a missing + * permission, not a stale token, so it must not clear the profile. A stale token + * on these screens still recovers through the next ordinary read. + */ +internal fun isHouseholdManagementRequest(method: HttpMethod, path: String): Boolean { + val profilesPath = path.startsWith("/api/v2/profiles") || path.startsWith("/api/v1/profiles") + if (!profilesPath || path.endsWith("/verify-pin")) return false + return method != HttpMethod.Get || path.contains("/profiles/household") +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt index 72b5d415e5..61b440086c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt @@ -71,6 +71,12 @@ class SiloAuthConfig { var deviceMetadataProvider: DeviceMetadataProvider? = null var diagnosticsObserver: NetworkDiagnosticsObserver? = null var cleartextOriginConsent: CleartextOriginConsent? = null + + /** + * Receives stale-profile refusals the v2 error decoder sees on this + * client. Null leaves them as ordinary errors. + */ + var accessChangeSignals: AccessChangeSignals? = null } /** @@ -94,6 +100,7 @@ val SiloAuthPlugin = createClientPlugin("SiloAuthPlugin", ::SiloAuthConfig) { val deviceMetadataProvider = pluginConfig.deviceMetadataProvider val diagnosticsObserver = pluginConfig.diagnosticsObserver val cleartextOriginConsent = pluginConfig.cleartextOriginConsent + pluginConfig.accessChangeSignals?.let { client.attributes.put(AccessChangeSignalsKey, it) } val refreshMutex = Mutex() diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt index a439f75671..c0537cf815 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/SiloHttpClientImpl.kt @@ -41,6 +41,7 @@ fun createSiloClient( diagnosticsObserver: NetworkDiagnosticsObserver? = null, cleartextOriginConsent: CleartextOriginConsent? = null, retryOnConnectionFailure: Boolean = true, + accessChangeSignals: AccessChangeSignals? = null, ): HttpClient { val platformClient = createPlatformHttpClient(retryOnConnectionFailure) @@ -79,6 +80,7 @@ fun createSiloClient( this.deviceMetadataProvider = deviceMetadataProvider this.diagnosticsObserver = diagnosticsObserver this.cleartextOriginConsent = cleartextOriginConsent + this.accessChangeSignals = accessChangeSignals } install(HttpTimeout) { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Call.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Call.kt index f8eabaf921..b8429cc44f 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Call.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/ApiV2Call.kt @@ -14,6 +14,7 @@ import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.network.resolveArtworkUrls import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.reportStaleProfileIfRefused /** * Wraps one v2 exchange: a 2xx body decodes to [T] with the production @@ -53,9 +54,13 @@ internal suspend fun HttpResponse.toApiV2Error(): ApiResult.Error { } catch (_: Exception) { null } + val code = problem?.code ?: "" + // A stale profile proof is the server's way of asking for the PIN again + // after an access change; the recovery decides whether it applies. + reportStaleProfileIfRefused(code) return ApiResult.Error( code = status.value, - error = problem?.code ?: "", + error = code, message = problem?.detail ?: "", ) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/EventsSocketV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/EventsSocketV2Api.kt index 101289e76d..cfae09b696 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/EventsSocketV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/EventsSocketV2Api.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.network.apiv2 import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.request.* import io.ktor.http.* @@ -8,6 +9,7 @@ import io.ktor.websocket.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import org.siloserver.silo.model.notifications.WsFrameEnvelope import org.siloserver.silo.model.notifications.WsSubscribe import org.siloserver.silo.model.notifications.WsTicketResponse import org.siloserver.silo.network.* @@ -18,6 +20,8 @@ class EventsSocketV2Api( private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate, + /** Told when the server reports that this viewer's access changed. */ + private val accessChanges: AccessChangeSignals? = null, ) { internal suspend fun current(scope: AuthScopeSnapshot): Boolean = scope.stillOwns(tokens, OwnerPolicy.FULL) @@ -43,6 +47,8 @@ class EventsSocketV2Api( is ApiResult.NetworkError -> throw EventsTicketFailure(0) } if (!current(scope) || mintedAt.elapsedNow().inWholeMilliseconds >= proof.expiresIn * 1000L) return@flow + // Taken after the mint, so the ticket's policy is no newer than this. + val connectedAt = accessChanges?.connectionTime() // Do not retry this upgrade, even on a lost handshake response: its // single-use proof may already be consumed. Reconnect collects afresh. withTimeoutOrNull(proof.maxConnectionSeconds * 1000L) { @@ -59,12 +65,30 @@ class EventsSocketV2Api( } } } + // The server announces an access change with a frame and then a + // 4001 close; one connection reports it once. The caller's + // reconnect then mints a fresh ticket under the new policy. + var accessChangeReported = false + suspend fun reportAccessChange() { + if (accessChangeReported || !current(scope)) return + accessChangeReported = true + accessChanges?.reportAccessChanged(connectedAt) + } try { send(Frame.Text(SiloJson.encodeToString(WsSubscribe.serializer(),WsSubscribe(channels = channels)))) + var identityLost = false for (frame in incoming) { - if (!current(scope)) break - if (frame is Frame.Text) this@flow.emit(frame.readText()) + if (!current(scope)) { + identityLost = true + break + } + if (frame is Frame.Text) { + val text = frame.readText() + if (isAccessChangedFrame(text)) reportAccessChange() + this@flow.emit(text) + } } + if (!identityLost && isAccessChangedClose(closeReasonIfClosed())) reportAccessChange() } finally { authorityWatcher.cancel() } } } @@ -73,6 +97,32 @@ class EventsSocketV2Api( internal class EventsTicketFailure(val code: Int) : Exception("Realtime ticket unavailable") +/** Whether [raw] is the server's `{"type":"access_changed"}` notice. */ +internal fun isAccessChangedFrame(raw: String): Boolean = try { + SiloJson.decodeFromString(WsFrameEnvelope.serializer(), raw).type == AccessChangeSignals.ACCESS_CHANGED_FRAME_TYPE +} catch (_: Exception) { + false +} + +/** Whether the server closed the socket because the viewer's access changed. */ +internal fun isAccessChangedClose(reason: CloseReason?): Boolean = + reason?.code == AccessChangeSignals.ACCESS_CHANGED_CLOSE_CODE + +/** + * The close reason once the incoming channel has ended. A peer that drops the + * connection without a close frame leaves none, which reads as null here. + */ +private suspend fun DefaultClientWebSocketSession.closeReasonIfClosed(): CloseReason? = + try { + withTimeoutOrNull(CLOSE_REASON_WAIT_MS) { closeReason.await() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + +private const val CLOSE_REASON_WAIT_MS = 1_000L + internal const val EVENTS_PROTOCOL = "silo.events.v2" internal fun validTicket(ticket: WsTicketResponse): Boolean = diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt index 9d28573042..7c80fc3258 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt @@ -173,10 +173,19 @@ class CatalogRepository( // Keeping them live prevents versions from leaking between browse contexts. /** Fetches full metadata for a single catalog item (offline: last cached detail). */ - suspend fun getItemDetail(contentId: String, libraryId: Int? = null): ApiResult<ItemDetail> { + /** + * Live item detail. By default it joins a Home warm-up already in flight + * for the same title; [joinWarmup] = false sends a fresh request instead, + * for a reload whose answer must postdate an access change. + */ + suspend fun getItemDetail( + contentId: String, + libraryId: Int? = null, + joinWarmup: Boolean = true, + ): ApiResult<ItemDetail> { if (libraryId != null) return catalogApi.getItemDetail(contentId, libraryId) val requestIdentityGeneration = identityTransitions.generation.value - val warmRequest = detailRequestMutex.withLock { + val warmRequest = if (!joinWarmup) null else detailRequestMutex.withLock { itemDetailInFlight[requestIdentityGeneration to contentId] } return warmRequest?.await() ?: fetchItemDetail(contentId, requestIdentityGeneration) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ForegroundAccessCheck.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ForegroundAccessCheck.kt new file mode 100644 index 0000000000..e71dc62edd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ForegroundAccessCheck.kt @@ -0,0 +1,26 @@ +package org.siloserver.silo.repository + +import org.siloserver.silo.network.AccessChangeSignals + +/** + * Catches an access change made while the app was in the background. + * + * The events sockets are the only source of [AccessChangeSignals.reportAccessChanged], + * and they close when the app stops. The server compares each connection with + * the policy its own ticket was minted under, so a connection opened after the + * change never reports it. When the app returns to the foreground, this check + * fetches the library list and reports an access change when the set of + * libraries differs from the cached list the screens were built from. + * + * It sees only changes to which libraries are visible. Other policy changes + * (maturity limits, playback quality) still surface when the affected screen + * next loads. + */ +class ForegroundAccessCheck( + private val personalDataRepository: PersonalDataRepository, + private val accessChanges: AccessChangeSignals, +) { + suspend fun afterReturnToForeground() { + if (personalDataRepository.libraryListChangedSinceCached()) accessChanges.reportAccessChanged() + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HomeRealtimeCoordinator.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HomeRealtimeCoordinator.kt index e39a07ee17..4c4c895d14 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HomeRealtimeCoordinator.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/HomeRealtimeCoordinator.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import org.siloserver.silo.network.AccessChangeSignals import org.siloserver.silo.network.HomeRealtimeClient import org.siloserver.silo.network.HomeRealtimeEvent import org.siloserver.silo.network.TokenManager @@ -28,12 +30,20 @@ import org.siloserver.silo.network.homeRefreshTrigger class HomeRealtimeCoordinator( private val client: HomeRealtimeClient, private val tokenManager: TokenManager, + /** + * Server-reported access changes. Either events socket may be the one that + * sees the change (each runs its own access check), so Home listens here + * rather than only to its own frames. The socket's 4001 close then makes + * [connect] reconnect with a freshly minted ticket. + */ + private val accessChanges: AccessChangeSignals? = null, ) { private val triggers = MutableSharedFlow<Unit>(extraBufferCapacity = 16) /** Debounced refetch signal — collect and call the Home refresh. */ @OptIn(FlowPreview::class) - val refreshSignals: Flow<Unit> = triggers.debounce(DEBOUNCE_MS) + val refreshSignals: Flow<Unit> = (accessChanges?.let { merge(triggers, it.changes) } ?: triggers) + .debounce(DEBOUNCE_MS) fun connect(scope: CoroutineScope): Job = scope.launch { var backoffMs = INITIAL_BACKOFF_MS diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/NotificationsRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/NotificationsRepository.kt index 557148a8ec..c71f9305ab 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/NotificationsRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/NotificationsRepository.kt @@ -22,6 +22,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch @@ -124,7 +126,9 @@ class NotificationsRepository( private val checkpoints: NotificationSyncStore? = null, private val identityTransitions: IdentityTransitionBarrier? = null, ) { - private var epoch = 0L + // Observable so a parked realtime loop can wait for the next identity. + private val epochState = MutableStateFlow(0L) + private val epoch: Long get() = epochState.value private var readCutoff: String? = null private var cutoffViewer: AuthScopeSnapshot? = null private val refreshMutex = Mutex() @@ -153,7 +157,7 @@ class NotificationsRepository( private val _preferences = MutableStateFlow<NotificationPreferences?>(null) private val _capability = MutableStateFlow<NotificationCapability?>(null) - /** True once [connectRealtime] gives up permanently due to an auth-class [NotificationRealtimeEvent.Closed]. */ + /** True while [connectRealtime] is parked after an auth-class [NotificationRealtimeEvent.Closed]. */ private val _realtimeFatal = MutableStateFlow(false) val unreadCount: StateFlow<Int> = _unreadCount.asStateFlow() @@ -163,9 +167,10 @@ class NotificationsRepository( val capability: StateFlow<NotificationCapability?> = _capability.asStateFlow() /** - * Becomes true if [connectRealtime] receives an auth-class [NotificationRealtimeEvent.Closed] - * (401/403/auth reason). When true the realtime loop has permanently stopped; callers should - * surface an appropriate error state rather than attempting to reconnect. + * True while [connectRealtime] is parked after an auth-class [NotificationRealtimeEvent.Closed] + * (401/403/auth reason). The loop stops retrying the same identity and resumes on the next + * [reset] — a profile re-verification, a profile switch, or an account or server change — + * which also clears this flag. */ val realtimeFatal: StateFlow<Boolean> = _realtimeFatal.asStateFlow() @@ -331,7 +336,8 @@ class NotificationsRepository( * [tryEmit] into the buffered flow never suspends. */ fun reset() { - epoch++ + epochState.update { it + 1 } + _realtimeFatal.value = false _error.value = null readCutoff = null cutoffViewer = null @@ -382,7 +388,16 @@ class NotificationsRepository( } catch (_: Throwable) { // fall through to backoff-reconnect } - if (authFailed) return@launch + if (authFailed) { + // Retrying the same identity would be refused again (a stale + // profile token stays stale). Park until the identity changes — + // the user re-enters the PIN, picks another profile, or the + // account changes — then reconnect with a fresh ticket. + epochState.first { it != connectionViewer.epoch } + _realtimeFatal.value = false + backoffMs = INITIAL_BACKOFF_MS + continue + } delay(backoffMs) backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt index 0b85172fd2..ac2c1c926c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/PersonalDataRepository.kt @@ -63,6 +63,20 @@ open class PersonalDataRepository( return result } + /** + * Re-checks the library list after the app returns to the foreground and + * says whether the set of visible libraries differs from the cached list, + * which is the one the screens last loaded. Goes through + * [recheckUserLibraries], so a short response is confirmed before it is + * trusted and the cache keeps only the visible libraries. False when there + * is no cached list, the request fails, or the identity changed while it ran. + */ + suspend fun libraryListChangedSinceCached(): Boolean { + val cachedIds = catalogCache.getCachedLibraries()?.mapTo(HashSet()) { it.id } ?: return false + val result = recheckUserLibraries(cachedIds) + return result is ApiResult.Success && result.data.mapTo(HashSet()) { it.id } != cachedIds + } + /** * Background re-check of a library list already on screen ([knownIds]). * A list missing any known library is read again before it is trusted, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt index 4ebfe95b1d..18cf02af62 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt @@ -10,7 +10,9 @@ import org.siloserver.silo.network.DefaultIdentityTransitionBarrier import org.siloserver.silo.network.IdentityTransitionBarrier import org.siloserver.silo.network.IdentityTransitionKind import org.siloserver.silo.network.ServerRegistry +import org.siloserver.silo.network.StaleProfileReport import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.isSameHttpOrigin import org.siloserver.silo.network.api.ProfileApi import org.siloserver.silo.network.map @@ -247,6 +249,59 @@ open class ProfileRepository( return profiles.firstOrNull { it.id == activeId } } + /** + * Clears the active profile when [report] proves its proof is stale. + * + * Acts only when the refused request presented exactly the profile id and + * token that are active now, on the active server: a report for a profile + * the user has since switched away from, re-verified, or already cleared is + * ignored, so repeated refusals cannot loop. A remote-playback overlay owns + * identity while it exists and is never touched. The account session is + * left alone; only the profile selection goes. + * + * @return true when the profile selection was cleared. + */ + suspend fun clearStaleProfile(report: StaleProfileReport): Boolean { + // Checked before entering the transition so an ignored report does not + // fire identity gates, then again inside it in case the identity moved. + // `changing` bumps the generation before its block runs (see + // [identityScopeStillHolds]), so the inner check allows exactly that one. + val generationBeforeTransition = identityTransitions.generation.value + if (!staleProfileStillActive(report, transitionsSinceCheck = 0)) return false + var cleared = false + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + if (identityTransitions.generation.value != generationBeforeTransition + 1) return@changing + if (!staleProfileStillActive(report, transitionsSinceCheck = 1)) return@changing + val activeServerId = tokenManager.getCurrentServerId() + tokenManager.setProfileIdentity(null, null) + if (activeServerId != null) { + serverRegistry?.setProfileId(activeServerId, null) + } + notificationsRepository?.reset() + requestsRepository?.reset() + cleared = true + } + return cleared + } + + private suspend fun staleProfileStillActive(report: StaleProfileReport, transitionsSinceCheck: Long): Boolean { + if (tokenManager.hasTemporaryScope()) return false + val identity = tokenManager.getProfileIdentity() + if (identity.profileId != report.profileId || identity.profileToken != report.profileToken) return false + val current = tokenManager.snapshotCurrentScope() + if (!isSameHttpOrigin(current?.serverUrl ?: tokenManager.getServerUrl(), report.requestUrl)) return false + val pinned = report.pinnedScope ?: return true + // A pinned request belongs to the identity it captured; a scope-less + // manager cannot prove that is still the active one. The profile id and + // token already matched above, so the generation is what proves no + // sign-in, server switch, or profile pick happened in between. The + // credential epoch is left out on purpose: an access-token refresh + // says nothing about whether the profile token is stale. + return current != null && + current.serverId == pinned.serverId && + current.identityGeneration == pinned.identityGeneration + transitionsSinceCheck + } + /** Clears the active profile selection and its token. */ suspend fun clearProfile() { identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileVerificationRecovery.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileVerificationRecovery.kt new file mode 100644 index 0000000000..6d112da84c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileVerificationRecovery.kt @@ -0,0 +1,180 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import org.siloserver.silo.network.AccessChangeSignals +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionPhase +import org.siloserver.silo.network.StaleProfileReport +import org.siloserver.silo.network.TokenManager + +/** + * The user must pick a profile (and enter its PIN) again because the server + * stopped accepting the active profile's proof on server [serverId]. + * + * The prompt stays valid while that server is active, signed in, and has no + * profile selected. Signing out, switching servers, or picking a profile ends + * it. Other identity transitions (a remote-playback overlay starting or ending, + * removing another server) leave the profile cleared, so they must not end it. + * + * [sequence] tells two prompts for the same server apart. + */ +data class ProfileVerificationPrompt( + val serverId: String?, + val sequence: Long, +) + +/** What the navigation layer does with a pending [ProfileVerificationPrompt]. */ +enum class ProfilePromptAction { + /** Send the user to profile selection now. */ + Navigate, + + /** Keep the prompt until the user leaves the current destination. */ + Defer, + + /** + * The user is already on the profile picker or one of its screens. Consume + * the prompt without navigating, so a PIN being typed is not thrown away; + * the picker reloads itself from [ProfileVerificationRecovery.profileCleared]. + */ + AlreadyThere, + + /** The prompt no longer applies; consume it without navigating. */ + Drop, +} + +/** + * Turns stale-profile refusals into one profile-selection prompt. + * + * Any v2 request can come back 403 `profile_verification_required` after an + * administrator changes the account's access: the server stops accepting PIN + * profile tokens minted before the change. Without this every screen showed a + * failed load. The recovery clears the stale profile selection (never the + * account session) through [ProfileRepository.clearStaleProfile], which ignores + * refusals for any identity other than the active one, and then raises + * [pending] for the phone and TV navigation to act on. + * + * Background work (downloads, outbox replay, Watch Next) reaches this too when + * it ran on the active identity, because that identity is just as stale. It + * never navigates by itself: navigation reads [pending] and defers while the + * player is in front, so a background refusal cannot pull the user out of + * playback. + */ +class ProfileVerificationRecovery( + private val signals: AccessChangeSignals, + private val tokenManager: TokenManager, + private val profileRepository: ProfileRepository, + identityTransitions: IdentityTransitionBarrier, + scope: CoroutineScope, +) { + private val _pending = MutableStateFlow<ProfileVerificationPrompt?>(null) + + /** The prompt waiting for the navigation layer, or null. */ + val pending: StateFlow<ProfileVerificationPrompt?> = _pending.asStateFlow() + + /** + * [pending], emitted again after every completed identity transition. The + * navigation layer collects this so a prompt deferred behind a + * remote-playback overlay is decided again when the overlay ends, even if + * the visible destination does not change. + */ + val promptChecks: Flow<ProfileVerificationPrompt?> = combine( + pending, + identityTransitions.transitions + .filter { it.phase == IdentityTransitionPhase.DID_CHANGE } + .map { it.generation } + .onStart { emit(identityTransitions.generation.value) }, + ) { prompt, _ -> prompt } + + private val _profileCleared = MutableSharedFlow<Unit>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Fires each time a stale profile is cleared. The profile pickers reload on + * it: a list fetched with the stale token failed, and the next one is sent + * without it. + */ + val profileCleared: SharedFlow<Unit> = _profileCleared.asSharedFlow() + + private var sequence = 0L + + init { + scope.launch { + // Serial on purpose: the first report clears the profile, and every + // later one for the same identity then finds nothing to clear. + signals.staleProfileReports.collect { report -> + try { + handle(report) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // A failing identity gate must not crash the app or end the + // collection; the next refusal tries again. + } + } + } + } + + internal suspend fun handle(report: StaleProfileReport) { + if (!profileRepository.clearStaleProfile(report)) return + _pending.value = ProfileVerificationPrompt( + serverId = tokenManager.snapshotCurrentScope()?.serverId ?: tokenManager.getCurrentServerId(), + sequence = ++sequence, + ) + _profileCleared.tryEmit(Unit) + } + + /** + * Whether [prompt] still applies: the same server is active and signed in, + * and no profile has been selected since. Call only while no + * remote-playback overlay is installed (the overlay reports its own + * identity, not the saved one). + */ + suspend fun isCurrent(prompt: ProfileVerificationPrompt): Boolean { + if (tokenManager.getAccessToken().isNullOrBlank()) return false + val scope = tokenManager.snapshotCurrentScope() + val serverId = scope?.serverId ?: tokenManager.getCurrentServerId() + val profileId = if (scope != null) scope.profileId else tokenManager.getProfileId() + return serverId == prompt.serverId && profileId.isNullOrBlank() + } + + /** + * Decides what to do with [prompt] while [currentRoute] is displayed. + * [deferRoutes] are destinations the prompt must not interrupt: playback, + * and the sign-in and server screens where no profile is in use. + * [pickerRoutes] are the profile picker and its create and edit screens. + */ + suspend fun actionFor( + prompt: ProfileVerificationPrompt, + currentRoute: String?, + deferRoutes: Set<String>, + pickerRoutes: Set<String> = emptySet(), + ): ProfilePromptAction = when { + // A remote-playback overlay owns identity until it ends; decide after. + tokenManager.hasTemporaryScope() -> ProfilePromptAction.Defer + !isCurrent(prompt) -> ProfilePromptAction.Drop + currentRoute == null || currentRoute in deferRoutes -> ProfilePromptAction.Defer + currentRoute in pickerRoutes -> ProfilePromptAction.AlreadyThere + else -> ProfilePromptAction.Navigate + } + + /** Marks [prompt] handled; a newer prompt raised meanwhile is kept. */ + fun consume(prompt: ProfileVerificationPrompt) { + _pending.compareAndSet(prompt, null) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModel.kt index baa2836323..dcda2a41bd 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModel.kt @@ -6,6 +6,8 @@ import org.siloserver.silo.model.personal.Collection import org.siloserver.silo.model.personal.CollectionGroup import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.CollectionRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,25 +71,32 @@ internal fun buildCollectionSections( class CollectionsViewModel( private val collectionRepository: CollectionRepository, ) : ViewModel() { + /** Access changes this ViewModel has applied, kept while its screen is away. */ + val accessChanges = org.siloserver.silo.network.AccessChangeCursor() private val _uiState = MutableStateFlow(CollectionsUiState()) val uiState: StateFlow<CollectionsUiState> = _uiState.asStateFlow() + // The newest load is the only one that publishes: a refresh after an + // access change replaces a load still waiting on the older policy. + // Declared before init, which starts the first load. + private var loadJob: Job? = null + init { loadCollections() } - fun loadCollections() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - applyResult(collectionRepository.listCollections(), refreshing = false) - } - } + fun loadCollections() = load(refreshing = false) + + fun refresh() = load(refreshing = true) - fun refresh() { - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true) } - applyResult(collectionRepository.listCollections(), refreshing = true) + private fun load(refreshing: Boolean) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + _uiState.update { if (refreshing) it.copy(isRefreshing = true) else it.copy(isLoading = true, error = null) } + val result = collectionRepository.listCollections() + ensureActive() + applyResult(result, refreshing) } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt index 21f3a64c52..38b3b7ceca 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt @@ -103,6 +103,14 @@ class HomeViewModel( private var realtimeRefreshInFlight = false + /** + * A realtime signal arrived while a fetch was running. That fetch may have + * been answered before the event behind the signal (an access change made + * after the request went out), so one more realtime refresh runs when it + * finishes. + */ + private var realtimeRefreshPending = false + /** * Bumped by every fetch, checked before any of them publishes. * @@ -117,12 +125,13 @@ class HomeViewModel( private var fetchGeneration = 0 /** - * Debounced realtime refetch: quiet (no spinner) and single-flight — - * an in-flight realtime or manual refresh already delivers the fresh - * sections, so overlapping signals are dropped rather than raced. + * Debounced realtime refetch: quiet (no spinner) and single-flight. + * Signals that arrive while a realtime or manual refresh is running are + * not raced against it; they collapse into one follow-up refresh after it. */ fun refreshFromRealtime() { if (realtimeRefreshInFlight || _uiState.value.isRefreshing) { + realtimeRefreshPending = true diagnostics.completed( HomeLoadObservation( trigger = HomeLoadTrigger.REALTIME, @@ -143,9 +152,16 @@ class HomeViewModel( } finally { realtimeRefreshInFlight = false } + runPendingRealtimeRefresh() } } + private fun runPendingRealtimeRefresh() { + if (!realtimeRefreshPending) return + realtimeRefreshPending = false + refreshFromRealtime() + } + fun loadSections() { viewModelScope.launch { // Stale-while-revalidate: serve the cached home instantly (offline- @@ -213,6 +229,7 @@ class HomeViewModel( // it fires a redundant request. if (generation == fetchGeneration) { _uiState.update { it.copy(isRefreshing = false) } + runPendingRealtimeRefresh() } } } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/AccessChangeCursorTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/AccessChangeCursorTest.kt new file mode 100644 index 0000000000..b906dc0c78 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/AccessChangeCursorTest.kt @@ -0,0 +1,87 @@ +package org.siloserver.silo.network + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A ViewModel's cursor carries access changes across the times its screen is + * out of composition, so returning to the screen applies a change reported + * while another destination was in front. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AccessChangeCursorTest { + + @Test + fun `the first collection does not refresh`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + signals.reportAccessChanged() + val cursor = AccessChangeCursor() + + assertEquals(0, collectWhileVisible(signals, cursor) {}) + } + + @Test + fun `a change reported while away refreshes on return`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val cursor = AccessChangeCursor() + collectWhileVisible(signals, cursor) {} + + // The screen is out of composition (detail in front) when it arrives. + signals.reportAccessChanged() + + assertEquals(1, collectWhileVisible(signals, cursor) {}) + assertEquals(0, collectWhileVisible(signals, cursor) {}, "an applied change does not refresh twice") + } + + @Test + fun `the second socket's report of an applied change does not refresh on return`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val cursor = AccessChangeCursor() + val whileVisible = collectWhileVisible(signals, cursor) { + signals.reportAccessChanged() + } + assertEquals(1, whileVisible) + + // The other events socket reports the same change seconds later, while + // the screen is away. + testScheduler.advanceTimeBy(12_000L) + signals.reportAccessChanged() + + assertEquals(0, collectWhileVisible(signals, cursor) {}) + } + + @Test + fun `a later change while away refreshes on return`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val cursor = AccessChangeCursor() + collectWhileVisible(signals, cursor) { signals.reportAccessChanged() } + + testScheduler.advanceTimeBy(AccessChangeSignals.ACCESS_CHANGE_COALESCE_MS) + signals.reportAccessChanged() + + assertEquals(1, collectWhileVisible(signals, cursor) {}) + } + + /** Collects with [cursor] like a composed screen, runs [whileVisible], then leaves. */ + private fun TestScope.collectWhileVisible( + signals: AccessChangeSignals, + cursor: AccessChangeCursor, + whileVisible: () -> Unit, + ): Int { + var refreshes = 0 + val job = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.changes(cursor).collect { refreshes++ } + } + testScheduler.runCurrent() + whileVisible() + testScheduler.runCurrent() + job.cancel() + testScheduler.runCurrent() + return refreshes + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginTokenRefreshRequiredTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginTokenRefreshRequiredTest.kt new file mode 100644 index 0000000000..ffca7397fa --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginTokenRefreshRequiredTest.kt @@ -0,0 +1,124 @@ +package org.siloserver.silo.network + +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.client.request.get +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.CoroutineStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * After an administrator changes an account's role, the server rejects access + * tokens minted before the change with 401 `token_refresh_required`. The refresh + * token still works, so the client must refresh quietly and retry once — never + * end the session. + */ +class SiloAuthPluginTokenRefreshRequiredTest { + + private val tokenRefreshRequired = + """{"type":"https://siloserver.org/problems/token_refresh_required","title":"Unauthorized","status":401,"detail":"Refresh the access token."}""" + + @Test + fun `a role change refreshes and retries without signing out`() = runTest { + val tokens = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("pre-change-access", "refresh-token", 3_600) + setProfileIdentity("kid", "pin-token") + } + var sessionExpiredCount = 0 + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + tokens.sessionExpired.collect { sessionExpiredCount++ } + } + val sent = mutableListOf<Pair<String, String?>>() + val client = HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + when { + request.url.encodedPath.endsWith("/auth/refresh") -> respond( + content = """{"access_token":"post-change-access","refresh_token":"rotated-refresh","expires_in":3600}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + request.headers[HttpHeaders.Authorization] == "Bearer pre-change-access" -> respond( + content = tokenRefreshRequired, + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + else -> respond( + content = "{}", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + + val response = client.get("/api/v2/home/sections") + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals( + listOf( + "/api/v2/home/sections" to "Bearer pre-change-access", + "/api/v2/auth/refresh" to null, + "/api/v2/home/sections" to "Bearer post-change-access", + ), + sent, + ) + assertEquals("post-change-access", tokens.getAccessToken()) + assertEquals("rotated-refresh", tokens.getRefreshToken()) + assertEquals("kid", tokens.getProfileId(), "the profile selection survives") + assertEquals(0, sessionExpiredCount, "the user must not be sent to sign-in") + } + + @Test + fun `a pinned background request refreshes its own scope on a role change`() = runTest { + val scope = AuthScopeSnapshot( + serverId = "server-1", + profileId = "kid", + serverUrl = "https://silo.example", + profileToken = "pin-token", + ) + val tokens = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("pre-change-access", "refresh-token", 3_600) + } + val client = HttpClient( + MockEngine { request -> + when { + request.url.encodedPath.endsWith("/auth/refresh") -> respond( + content = """{"access_token":"post-change-access","refresh_token":"rotated-refresh","expires_in":3600}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + request.headers[HttpHeaders.Authorization] == "Bearer pre-change-access" -> respond( + content = tokenRefreshRequired, + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + else -> respond("{}", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = tokens } + } + + val response = client.get("https://silo.example/api/v2/playback/progress") { authScope(scope) } + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("post-change-access", tokens.getAccessToken()) + assertTrue(!tokens.getRefreshToken().isNullOrBlank()) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/StaleProfileDetectionTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/StaleProfileDetectionTest.kt new file mode 100644 index 0000000000..cc90781e52 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/StaleProfileDetectionTest.kt @@ -0,0 +1,147 @@ +package org.siloserver.silo.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.safeApiV2Call +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The v2 error decoder reports a 403 `profile_verification_required` together + * with the profile identity the refused request presented, so the recovery can + * tell a stale token on the active profile from everything else. + */ +class StaleProfileDetectionTest { + + private fun problem(code: String) = + """{"type":"https://siloserver.org/problems/$code","title":"Forbidden","status":403,"detail":"x"}""" + + private suspend fun tokens() = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("access", "refresh", 3_600) + setProfileIdentity("kid", "pin-token") + } + + private fun client( + tokens: TokenManager, + signals: AccessChangeSignals?, + code: String = PROFILE_VERIFICATION_REQUIRED, + ) = HttpClient( + MockEngine { + respond( + content = problem(code), + status = HttpStatusCode.Forbidden, + headers = headersOf(HttpHeaders.ContentType, "application/problem+json"), + ) + }, + ) { + install(SiloAuthPlugin) { + tokenManager = tokens + accessChangeSignals = signals + } + } + + @Test + fun `a stale profile refusal is reported with the presented identity`() = runTest { + val signals = AccessChangeSignals() + val reports = mutableListOf<StaleProfileReport>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.staleProfileReports.collect { reports += it } + } + val client = client(tokens(), signals) + + val result = safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { client.get("/api/v2/home/sections") } + testScheduler.runCurrent() + + val error = assertIs<ApiResult.Error>(result) + assertEquals(403, error.code) + assertEquals(PROFILE_VERIFICATION_REQUIRED, error.error, "callers still see the error") + val report = reports.single() + assertEquals("kid", report.profileId) + assertEquals("pin-token", report.profileToken) + assertTrue(report.requestUrl.startsWith("https://silo.example/")) + } + + @Test + fun `other refusals and household management are not reported`() = runTest { + val signals = AccessChangeSignals() + val reports = mutableListOf<StaleProfileReport>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.staleProfileReports.collect { reports += it } + } + val tokens = tokens() + + val denied = client(tokens, signals, code = "permission_denied") + safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { denied.get("/api/v2/home/sections") } + // A non-primary profile editing the household gets the same problem + // type; that is a missing permission, not a stale token. + val stale = client(tokens, signals) + safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { stale.patch("/api/v2/profiles/parent") } + testScheduler.runCurrent() + + assertTrue(reports.isEmpty()) + } + + @Test + fun `a request without a profile is not reported`() = runTest { + val signals = AccessChangeSignals() + val reports = mutableListOf<StaleProfileReport>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.staleProfileReports.collect { reports += it } + } + val tokens = tokens().apply { setProfileIdentity(null, null) } + + safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { client(tokens, signals).get("/api/v2/home/sections") } + testScheduler.runCurrent() + + assertTrue(reports.isEmpty()) + } + + @Test + fun `a client without signals keeps the plain error`() = runTest { + val result = safeApiV2Call<Unit>(ApiV2Gate.Unrestricted) { + client(tokens(), signals = null).get("/api/v2/home/sections") + } + + assertEquals(PROFILE_VERIFICATION_REQUIRED, assertIs<ApiResult.Error>(result).error) + } + + @Test + fun `household management is told apart from profile reads`() { + assertTrue(isHouseholdManagementRequest(HttpMethod.Patch, "/api/v2/profiles/p1")) + assertTrue(isHouseholdManagementRequest(HttpMethod.Post, "/api/v2/profiles")) + assertTrue(isHouseholdManagementRequest(HttpMethod.Delete, "/api/v2/profiles/p1/avatar")) + assertTrue(isHouseholdManagementRequest(HttpMethod.Get, "/api/v2/profiles/household/sessions")) + // The picker's list and PIN entry fail on a stale token and must recover. + assertFalse(isHouseholdManagementRequest(HttpMethod.Get, "/api/v2/profiles")) + assertFalse(isHouseholdManagementRequest(HttpMethod.Post, "/api/v2/profiles/p1/verify-pin")) + assertFalse(isHouseholdManagementRequest(HttpMethod.Get, "/api/v2/home/sections")) + } + + @Test + fun `only an authoritative refusal counts as lost access`() { + assertTrue(ApiResult.Error(404, "not_found", "").isAccessRefusal()) + assertTrue(ApiResult.Error(410, "gone", "").isAccessRefusal()) + assertTrue(ApiResult.Error(403, "forbidden", "").isAccessRefusal()) + // A stale profile proof is recovered by re-verifying, not by hiding the title. + assertFalse(ApiResult.Error(403, PROFILE_VERIFICATION_REQUIRED, "").isAccessRefusal()) + assertFalse(ApiResult.Error(401, "unauthorized", "").isAccessRefusal()) + assertFalse(ApiResult.Error(503, "unavailable", "").isAccessRefusal()) + assertFalse(ApiResult.Error(0, "identity_changed", "").isAccessRefusal()) + assertFalse(ApiResult.NetworkError(RuntimeException("offline")).isAccessRefusal()) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt index 91bf126dc2..3a604c4f30 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt @@ -184,6 +184,30 @@ class CatalogRepositoryDetailCacheTest { assertEquals(1, calls) } + @Test + fun accessChangeReloadDoesNotJoinAnActiveWarmup() = runTest { + var calls = 0 + val entered = CompletableDeferred<Unit>() + val release = CompletableDeferred<Unit>() + val repository = gatedRepository( + requestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + onRequest = { calls += 1 }, + ) + + // The warm-up went out before the access change; the reload after it + // must send its own request. + val warmup = async { repository.warmItemDetail("c1") } + entered.await() + val reload = async { repository.getItemDetail("c1", joinWarmup = false) } + repeat(10) { yield() } + release.complete(Unit) + listOf(warmup, reload).awaitAll() + + assertEquals(2, calls) + } + @Test fun cancelingHomePrefetchDoesNotCancelDestinationDetailRequest() = runTest { var calls = 0 diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt index d6d1b8eda4..45a0892add 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HiddenLibrariesStoreTest.kt @@ -29,6 +29,7 @@ import org.siloserver.silo.network.api.SettingsApi import org.siloserver.silo.repository.port.CatalogCachePort import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse class HiddenLibrariesStoreTest { @@ -64,6 +65,8 @@ class HiddenLibrariesStoreTest { override suspend fun cacheLibraries(libraries: List<UserLibrary>) { cachedLibraries = libraries } + + override suspend fun getCachedLibraries(): List<UserLibrary>? = cachedLibraries } private var libraryRequests = 0 @@ -110,6 +113,22 @@ class HiddenLibrariesStoreTest { assertEquals(listOf(1), cache.cachedLibraries?.map { it.id }) } + @Test + fun foregroundCheckDoesNotReportALibraryTheProfileHidAsAnAccessChange() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + // The screens cached what they showed: library 2 is hidden. + val cache = FakeCache().apply { cachedLibraries = listOf(UserLibrary(id = 1, name = "Library 1", type = "movie")) } + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(librariesClient()), + catalogCache = cache, + identityTransitions = barrier, + hiddenLibraries = HiddenLibrariesStore(SettingsRepository(FakeSettings(ids(2))), barrier), + ) + + assertFalse(repository.libraryListChangedSinceCached()) + assertEquals(listOf(1), cache.cachedLibraries?.map { it.id }) + } + @Test fun recheckTreatsANewlyHiddenLibraryAsRemovedWithoutReadingTwice() = runTest { val barrier = DefaultIdentityTransitionBarrier() diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HomeRealtimeAccessChangeTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HomeRealtimeAccessChangeTest.kt new file mode 100644 index 0000000000..c768b89196 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/HomeRealtimeAccessChangeTest.kt @@ -0,0 +1,145 @@ +package org.siloserver.silo.repository + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.network.AccessChangeSignals +import org.siloserver.silo.network.HomeRealtimeClient +import org.siloserver.silo.network.HomeRealtimeEvent +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.apiv2.isAccessChangedClose +import org.siloserver.silo.network.apiv2.isAccessChangedFrame +import io.ktor.websocket.CloseReason +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeRealtimeAccessChangeTest { + + private class ScriptedClient(private val script: (Int) -> Flow<HomeRealtimeEvent>) : HomeRealtimeClient { + var connects = 0 + override fun connect(): Flow<HomeRealtimeEvent> = script(++connects) + } + + @Test + fun `an access change refreshes Home once per burst`() = runTest { + val signals = AccessChangeSignals() + val client = ScriptedClient { flow { awaitCancellation() } } + val coordinator = HomeRealtimeCoordinator(client, TokenManagerImpl(), signals) + var refreshes = 0 + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.refreshSignals.collect { refreshes++ } + } + testScheduler.runCurrent() + + // Both events sockets see the change, each reporting it once. + signals.reportAccessChanged() + signals.reportAccessChanged() + testScheduler.advanceTimeBy(10_000L) + + assertEquals(1, refreshes) + } + + @Test + fun `reports of one change from both sockets refresh once`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val changes = mutableListOf<Long>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.changes.collect { changes += testScheduler.currentTime } + } + testScheduler.runCurrent() + + // Each socket runs its own 15-second server check, so the second + // report of the same change can arrive seconds after the first. + signals.reportAccessChanged() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(12_000L) + signals.reportAccessChanged() + testScheduler.runCurrent() + assertEquals(listOf(0L), changes, "the first report refreshes at once; the second is the same change") + + // A later change refreshes again. + testScheduler.advanceTimeBy(AccessChangeSignals.ACCESS_CHANGE_COALESCE_MS) + signals.reportAccessChanged() + testScheduler.runCurrent() + assertEquals(2, changes.size) + } + + @Test + fun `a second change reported by a reconnected socket inside the window refreshes`() = runTest { + val signals = AccessChangeSignals { testScheduler.currentTime } + val changes = mutableListOf<Long>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + signals.changes.collect { changes += testScheduler.currentTime } + } + testScheduler.runCurrent() + val homeOpened = signals.connectionTime() + val notificationsOpened = signals.connectionTime() + + // Home reports the first edit and reconnects under the new policy. + testScheduler.advanceTimeBy(3_000L) + signals.reportAccessChanged(homeOpened) + testScheduler.runCurrent() + testScheduler.advanceTimeBy(1_500L) + val homeReopened = signals.connectionTime() + + // Notifications reports the same edit from its older connection. + testScheduler.advanceTimeBy(7_500L) + signals.reportAccessChanged(notificationsOpened) + testScheduler.runCurrent() + assertEquals(1, changes.size, "the older connection's report is the change already loaded") + + // A second edit, seen by the reconnected Home socket 15 s after it opened. + testScheduler.advanceTimeBy(4_000L) + signals.reportAccessChanged(homeReopened) + testScheduler.runCurrent() + assertEquals(2, changes.size, "a connection opened after the refresh reports a new change") + } + + @Test + fun `nothing refreshes without an access change`() = runTest { + val signals = AccessChangeSignals() + val coordinator = HomeRealtimeCoordinator(ScriptedClient { flow { awaitCancellation() } }, TokenManagerImpl(), signals) + var refreshes = 0 + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.refreshSignals.collect { refreshes++ } + } + + testScheduler.advanceTimeBy(10_000L) + + assertEquals(0, refreshes) + } + + @Test + fun `the socket reconnects promptly after an access-changed close`() = runTest { + // First connection: subscribed, then the server's 4001 close ends it. + val client = ScriptedClient { attempt -> + if (attempt == 1) flowOf(HomeRealtimeEvent.Connected, HomeRealtimeEvent.Closed()) + else flow { awaitCancellation() } + } + val coordinator = HomeRealtimeCoordinator(client, TokenManagerImpl(), AccessChangeSignals()) + val job = coordinator.connect(backgroundScope) + + testScheduler.advanceTimeBy(1_100L) + + assertEquals(2, client.connects, "a fresh connection (and ticket) after the initial backoff") + job.cancel() + } + + @Test + fun `access-change frames and close codes are recognised`() { + assertTrue(isAccessChangedFrame("""{"type":"access_changed"}""")) + assertFalse(isAccessChangedFrame("""{"type":"subscribed","channel":"catalog"}""")) + assertFalse(isAccessChangedFrame("not json")) + assertTrue(isAccessChangedClose(CloseReason(4001, "access_changed"))) + assertFalse(isAccessChangedClose(CloseReason(CloseReason.Codes.NORMAL, ""))) + assertFalse(isAccessChangedClose(null)) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/NotificationsRepositoryTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/NotificationsRepositoryTest.kt index 23ba5d6104..b2e69a9861 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/NotificationsRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/NotificationsRepositoryTest.kt @@ -401,7 +401,7 @@ class NotificationsRepositoryTest { assertFalse(NotificationRealtimeEvent.Read("x").isAuthClose()) } - // ---- connectRealtime: auth close stops loop and sets realtimeFatal ------- + // ---- connectRealtime: auth close parks the loop until the identity changes ---- @Test fun `connectRealtime stops reconnecting and sets realtimeFatal on auth Closed`() = @@ -420,20 +420,86 @@ class NotificationsRepositoryTest { } val repo = NotificationsRepository(api, realtimeFactory = { realtime }) val job = repo.connectRealtime(this) - // Give the loop a chance to run. Because reconnect uses delay, and - // testScheduler advances time, we advance past the initial backoff. - testScheduler.advanceTimeBy(2_000L) + // Well past every backoff step: a parked loop must not retry the + // identity that was just refused. + testScheduler.advanceTimeBy(120_000L) kotlinx.coroutines.yield() - job.cancel() - // connect() must have been called exactly once — the loop must not - // have retried after an auth-class Closed. assertEquals(1, connectCount, "Auth-close must stop the reconnect loop (connectCount=$connectCount)") assertTrue(repo.realtimeFatal.value, "realtimeFatal must be true after an auth-class Closed") + assertTrue(job.isActive, "the loop parks rather than ending") + job.cancel() + } + + @Test + fun `connectRealtime resumes after a stale-profile refusal once the profile is re-verified`() = + kotlinx.coroutines.test.runTest { + val api = FakeNotificationsApi() + var connectCount = 0 + val realtime = object : org.siloserver.silo.network.NotificationsRealtimeClient { + override fun connect(): kotlinx.coroutines.flow.Flow<NotificationRealtimeEvent> { + connectCount++ + return if (connectCount == 1) { + // The ticket mint was refused: the profile token is stale. + kotlinx.coroutines.flow.flowOf(NotificationRealtimeEvent.Closed("ticket_error_403")) + } else { + kotlinx.coroutines.flow.flow { awaitCancellation() } + } + } + } + val repo = NotificationsRepository(api, realtimeFactory = { realtime }) + val job = repo.connectRealtime(this) + testScheduler.advanceTimeBy(60_000L) + kotlinx.coroutines.yield() + assertEquals(1, connectCount) + assertTrue(repo.realtimeFatal.value) + + // Re-entering the PIN (or picking another profile) resets the + // repository, which is the identity change the loop waits for. + repo.reset() + testScheduler.runCurrent() + + assertEquals(2, connectCount, "the loop must reconnect with a fresh ticket") + assertFalse(repo.realtimeFatal.value) + job.cancel() + } + + @Test + fun `connectRealtime treats an access-changed close as an ordinary reconnect`() = + kotlinx.coroutines.test.runTest { + val api = FakeNotificationsApi() + var connectCount = 0 + val realtime = object : org.siloserver.silo.network.NotificationsRealtimeClient { + override fun connect(): kotlinx.coroutines.flow.Flow<NotificationRealtimeEvent> { + connectCount++ + return if (connectCount == 1) { + // The server's 4001 close ends the socket cleanly. + kotlinx.coroutines.flow.flowOf( + NotificationRealtimeEvent.Snapshot(emptyList()), + NotificationRealtimeEvent.Closed(), + ) + } else { + kotlinx.coroutines.flow.flow { awaitCancellation() } + } + } + } + val repo = NotificationsRepository(api, realtimeFactory = { realtime }) + val job = repo.connectRealtime(this) + testScheduler.advanceTimeBy(1_100L) + kotlinx.coroutines.yield() + + assertEquals(2, connectCount, "a clean close reconnects after the initial backoff") + assertFalse(repo.realtimeFatal.value) + job.cancel() } + @Test + fun `access_changed close reasons are not auth closes`() { + assertFalse(NotificationRealtimeEvent.Closed("access_changed").isAuthClose()) + } + // ---- connectRealtime: backoff resets on first event of each connect ------ @Test diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt index 54dd6c6645..e4a3b810f7 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/PersonalDataRepositoryCacheTest.kt @@ -20,6 +20,8 @@ import org.siloserver.silo.network.api.PersonalDataApi import org.siloserver.silo.repository.port.CatalogCachePort import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class PersonalDataRepositoryCacheTest { @@ -29,6 +31,49 @@ class PersonalDataRepositoryCacheTest { override suspend fun cacheLibraries(libraries: List<UserLibrary>) { cachedLibraries = libraries } + + override suspend fun getCachedLibraries(): List<UserLibrary>? = cachedLibraries + } + + private fun librariesClient(vararg ids: Int) = HttpClient( + MockEngine { + val items = ids.joinToString(",") { """{"id":"$it","name":"Library $it","type":"movie","sort_order":0}""" } + respond( + """{"items":[$items]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + } + + private fun library(id: Int) = UserLibrary(id = id, name = "Library $id", type = "movie") + + @Test + fun libraryListChangeIsReportedAgainstTheCachedList() = runTest { + val cache = FakeCache().apply { cachedLibraries = listOf(library(1), library(2)) } + val repository = PersonalDataRepository(PersonalDataApi(librariesClient(1)), catalogCache = cache) + + assertTrue(repository.libraryListChangedSinceCached()) + assertEquals(listOf(1), cache.cachedLibraries?.map { it.id }) + // The fresh list is now the baseline, so the same answer is no change. + assertFalse(repository.libraryListChangedSinceCached()) + } + + @Test + fun libraryListWithTheSameLibrariesInAnotherOrderIsNoChange() = runTest { + val cache = FakeCache().apply { cachedLibraries = listOf(library(1), library(2)) } + val repository = PersonalDataRepository(PersonalDataApi(librariesClient(2, 1)), catalogCache = cache) + + assertFalse(repository.libraryListChangedSinceCached()) + } + + @Test + fun libraryListWithoutACachedListIsNoChange() = runTest { + val repository = PersonalDataRepository(PersonalDataApi(librariesClient(1)), catalogCache = FakeCache()) + + assertFalse(repository.libraryListChangedSinceCached()) } @Test diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileVerificationRecoveryTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileVerificationRecoveryTest.kt new file mode 100644 index 0000000000..a498059776 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileVerificationRecoveryTest.kt @@ -0,0 +1,378 @@ +package org.siloserver.silo.repository + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.network.AccessChangeSignals +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionBarrier +import org.siloserver.silo.network.IdentityTransitionKind +import org.siloserver.silo.network.ProfileIdentity +import org.siloserver.silo.network.StaleProfileReport +import org.siloserver.silo.network.TemporaryAuthScope +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.ProfileApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A stale PIN profile token (403 `profile_verification_required`) must send the + * user back to profile selection without signing them out, and only for the + * identity that is actually active. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileVerificationRecoveryTest { + + private val serverUrl = "https://silo.example" + + private class ScopedTokenManager( + private val barrier: IdentityTransitionBarrier, + private val delegate: TokenManagerImpl = TokenManagerImpl(), + ) : TokenManager by delegate { + override suspend fun snapshotCurrentScope() = AuthScopeSnapshot( + serverId = "server-1", + profileId = delegate.getProfileId(), + serverUrl = delegate.getServerUrl(), + profileToken = delegate.getProfileToken(), + identityGeneration = barrier.generation.value, + isIdentityGenerationStamped = true, + credentialEpoch = 1, + ) + + override suspend fun getCurrentServerId(): String = "server-1" + + // Interface delegation would read the delegate's identity otherwise. + override suspend fun getProfileIdentity(): ProfileIdentity = delegate.getProfileIdentity() + } + + private class Fixture( + val barrier: DefaultIdentityTransitionBarrier, + val tokens: ScopedTokenManager, + val profiles: ProfileRepository, + val recovery: ProfileVerificationRecovery, + ) + + private suspend fun kotlinx.coroutines.test.TestScope.fixture(): Fixture { + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopedTokenManager(barrier) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + val noOpClient = HttpClient(MockEngine { _ -> + respond("{}", HttpStatusCode.OK, headersOf("Content-Type", "application/json")) + }) + val profiles = ProfileRepository( + profileApi = ProfileApi(noOpClient, ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + profiles.selectProfile("kid", "stale-pin-token") + val recovery = ProfileVerificationRecovery(AccessChangeSignals(), tokens, profiles, barrier, backgroundScope) + return Fixture(barrier, tokens, profiles, recovery) + } + + private fun report( + profileId: String = "kid", + profileToken: String? = "stale-pin-token", + url: String = "$serverUrl/api/v2/home/sections", + pinned: AuthScopeSnapshot? = null, + ) = StaleProfileReport(url, profileId, profileToken, pinned) + + @Test + fun `a stale profile token clears the profile and keeps the sign-in`() = runTest { + val f = fixture() + + f.recovery.handle(report()) + + assertNull(f.tokens.getProfileId()) + assertNull(f.tokens.getProfileToken()) + assertEquals("access", f.tokens.getAccessToken(), "the account must stay signed in") + assertEquals("refresh", f.tokens.getRefreshToken()) + val prompt = assertNotNull(f.recovery.pending.value) + assertEquals( + ProfilePromptAction.Navigate, + f.recovery.actionFor(prompt, "home", deferRoutes = setOf("player")), + ) + } + + @Test + fun `repeated refusals for the same identity clear it once`() = runTest { + val f = fixture() + + f.recovery.handle(report()) + val generationAfterFirst = f.barrier.generation.value + // Every screen that loaded under the stale token reports it too. + repeat(3) { f.recovery.handle(report()) } + + assertEquals(generationAfterFirst, f.barrier.generation.value, "no further identity transitions") + } + + @Test + fun `a refusal for a token that was already replaced is ignored`() = runTest { + val f = fixture() + // The user re-entered the PIN before the late response arrived. + f.profiles.selectProfile("kid", "fresh-pin-token") + + f.recovery.handle(report(profileToken = "stale-pin-token")) + + assertEquals("kid", f.tokens.getProfileId()) + assertEquals("fresh-pin-token", f.tokens.getProfileToken()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `a refusal for another profile or server is ignored`() = runTest { + val f = fixture() + + f.recovery.handle(report(profileId = "parent")) + f.recovery.handle(report(url = "https://other.example/api/v2/home/sections")) + + assertEquals("kid", f.tokens.getProfileId()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `a pinned background request from an earlier identity is ignored`() = runTest { + val f = fixture() + val captured = f.tokens.snapshotCurrentScope() + // Same profile id and token, but the identity moved since the capture. + f.barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + + f.recovery.handle(report(pinned = captured)) + + assertEquals("kid", f.tokens.getProfileId()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `a pinned background request on the active identity recovers it`() = runTest { + val f = fixture() + + f.recovery.handle(report(pinned = f.tokens.snapshotCurrentScope())) + + assertNull(f.tokens.getProfileId()) + assertNotNull(f.recovery.pending.value) + } + + @Test + fun `a remote playback overlay is never cleared`() = runTest { + val f = fixture() + f.tokens.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay", + serverId = "server-1", + serverUrl = serverUrl, + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "kid", + profileToken = "stale-pin-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + + f.recovery.handle(report()) + + assertEquals("kid", f.tokens.getProfileId()) + assertNull(f.recovery.pending.value) + } + + @Test + fun `the prompt waits behind playback and drops once a profile is picked`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + + assertEquals( + ProfilePromptAction.Defer, + f.recovery.actionFor(prompt, "player", deferRoutes = setOf("player")), + ) + + f.profiles.selectProfile("kid", "fresh-pin-token") + assertEquals( + ProfilePromptAction.Drop, + f.recovery.actionFor(prompt, "home", deferRoutes = setOf("player")), + ) + } + + @Test + fun `the prompt drops after sign-out`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + + f.tokens.clearTokens() + + assertFalse(f.recovery.isCurrent(prompt)) + f.recovery.consume(prompt) + assertNull(f.recovery.pending.value) + } + + @Test + fun `unrelated identity transitions keep the prompt`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + + // For example another server removed, or the active one re-selected. + f.barrier.changing(IdentityTransitionKind.SERVER_REMOVE) { } + + assertEquals( + ProfilePromptAction.Navigate, + f.recovery.actionFor(prompt, "home", deferRoutes = setOf("player")), + ) + } + + @Test + fun `a remote playback overlay defers the prompt until it ends`() = runTest { + val f = fixture() + f.recovery.handle(report()) + val prompt = assertNotNull(f.recovery.pending.value) + f.tokens.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay", + serverId = "server-1", + serverUrl = serverUrl, + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "guest", + profileToken = "guest-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + + assertEquals(ProfilePromptAction.Defer, f.recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + + f.tokens.endTemporaryScope() + assertEquals(ProfilePromptAction.Navigate, f.recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + } + + @Test + fun `ending a remote playback overlay asks again without a navigation`() = runTest { + // Production shape: the token manager mutates identity through the same barrier. + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopedTokenManager(barrier, TokenManagerImpl(barrier)) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + val profiles = ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + profiles.selectProfile("kid", "stale-pin-token") + val recovery = ProfileVerificationRecovery(AccessChangeSignals(), tokens, profiles, barrier, backgroundScope) + recovery.handle(report()) + val prompt = assertNotNull(recovery.pending.value) + tokens.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay", + serverId = "server-1", + serverUrl = serverUrl, + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "guest", + profileToken = "guest-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + val checks = mutableListOf<ProfileVerificationPrompt?>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + recovery.promptChecks.collect { checks += it } + } + testScheduler.runCurrent() + assertEquals(listOf<ProfileVerificationPrompt?>(prompt), checks) + assertEquals(ProfilePromptAction.Defer, recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + + tokens.endTemporaryScope() + testScheduler.runCurrent() + + assertTrue(checks.size > 1, "the overlay ending re-emits the pending prompt") + assertEquals(prompt, checks.last()) + assertEquals(ProfilePromptAction.Navigate, recovery.actionFor(prompt, "home", deferRoutes = emptySet())) + } + + @Test + fun `on the picker the prompt is consumed without navigating`() = runTest { + val f = fixture() + val cleared = mutableListOf<Unit>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + f.recovery.profileCleared.collect { cleared += it } + } + f.recovery.handle(report()) + testScheduler.runCurrent() + val prompt = assertNotNull(f.recovery.pending.value) + + assertEquals( + ProfilePromptAction.AlreadyThere, + f.recovery.actionFor(prompt, "profiles", deferRoutes = emptySet(), pickerRoutes = setOf("profiles")), + ) + assertEquals(1, cleared.size, "the picker is told to reload its grid") + } + + @Test + fun `a failing clear does not stop later reports`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + var failNext = true + barrier.installGate { + if (failNext) { + failNext = false + error("gate failed") + } + } + val tokens = ScopedTokenManager(barrier) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + tokens.setProfileIdentity("kid", "stale-pin-token") + val profiles = ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + val signals = AccessChangeSignals() + val recovery = ProfileVerificationRecovery(signals, tokens, profiles, barrier, backgroundScope) + testScheduler.runCurrent() + + signals.reportStaleProfile(report()) + testScheduler.runCurrent() + assertEquals("kid", tokens.getProfileId(), "the failed attempt changed nothing") + + signals.reportStaleProfile(report()) + testScheduler.runCurrent() + assertNull(tokens.getProfileId()) + assertNotNull(recovery.pending.value) + } + + @Test + fun `reports reach the recovery through the signals`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopedTokenManager(barrier) + tokens.setServerUrl(serverUrl) + tokens.saveTokens("access", "refresh", 3_600) + val profiles = ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = tokens, + identityTransitions = barrier, + ) + profiles.selectProfile("kid", "stale-pin-token") + val signals = AccessChangeSignals() + val recovery = ProfileVerificationRecovery(signals, tokens, profiles, barrier, backgroundScope) + testScheduler.runCurrent() + + signals.reportStaleProfile(report()) + testScheduler.runCurrent() + + assertNull(tokens.getProfileId()) + assertTrue(recovery.pending.value != null) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModelLoadOrderTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModelLoadOrderTest.kt new file mode 100644 index 0000000000..753425cf01 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/CollectionsViewModelLoadOrderTest.kt @@ -0,0 +1,68 @@ +package org.siloserver.silo.viewmodel + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.headersOf +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +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 kotlinx.coroutines.withContext +import org.siloserver.silo.network.api.CollectionApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.CollectionRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CollectionsViewModelLoadOrderTest { + + @Test + fun anOlderLoadDoesNotOverwriteTheRefreshThatReplacedIt() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val firstEntered = CompletableDeferred<Unit>() + val releaseFirst = CompletableDeferred<Unit>() + var calls = 0 + val client = HttpClient( + MockEngine { + val call = ++calls + val id = if (call == 1) { + firstEntered.complete(Unit) + releaseFirst.await() + "before" + } else { + "after" + } + respond( + """{"items":[{"id":"$id","name":"$id"}],"groups":[]}""", + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) + try { + val viewModel = CollectionsViewModel(CollectionRepository(CollectionApi(client, ApiV2Gate.Unrestricted))) + withContext(Dispatchers.Default) { firstEntered.await() } + + // An access change refreshes while the initial load still waits on + // the server's answer under the old policy. + viewModel.refresh() + withContext(Dispatchers.Default) { + viewModel.uiState.first { state -> state.collections.map { it.id } == listOf("after") } + releaseFirst.complete(Unit) + delay(200) + } + + assertEquals(listOf("after"), viewModel.uiState.value.collections.map { it.id }) + } finally { + client.close() + Dispatchers.resetMain() + } + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt index 24e3f62107..2d1b060e96 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt @@ -125,6 +125,47 @@ class HomeViewModelCacheIdentityTest { assertEquals(listOf(0, 0), observations.map { it.sectionCount }) } + @Test + fun realtimeSignalsDuringARefreshRunOneMoreRefreshAfterIt() = runTest(dispatcher) { + var requests = 0 + val realtimeEntered = CompletableDeferred<Unit>() + val releaseRealtime = CompletableDeferred<Unit>() + val followUpEntered = CompletableDeferred<Unit>() + val client = HttpClient( + MockEngine { + when (++requests) { + 2 -> { + realtimeEntered.complete(Unit) + releaseRealtime.await() + } + 3 -> followUpEntered.complete(Unit) + } + respond( + """{"sections":[]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(SiloJson) } + } + val viewModel = HomeViewModel( + sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + mediaActions = mediaActions(), + ) + viewModel.uiState.first { !it.isLoading } + + viewModel.refreshFromRealtime() + realtimeEntered.await() + // An access change reported while that fetch is out: its answer may + // predate the change. Repeated signals collapse into one follow-up. + viewModel.refreshFromRealtime() + viewModel.refreshFromRealtime() + releaseRealtime.complete(Unit) + + followUpEntered.await() + } + private class RecordingHomeCache : HomeCachePort { var sections: List<ResolvedSection>? = null From 95a6f21c136311290553aa8b347ebfb283f3507e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:35:37 -0400 Subject: [PATCH 76/87] feat(settings): hide spoilers for unwatched episodes, per profile (#427) * feat(settings): hide spoilers for unwatched episodes, per profile Adopt the settings contract at revision 15, which adds the profile settings catalog.hide_unwatched_episode_images and catalog.hide_unwatched_episode_overviews. When a profile turns them on, the phone and TV apps blur the still and hide the description of episodes the profile has not started: episode rails, the series hero, episode detail, Continue Watching / Next Up cards, the TV home marquee, and the players' next-episode screens. A Spoilers group in Settings holds the two switches, shown only when the server serves revision 15. Below API 31, where Modifier.blur does nothing, a hidden still shows only its ThumbHash. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(settings): reload spoiler settings when the server or profile changes The spoiler store remembered only that it had loaded, not for which server and profile. After Add Server and sign-in, it kept the previous server's answer until the app restarted. It now follows the active server and profile and ties each load to them. The series hero also no longer reserves an empty block where a hidden episode description would have been. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(settings): preserve episode spoiler protection across updates Cancel obsolete hydration, preserve pending toggles, and cache writes for their original profile. Rebuild TV marquee projections when spoiler preferences change and protect episode detail fallback artwork. Co-Authored-By: Codex (gpt-6.1-sol) <noreply@openai.com> * fix(tv): refresh marquee spoilers after watch updates Refresh displayed and pending marquee sources from their own current rows while preserving focus and enrichment. Cover watch changes and duplicate episode IDs across rows. Co-Authored-By: Codex (gpt-6.1-sol) <noreply@openai.com> * fix(spoilers): preserve series artwork with v2 provenance Carry selected image provenance through phone and TV cards, episode rails, marquee projections, detail fallbacks, and player next-up. Keep series artwork visible and protect older responses conservatively. Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com> * fix(settings): require revision 16 for spoiler keys Preserve revision 15 for the watchlist settings already published on server main. Refresh generated bindings and fixture records from the integrated server contract. Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com> * fix(downloads): protect unwatched episode artwork Persist artwork provenance and episode user data, protect inherited headers, and apply current local watch and resume state. Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com> * fix(tv): publish safe artwork for protected Watch Next episodes Resolve profile preferences in background sync, use safe series artwork, and fence obsolete publication. Clear and reseed tiles when image protection changes. Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com> * fix(downloads): refresh episode watch state before artwork projection Use authenticated server snapshots while preserving pending and newer local writes. Fence concurrent section builds so older snapshots cannot overwrite a refresh or deletion. Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com> * fix(tv): preserve Watch Next tiles on ordinary startup Wait for known preferences and persist the last image-protection value. Clear only when protection becomes enabled; verify that unchanged settings preserve the publication generation across seeder recreation. Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com> * fix: track watch and playback mutations separately * fix: protect episode artwork in catalog grids * fix: clear catalog progress on unwatched resets * fix(settings): treat spoiler keys the server refuses as unsupported A server can reach the gating settings revision without these keys: server main took revision 16 for another setting. Its batched effective read then answers 404 unknown_setting, which the store kept as a transient error, so support stayed Unknown, the Spoilers settings stayed hidden, and the TV Watch Next worker retried forever. A 404 now means unsupported, as it already does for the capabilities call. Also point stale "revision 15" comments at the gating constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(downloads): read episode watch state only for image protection - Skip the per-episode watch reads and the Room overlay unless image protection is on, fetch when it turns on, and let pull-to-refresh reconcile download records without waiting for those reads. - Wait for the first load instead of publishing an empty list, which flashed the empty state when the tab opened. - Build sections one at a time, so a build started from a sidecar map that a reload is replacing can't win publication. - Season and series rows name the download their poster came from, so a season showing another season's still is still protected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): keep Watch Next safe through failed lookups and interrupted wipes A failed series lookup was treated as "no safe artwork", so the run omitted the protected tile and deleted it from the launcher. It now fails the run so the worker retries. Enabling image protection recorded the setting before the tile wipe finished; a process death in between skipped the wipe on the next start. The setting is now recorded after the wipe completes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep resume guards for rows written before positionUpdatedAtMs The migration adds positionUpdatedAtMs without a backfill. A delayed watched acknowledgement treated those rows as older and could clear a newer resume sample, and a rejected watched write queued before the upgrade could not restore the resume point it cleared. Both guards now fall back to clientUpdatedAtMs for such rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mobile): reset episode progress when marking it watched or unwatched The detail page changed only the played flag, so an in-progress episode marked watched and then unwatched kept its old progress, counted as started, and showed its still despite image protection. Both changes now clear progress, as the server does and the TV app already did, and a failed write restores the exact previous state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): rebuild the hero backdrop when the item's watch state changes Keyed on the content id, the hero kept the spoiler decision it made when the item first appeared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(settings): gate spoiler settings on contract revision 17 Server #1709 now builds on server main, where revision 16 belongs to other settings, and adds the two spoiler keys in revision 17. Raise the gate to 17, regenerate SettingKeys, and re-vendor the settings and API v2 fixtures from silo-server 51953b6c1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(player): keep shuffled movie overviews visible with spoiler protection Shuffle post-roll built its next-item state without the unwatched flag, so the default (unwatched) hid every shuffled movie's overview when episode descriptions were hidden. A shuffle pick carries no watch state, so episodes stay protected and movies never are, on phone and TV. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spoilers): honor artwork provenance and close review races - Detail heroes (phone compact and expanded, TV) and the TV marquee's enrichment backdrop now follow the displayed artwork's backdrop_is_episode_still flag instead of assuming every detail backdrop is series art. - A Watch Next wipe that finishes after a newer protection change no longer records its older hide_images value. - A server or profile switch shows the new identity's cached answer right away, and a superseded refresh releases its lock before its second request. - A failed watched toggle undoes only the fields it changed, and only while they still show, so a newer reload survives. - Shuffle post-roll uses the pick's own watch state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(detail): undo a failed watched toggle even after new progress A return refresh can overlay a newer playback position on the optimistic watched state. The rollback then skipped the whole snapshot and left the episode marked watched after the write failed. Restore played while it still shows the attempted value, and restore progress only while it still shows the reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): pin spoiler writes to the profile that made them A toggle queued behind another write was sent with whatever auth scope was current when the request ran, so a server or profile switch in between wrote the old choice to the new profile. Capture the auth scope with the store's identity and pass it to every write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): pin the spoiler value read to the captured profile The refresh resolved both keys against whatever profile was active when the request ran, while its answer is cached and written back under the identity captured at the start. Pass the same captured auth scope to the effective-values read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(watch-party): apply spoiler protection to party previews The picker's confirmation page, the staged title in the lobby and the player panel, the invite page, and suggestion posters showed an unwatched episode's still and description even with protection on. They now follow the same rule as every other surface, from this profile's watch state and each artwork slot's provenance, on phone and TV. A title known only by type counts as an unstarted episode, as a suggestion does. The staged detail is read again when the room changes phase, so a title the party has played stops being hidden. Matches the Watch Party coverage in Silo-Server/silo-apple#559. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(watch-party): keep protected stills hidden while artwork changes The phone room backdrop crossfaded on the URL alone, so an outgoing protected still was drawn with the incoming artwork's treatment for the length of the fade. Each backdrop now carries its own thumbhash, poster blur, and hidden state through the crossfade. The TV invite page opened from the player left the staged backdrop unprotected. It now receives the spoiler decision, which every caller must pass, and that overlay re-reads the staged detail on phase changes like the lobby. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(watch-party): hide the current backdrop at once when protection turns on With each crossfade layer keeping its own hidden state, turning protection on for an unchanged artwork faded the sharp still out over 400 ms. A layer showing the current artwork now takes the current protection immediately; a different outgoing artwork keeps its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spoilers): close three remaining protection gaps - Home and the TV detail page now clear resume progress when an episode is marked unwatched, as the server does. Before, the episode still counted as started and its still stayed visible until a refresh. - The phone Watch Party picker's shelf and search tiles hide an unwatched episode's still, not only the confirmation page. - A forced profile-verification recovery clears the spoiler store on phone and TV, like Switch Profile, so the next profile never starts from the previous profile's answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(spoilers): keep cast identities off the launcher and report failed saves - While a phone's cast identity is the TV's temporary scope, the launcher effect no longer seeds Watch Next and the sync worker skips, so the phone profile's titles never replace the TV owner's row. The TV profile seeds again when the cast ends. - The TV marquee and root backdrop crossfades show the current item's protected copy at once when protection turns on for the focused episode, instead of fading the sharp still and synopsis out. - A failed spoiler-switch save now explains itself in Settings on phone and TV, as title art and skip intervals do, instead of silently snapping back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): apply a protection change to departing marquee layers A protection change during a crossfade between two items left the outgoing item's sharp still and synopsis fading out. Each departing layer is now re-derived from its source item when the current preferences change what it shows, and left as is otherwise, so enrichment crossfades are unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): keep safe enriched artwork when protecting departing layers underPrefs rebuilt an episode with no description on every call, since it compared the overview switch rather than whether a description was hidden. On the root backdrop that dropped the enriched series art. Protection turning on also rebuilt from the section item, losing an explicit series backdrop. Compare what is actually hidden, drop only what the preferences now hide (using the shown backdrop's provenance), rebuild only to reveal, and apply it only to departing layers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): fence Watch Next writes and picker spoilers to this profile - The Watch Next sync now treats a phone's temporary cast identity as an invalid owner at every authority check, including the final write, so a handoff that starts mid-run cannot publish the phone profile's titles to the TV launcher. - The TV Watch Party picker decides spoiler protection from this profile's own watch state. Its shelves carry the room's progress for the progress bar, which let another member's progress reveal a still and left this profile's own started search results blurred. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): use this profile's watch state for poster cards too The party picker's own-progress override reached the backdrop and reference-shelf cards but not the default poster card, which still read the room's progress. Each row item now computes one unwatched decision and derives both the still and the poster protection from it. The Watch Next authority fence also runs the suspending identity lookup before validating the captured owner, so the owner is checked last. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com> --- .../15.json | 1024 +++++++++++++++++ .../silo/common/cards/EpisodeSpoilerLocals.kt | 39 + .../silo/common/data/db/SiloDatabase.kt | 3 +- .../common/data/db/dao/ContentItemStateDao.kt | 2 +- .../common/data/db/dao/DirtyOperationDao.kt | 10 + .../common/data/db/dao/UserItemStateDao.kt | 21 +- .../data/db/entity/ContentItemStateEntity.kt | 2 + .../common/data/db/entity/DownloadEntity.kt | 2 + .../data/db/entity/UserItemStateEntity.kt | 2 + .../repository/RoomUserItemStateRepository.kt | 22 +- .../data/sync/ContentProjectionRevert.kt | 1 + .../silo/common/data/sync/OutboxOperation.kt | 1 + .../silo/common/di/PlayerInfraModule.kt | 24 + .../silo/common/downloads/DownloadEnqueuer.kt | 8 + .../downloads/DownloadSidecarMapping.kt | 7 + .../common/downloads/DownloadTileArtwork.kt | 12 +- .../common/settings/EpisodeSpoilerStore.kt | 457 ++++++++ .../settings/ServerDrivenConfigRefresher.kt | 2 + .../silo/common/ui/components/SpoilerImage.kt | 106 ++ .../data/db/SiloDatabaseMigrationTest.kt | 37 + .../RoomUserItemStateRepositoryTest.kt | 72 ++ .../downloads/DownloadMetadataStoreTest.kt | 13 + .../downloads/DownloadSidecarArtworkTest.kt | 6 +- .../settings/EpisodeSpoilerStoreTest.kt | 419 +++++++ .../android/auth/ProfileSwitchTeardown.kt | 3 + .../silo/android/auth/SignOutTeardown.kt | 3 + .../silo/android/di/AndroidModule.kt | 8 +- .../android/ui/components/BackdropCard.kt | 6 +- .../ui/components/CardActionsHelpers.kt | 21 +- .../silo/android/ui/components/MediaCard.kt | 10 +- .../silo/android/ui/components/MediaRow.kt | 40 +- .../android/ui/navigation/AppNavigation.kt | 13 +- .../android/ui/screens/browse/CatalogGrid.kt | 14 +- .../screens/detail/DetailSharedComponents.kt | 21 +- .../android/ui/screens/detail/EpisodeList.kt | 14 +- .../ui/screens/detail/ItemDetailScreen.kt | 12 + .../ui/screens/detail/ItemDetailViewModel.kt | 66 +- .../ui/screens/detail/MovieDetailContent.kt | 9 +- .../ui/screens/detail/SeriesDetailContent.kt | 24 +- .../ui/screens/downloads/DownloadEntryRows.kt | 35 +- .../ui/screens/downloads/DownloadsScreen.kt | 12 + .../screens/downloads/DownloadsViewModel.kt | 172 ++- .../personal/PersonalMediaGridContent.kt | 17 +- .../android/ui/screens/player/OnDeckItems.kt | 4 + .../ui/screens/player/PlayerNextUpScreen.kt | 11 +- .../ui/screens/player/PlayerViewModel.kt | 7 + .../ui/screens/search/SearchResults.kt | 10 +- .../settings/EpisodeSpoilerSettings.kt | 38 + .../ui/screens/settings/SettingsPages.kt | 11 + .../ui/screens/settings/SettingsScreen.kt | 1 + .../ui/screens/settings/SettingsViewModel.kt | 33 + .../watchparty/WatchPartyPickerSheet.kt | 21 +- .../watchparty/WatchPartyRoomContent.kt | 48 +- .../ui/screens/watchparty/WatchPartyStyle.kt | 29 +- .../android/auth/ProfileSwitchTeardownTest.kt | 4 +- .../silo/android/auth/SignOutTeardownTest.kt | 1 + .../ui/screens/detail/WatchedRollbackTest.kt | 28 + .../screens/downloads/DownloadArtworkTest.kt | 84 ++ ...SettingsViewModelOfflinePreferencesTest.kt | 5 + .../siloserver/silo/tv/di/AndroidTvModule.kt | 3 +- .../silo/tv/profiles/TvActiveProfileReset.kt | 3 + .../tv/ui/components/TvCardActionsHelpers.kt | 15 +- .../silo/tv/ui/components/TvCatalogGrid.kt | 8 +- .../silo/tv/ui/components/TvEpisodeCard.kt | 6 +- .../silo/tv/ui/components/TvFocusMarquee.kt | 11 +- .../tv/ui/components/TvFocusMarqueeModel.kt | 119 +- .../silo/tv/ui/components/TvMediaCard.kt | 5 +- .../silo/tv/ui/components/TvMediaRow.kt | 28 +- .../silo/tv/ui/components/TvPoster.kt | 7 +- .../tv/ui/components/TvReferenceShelfCard.kt | 5 +- .../tv/ui/components/TvRootHeroBackdrop.kt | 23 +- .../tv/ui/components/TvSkylineSectionFeed.kt | 2 + .../silo/tv/ui/navigation/TvAppNavigation.kt | 24 +- .../ui/screens/detail/TvDetailEpisodeRail.kt | 15 +- .../ui/screens/detail/TvItemDetailScreen.kt | 64 +- .../screens/detail/TvItemDetailViewModel.kt | 5 +- .../tv/ui/screens/player/TvPlayerScreen.kt | 5 +- .../tv/ui/screens/player/TvPlayerViewModel.kt | 4 + .../player/TvWatchPartyPlayerOverlays.kt | 3 + .../ui/screens/settings/TvSettingsScreen.kt | 36 + .../screens/settings/TvSettingsViewModel.kt | 33 + .../watchparty/TvWatchPartyLobbyScreen.kt | 3 + .../screens/watchparty/TvWatchPartyPicker.kt | 18 +- .../ui/screens/watchparty/TvWatchPartyRoom.kt | 22 +- .../watchparty/TvWatchPartyStagedItem.kt | 28 +- .../screens/watchparty/TvWatchPartyStyle.kt | 7 +- .../silo/tv/watchnext/TvWorkerFactory.kt | 4 + .../silo/tv/watchnext/WatchNextHomeSync.kt | 44 +- .../tv/watchnext/WatchNextProgramMapper.kt | 28 +- .../silo/tv/watchnext/WatchNextSeeder.kt | 30 + .../silo/tv/watchnext/WatchNextSyncWorker.kt | 24 +- .../ui/components/TvFocusMarqueeModelTest.kt | 129 +++ .../screens/detail/TvDetailHeroArtworkTest.kt | 25 + .../tv/watchnext/WatchNextHomeSyncTest.kt | 60 +- .../watchnext/WatchNextProgramMapperTest.kt | 27 + .../silo/tv/watchnext/WatchNextSeederTest.kt | 47 + .../silo/model/catalog/CatalogModels.kt | 13 + .../silo/model/download/DownloadSidecar.kt | 4 + .../silo/model/section/SectionModels.kt | 2 + .../silo/model/settings/EpisodeSpoilers.kt | 87 ++ .../silo/model/settings/SettingKeys.kt | 10 +- .../apiv2/CatalogDetailReadV2Models.kt | 6 + .../silo/playback/ShufflePlayback.kt | 4 + .../silo/repository/port/UserItemStatePort.kt | 3 + .../silo/viewmodel/HomeViewModel.kt | 3 +- .../silo/viewmodel/WatchPartySpoilers.kt | 65 ++ .../silo/viewmodel/WatchPartyViewModels.kt | 2 + .../model/settings/EpisodeSpoilersTest.kt | 117 ++ .../network/apiv2/CatalogDetailReadV2Test.kt | 8 +- .../silo/playback/ShufflePlaybackTest.kt | 2 + .../silo/viewmodel/WatchPartySpoilersTest.kt | 80 ++ .../resources/api/v2/fixtures/SOURCE | 4 +- ...e_account_identity_link_invalid_grant.json | 2 +- .../complete_oauth_login_invalid_grant.json | 2 +- ...t_identity_link_ticket_wrong_password.json | 2 +- ..._account_identity_last_sign_in_method.json | 2 +- .../get_external_sign_in_capabilities_ok.json | 5 +- .../api/v2/fixtures/get_system_info_ok.json | 2 +- .../refresh_session_provider_unavailable.json | 2 +- ..._in_with_network_identity_off_overlay.json | 2 +- .../commonTest/resources/settings/v1/SOURCE | 8 +- .../resources/settings/v1/conformance.json | 2 +- .../resources/settings/v1/manifest.json | 37 +- 123 files changed, 4299 insertions(+), 224 deletions(-) create mode 100644 android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/15.json create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cards/EpisodeSpoilerLocals.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStore.kt create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/SpoilerImage.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStoreTest.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/EpisodeSpoilerSettings.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/WatchedRollbackTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadArtworkTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeederTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilers.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilers.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilersTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilersTest.kt diff --git a/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/15.json b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/15.json new file mode 100644 index 0000000000..c429039caf --- /dev/null +++ b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/15.json @@ -0,0 +1,1024 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "88ff4db6ea0cfe226006354b1474462c", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, `positionUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "positionUpdatedAtMs", + "columnName": "positionUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, `watchedUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "watchedUpdatedAtMs", + "columnName": "watchedUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, `offlineTracksJson` TEXT, `offlinePosterPath` TEXT, `offlineSeriesPosterPath` TEXT, `seriesPosterThumbhash` TEXT, `posterIsEpisodeStill` INTEGER, `episodeUserDataJson` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "offlineTracksJson", + "columnName": "offlineTracksJson", + "affinity": "TEXT" + }, + { + "fieldPath": "offlinePosterPath", + "columnName": "offlinePosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "offlineSeriesPosterPath", + "columnName": "offlineSeriesPosterPath", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesPosterThumbhash", + "columnName": "seriesPosterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "posterIsEpisodeStill", + "columnName": "posterIsEpisodeStill", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeUserDataJson", + "columnName": "episodeUserDataJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '88ff4db6ea0cfe226006354b1474462c')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cards/EpisodeSpoilerLocals.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cards/EpisodeSpoilerLocals.kt new file mode 100644 index 0000000000..7c426f1cb2 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cards/EpisodeSpoilerLocals.kt @@ -0,0 +1,39 @@ +package org.siloserver.silo.common.cards + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import org.siloserver.silo.common.settings.EpisodeSpoilerStore +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs + +/** + * The profile's spoiler protection for unwatched episodes, published once + * near each app shell. Surfaces pair it with + * [org.siloserver.silo.model.settings.EpisodeSpoilers.isUnwatched] to decide + * per episode. Outside a [ProvideEpisodeSpoilerPrefs] scope nothing is hidden. + */ +val LocalEpisodeSpoilerPrefs: ProvidableCompositionLocal<EpisodeSpoilerPrefs> = + staticCompositionLocalOf { EpisodeSpoilerPrefs.NONE } + +/** + * Hydrates the [store] for [sessionKey] (the active profile id) and publishes + * its resolved prefs via [LocalEpisodeSpoilerPrefs]. Keyed on the session for + * the same reason as [ProvideCardPresentation]: the provider is first + * composed on the unauthenticated Login screen. + */ +@Composable +fun ProvideEpisodeSpoilerPrefs( + store: EpisodeSpoilerStore, + sessionKey: Any? = null, + content: @Composable () -> Unit, +) { + LaunchedEffect(store, sessionKey) { + if (sessionKey != null) store.hydrateIfNeeded() + } + val state by store.state.collectAsState() + CompositionLocalProvider(LocalEpisodeSpoilerPrefs provides state.prefs, content = content) +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt index 9d6bea2a7b..ccd8382119 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt @@ -56,7 +56,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 14, + version = 15, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -71,6 +71,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity AutoMigration(from = 11, to = 12), AutoMigration(from = 12, to = 13), AutoMigration(from = 13, to = 14), + AutoMigration(from = 14, to = 15), ], ) abstract class SiloDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/ContentItemStateDao.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/ContentItemStateDao.kt index 73c4512bcd..acbe3c333c 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/ContentItemStateDao.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/ContentItemStateDao.kt @@ -38,7 +38,7 @@ interface ContentItemStateDao { // Revert a single optimistic field to "unknown" after its outbox op is dropped // (terminal server rejection) — the overlay then defers to server state. - @Query("UPDATE content_item_state SET watched = NULL WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId") + @Query("UPDATE content_item_state SET watched = NULL, watchedUpdatedAtMs = NULL WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId") suspend fun clearWatched(serverId: String, profileId: String, contentId: String) @Query("UPDATE content_item_state SET favorite = NULL WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId") diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DirtyOperationDao.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DirtyOperationDao.kt index 16a0240fd2..133a4a416a 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DirtyOperationDao.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/DirtyOperationDao.kt @@ -25,6 +25,16 @@ interface DirtyOperationDao { "AND targetContentId = :contentId AND opKind IN ('SET_WATCHED', 'SET_RATING', 'SET_POSITION', 'PERSONAL_V2')") suspend fun unresolvedPersonalCount(serverId: String, profileId: String, contentId: String): Int + @Query("SELECT contentId FROM content_item_state WHERE serverId = :serverId AND profileId = :profileId " + + "AND contentId IN (:contentIds) AND watchedUpdatedAtMs >= :sinceMs " + + "UNION SELECT contentId FROM user_item_state WHERE serverId = :serverId AND profileId = :profileId " + + "AND contentId IN (:contentIds) AND positionUpdatedAtMs >= :sinceMs " + + "UNION SELECT targetContentId FROM dirty_operations WHERE serverId = :serverId AND profileId = :profileId " + + "AND targetContentId IN (:contentIds) AND opKind IN ('SET_WATCHED', 'SET_POSITION')") + suspend fun contentIdsWithPendingOrNewerUserState( + serverId: String, profileId: String, contentIds: List<String>, sinceMs: Long, + ): List<String> + /** A newer desired-state write supersedes any v2 personal row the previous attempt left behind. */ @Query("DELETE FROM dirty_operations WHERE opKind = 'PERSONAL_V2' AND serverId = :serverId " + "AND profileId = :profileId AND targetContentId = :contentId") diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/UserItemStateDao.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/UserItemStateDao.kt index 4f4c3fe834..dd8a6d6ce6 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/UserItemStateDao.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/UserItemStateDao.kt @@ -32,7 +32,7 @@ interface UserItemStateDao { /** Clears resume state without discarding per-file playback or reading preferences. */ @Query( - "UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs " + + "UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs, positionUpdatedAtMs = :updatedAtMs " + "WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId " + "AND positionSeconds <> 0", ) @@ -43,17 +43,23 @@ interface UserItemStateDao { updatedAtMs: Long, ) - /** A delayed watched acknowledgement must not clear a newer local resume sample. */ - @Query("UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs " + + /** + * A delayed watched acknowledgement must not clear a newer local resume sample. + * Rows written before positionUpdatedAtMs existed fall back to clientUpdatedAtMs. + */ + @Query("UPDATE user_item_state SET positionSeconds = 0, clientUpdatedAtMs = :updatedAtMs, positionUpdatedAtMs = :updatedAtMs " + "WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId " + - "AND clientUpdatedAtMs < :admittedAtMs") + "AND COALESCE(positionUpdatedAtMs, clientUpdatedAtMs) < :admittedAtMs") suspend fun clearPlaybackProgressBefore(serverId: String, profileId: String, contentId: String, admittedAtMs: Long, updatedAtMs: Long) - /** Restore a reset resume row only while it is still the untouched reset. */ + /** + * Restore a reset resume row only while it is still the untouched reset. + * A reset made before positionUpdatedAtMs existed is matched by clientUpdatedAtMs. + */ @Query( - "UPDATE user_item_state SET positionSeconds = :positionSeconds, clientUpdatedAtMs = :previousUpdatedAtMs " + + "UPDATE user_item_state SET positionSeconds = :positionSeconds, clientUpdatedAtMs = CASE WHEN clientUpdatedAtMs = :clearedAtMs THEN :previousUpdatedAtMs ELSE clientUpdatedAtMs END, positionUpdatedAtMs = :previousPositionUpdatedAtMs " + "WHERE serverId = :serverId AND profileId = :profileId AND contentId = :contentId " + - "AND fileId = :fileId AND positionSeconds = 0 AND clientUpdatedAtMs = :clearedAtMs", + "AND fileId = :fileId AND positionSeconds = 0 AND COALESCE(positionUpdatedAtMs, clientUpdatedAtMs) = :clearedAtMs", ) suspend fun restorePlaybackProgressIfUnchanged( serverId: String, @@ -62,6 +68,7 @@ interface UserItemStateDao { fileId: Int, positionSeconds: Double, previousUpdatedAtMs: Long, + previousPositionUpdatedAtMs: Long?, clearedAtMs: Long, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/ContentItemStateEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/ContentItemStateEntity.kt index 1a2e3d00e9..666a8922de 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/ContentItemStateEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/ContentItemStateEntity.kt @@ -30,4 +30,6 @@ data class ContentItemStateEntity( val favorite: Boolean?, val clientUpdatedAtMs: Long, val serverUpdatedAtMs: Long?, + /** Timestamp of the watch/progress mutation, excluding ratings and track choices. */ + val watchedUpdatedAtMs: Long? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt index 647a030dec..f07d2cd501 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt @@ -93,4 +93,6 @@ data class DownloadEntity( val offlineSeriesPosterPath: String? = null, /** ThumbHash of the parent series poster (episode downloads only). */ val seriesPosterThumbhash: String? = null, + val posterIsEpisodeStill: Boolean? = null, + val episodeUserDataJson: String? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/UserItemStateEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/UserItemStateEntity.kt index 40b5fd8034..379218282f 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/UserItemStateEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/UserItemStateEntity.kt @@ -47,4 +47,6 @@ data class UserItemStateEntity( val readProgress: Double?, val clientUpdatedAtMs: Long, val serverUpdatedAtMs: Long?, + /** Timestamp of the watch/progress mutation, excluding ratings and track choices. */ + val positionUpdatedAtMs: Long? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt index 66ee60c65e..9d8e741099 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepository.kt @@ -121,12 +121,13 @@ class RoomUserItemStateRepository( current.watched == saved["previous_watched"]?.jsonPrimitive?.booleanOrNull && current.ratingValue == saved["previous_rating"]?.jsonPrimitive?.intOrNull if (unchanged) { - val previous = current ?: ContentItemStateEntity(row.serverId, row.profileId, row.targetContentId, null, null, null, now(), null) + val nowMs = now() + val previous = current ?: ContentItemStateEntity(row.serverId, row.profileId, row.targetContentId, null, null, null, nowMs, null) val projected = when (val command = handle.command) { - is PersonalWrite.Watched -> previous.copy(watched = command.watched) + is PersonalWrite.Watched -> previous.copy(watched = command.watched, watchedUpdatedAtMs = nowMs) is PersonalWrite.Rating -> previous.copy(ratingValue = command.rating) } - contentDao.upsert(projected.copy(clientUpdatedAtMs = now())) + contentDao.upsert(projected.copy(clientUpdatedAtMs = nowMs)) if (handle.command is PersonalWrite.Watched) userStateDao.clearPlaybackProgressBefore(row.serverId, row.profileId, row.targetContentId, row.createdAtMs, now()) } @@ -246,7 +247,7 @@ class RoomUserItemStateRepository( clientUpdatedAtMs = nowMs, serverUpdatedAtMs = null, ) - userStateDao.upsert(row) + userStateDao.upsert(row.copy(positionUpdatedAtMs = nowMs)) // V2 playback sends sequenced progress under its admitted session. Keep local resume, // but never coalesce a new position into a legacy queue with unknown authority. @@ -551,6 +552,15 @@ class RoomUserItemStateRepository( .associate { it.contentId to LocalContentState(watched = it.watched, favorite = null) } } + override suspend fun contentIdsWithPendingOrNewerUserState(contentIds: List<String>, sinceMs: Long): Set<String> { + val snapshot = snapshotProvider() ?: return emptySet() + val profileId = snapshot.profileId ?: return emptySet() + // The UNION binds the ID list three times; stay below SQLite's older 999-variable limit. + return contentIds.distinct().chunked(MAX_IN_LIST_IDS / 3).flatMap { + outboxDao.contentIdsWithPendingOrNewerUserState(snapshot.serverId, profileId, it, sinceMs) + }.toSet() + } + private suspend fun record( contentId: String, opKind: String, @@ -578,7 +588,8 @@ class RoomUserItemStateRepository( clientUpdatedAtMs = nowMs, serverUpdatedAtMs = null, ) - contentDao.upsert(applyField(existing).copy(clientUpdatedAtMs = nowMs)) + contentDao.upsert(applyField(existing).copy(clientUpdatedAtMs = nowMs, + watchedUpdatedAtMs = if (opKind == OutboxOperation.SET_WATCHED) nowMs else existing.watchedUpdatedAtMs)) var operationPayload = payloadJson if (clearPlaybackProgress) { @@ -598,6 +609,7 @@ class RoomUserItemStateRepository( fileId = row.fileId, positionSeconds = row.positionSeconds, previousClientUpdatedAtMs = row.clientUpdatedAtMs, + previousPositionUpdatedAtMs = row.positionUpdatedAtMs, clearedAtMs = nowMs, ) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/ContentProjectionRevert.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/ContentProjectionRevert.kt index 46051ebdc7..13eae36569 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/ContentProjectionRevert.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/ContentProjectionRevert.kt @@ -35,6 +35,7 @@ internal suspend fun UserItemStateDao.restorePlaybackProgressForTerminalOp(op: D fileId = progress.fileId, positionSeconds = progress.positionSeconds, previousUpdatedAtMs = progress.previousClientUpdatedAtMs, + previousPositionUpdatedAtMs = progress.previousPositionUpdatedAtMs, clearedAtMs = progress.clearedAtMs, ) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/OutboxOperation.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/OutboxOperation.kt index e4eb5be7d1..ab2b7a9c29 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/OutboxOperation.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/sync/OutboxOperation.kt @@ -40,6 +40,7 @@ data class OutboxOperation( val positionSeconds: Double, val previousClientUpdatedAtMs: Long, val clearedAtMs: Long, + val previousPositionUpdatedAtMs: Long? = null, ) /** diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt index e2e1a7604a..2b68ab3e1a 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/di/PlayerInfraModule.kt @@ -12,10 +12,12 @@ import org.siloserver.silo.common.player.SleepTimerController import org.siloserver.silo.common.settings.AndroidPlayerSettingsStore import org.siloserver.silo.common.settings.CardPresentationStore import org.siloserver.silo.common.settings.DefaultCardPresentationStore +import org.siloserver.silo.common.settings.DefaultEpisodeSpoilerStore import org.siloserver.silo.common.settings.DefaultLibraryPlaybackPrefsStore import org.siloserver.silo.common.settings.DefaultOverlayPrefsStore import org.siloserver.silo.common.settings.DefaultSeekIntervalStore import org.siloserver.silo.common.settings.DefaultServerSettingsFlusher +import org.siloserver.silo.common.settings.EpisodeSpoilerStore import org.siloserver.silo.common.settings.LibraryPlaybackPrefsStore import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.common.settings.PlayerSettingsStore @@ -201,6 +203,27 @@ val playerInfraModule = module { ) } + // Spoiler protection for unwatched episodes (see EpisodeSpoilers.MIN_CONTRACT_REVISION). Both + // keys are profile-scope only, so the store writes the profile row itself + // rather than going through the profile_device flusher. Resets and + // re-resolves on a server or profile change, like SeekIntervalStore: + // Add Server → sign in → pick profile clears nothing on its own. + single<EpisodeSpoilerStore> { + val identityChanges = get<ServerRegistry>().activeEntry + .map { it?.url to it?.profileId } + .distinctUntilChanged() + .map { Unit } + DefaultEpisodeSpoilerStore( + context = androidContext(), + repository = get<SettingsRepository>(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + getActiveProfileId = { get<ProfileRepository>().getActiveProfileId() }, + getServerUrl = { get<TokenManager>().getServerUrl() }, + captureAuthority = { get<TokenManager>().snapshotCurrentScope() }, + identityChanges = identityChanges, + ) + } + // "Show title art" (settings revision 16) for this device. Every title // surface and both settings screens read this one instance, so a change // reaches an open detail page without a restart. @@ -226,6 +249,7 @@ val playerInfraModule = module { libraryPlaybackPrefsStore = get(), playerSettingsStore = get(), seekIntervalStore = get(), + episodeSpoilerStore = get(), titleArtStore = get(), hiddenLibrariesStore = get(), hasAuthenticatedProfile = { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt index 52a2a570d9..9d2bb33a51 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt @@ -105,6 +105,7 @@ class DownloadEnqueuer( episodeNumber: Int?, episodeTitle: String?, posterUrl: String? = null, + posterIsEpisodeStill: Boolean? = null, downloadQualityOverride: DownloadQuality? = null, ): ApiResult<Unit> { val authority = authorities?.snapshotDurableLoginAuthority() @@ -139,6 +140,8 @@ class DownloadEnqueuer( title = seriesTitle, subtitle = listOfNotNull(episodeCode, shownEpisodeTitle).joinToString(" · ").ifEmpty { null }, posterUrl = posterUrl, + posterIsEpisodeStill = posterIsEpisodeStill, + episodeUserData = episodeDetail?.userData, seriesTitle = seriesTitle, seriesContentId = seriesContentId, seasonNumber = seasonNumber, @@ -213,6 +216,8 @@ class DownloadEnqueuer( } ?: "Series episode", posterUrl = posterUrl, posterThumbhash = seriesDetail?.posterThumbhash, + posterIsEpisodeStill = false, + episodeUserData = episodeDetail?.userData, seriesTitle = seriesTitle, seriesContentId = seriesContentId, seasonNumber = ep?.seasonNumber, @@ -274,6 +279,7 @@ class DownloadEnqueuer( episodeNumber = ep.episodeNumber, episodeTitle = ep.title, posterUrl = posterUrl, + posterIsEpisodeStill = false, // Season batch is original-only (server rejects a non-original // batch with 501 bulk_quality_unavailable). Force Original even // though startEpisode itself honors quality for single episodes. @@ -468,6 +474,8 @@ class DownloadEnqueuer( title = detail?.title ?: fallbackTitle, posterUrl = detail?.posterUrl, posterThumbhash = detail?.posterThumbhash, + posterIsEpisodeStill = detail?.posterIsEpisodeStill, + episodeUserData = detail?.userData.takeIf { detail?.type == "episode" }, year = detail?.year?.takeIf { it > 0 }, seriesTitle = detail?.seriesTitle, // Stable TV grouping key — present for episode detail downloads via the diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt index ac48df83b9..c4f08925a8 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt @@ -2,6 +2,7 @@ package org.siloserver.silo.common.downloads import org.siloserver.silo.common.data.db.entity.DownloadEntity import org.siloserver.silo.model.catalog.VersionChapter +import org.siloserver.silo.model.catalog.LeafItemUserData import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadSidecar import org.siloserver.silo.model.download.OfflineTrackInfo @@ -34,6 +35,8 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit subtitle = subtitle, posterUrl = posterUrl, posterThumbhash = posterThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + episodeUserDataJson = episodeUserData?.let { mappingJson.encodeToString(it) }, year = year, seriesTitle = seriesTitle, seriesContentId = seriesContentId, @@ -87,6 +90,10 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = subtitle = subtitle, posterUrl = posterUrl, posterThumbhash = posterThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + episodeUserData = episodeUserDataJson?.let { + runCatching { mappingJson.decodeFromString<LeafItemUserData>(it) }.getOrNull() + }, year = year, seriesTitle = seriesTitle, seriesContentId = seriesContentId, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt index 3213c46383..cf0c7aeb43 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadTileArtwork.kt @@ -4,8 +4,12 @@ import org.siloserver.silo.model.download.DownloadMediaType import org.siloserver.silo.model.download.DownloadSidecar import java.io.File -/** Image source and blur-up placeholder for a download's 2:3 tile. */ -data class DownloadTileArtwork(val url: String?, val thumbhash: String?) +/** + * Image source and blur-up placeholder for a download's 2:3 tile. + * [isEpisodeStill] is the provenance of [url]: false for a saved poster, the + * catalog's answer for a catalog URL, and null when that is unknown. + */ +data class DownloadTileArtwork(val url: String?, val thumbhash: String?, val isEpisodeStill: Boolean? = null) /** * The 2:3 tile artwork for one download. A movie uses its saved poster, else @@ -27,9 +31,11 @@ fun List<DownloadSidecar>.tileArtwork(isFile: (String) -> Boolean = ::isSavedArt ?: firstNotNullOfOrNull { sidecar -> sidecar.offlinePosterPath?.takeIf { !sidecar.isEpisodeDownload() && isFile(it) } } + val catalog = firstOrNull { it.posterUrl != null } return DownloadTileArtwork( - url = saved?.let(::localArtworkUrl) ?: firstNotNullOfOrNull { it.posterUrl }, + url = saved?.let(::localArtworkUrl) ?: catalog?.posterUrl, thumbhash = firstNotNullOfOrNull { it.seriesPosterThumbhash } ?: firstNotNullOfOrNull { it.posterThumbhash }, + isEpisodeStill = if (saved != null) false else catalog?.posterIsEpisodeStill, ) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStore.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStore.kt new file mode 100644 index 0000000000..92a0605dc3 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStore.kt @@ -0,0 +1,457 @@ +package org.siloserver.silo.common.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.repository.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** Whether the connected server knows the revision-17 spoiler keys. */ +enum class EpisodeSpoilerSupport { + Supported, + + /** Definitive answer: the server predates the keys. Never write. */ + Unsupported, + + /** No answer yet (cold start without a cache, or offline). */ + Unknown, +} + +/** One of the two spoiler switches. */ +enum class EpisodeSpoilerSetting(val key: String) { + Images(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES), + Overviews(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS), +} + +data class EpisodeSpoilerState( + val support: EpisodeSpoilerSupport = EpisodeSpoilerSupport.Unknown, + val hideImages: Boolean = false, + val hideOverviews: Boolean = false, +) { + val isSupported: Boolean get() = support == EpisodeSpoilerSupport.Supported + + /** + * What surfaces apply. Nothing is hidden unless the server is known to + * support the keys, so an older server shows episodes as before. + */ + val prefs: EpisodeSpoilerPrefs + get() = if (isSupported) EpisodeSpoilerPrefs(hideImages, hideOverviews) else EpisodeSpoilerPrefs.NONE + + fun value(setting: EpisodeSpoilerSetting): Boolean = when (setting) { + EpisodeSpoilerSetting.Images -> hideImages + EpisodeSpoilerSetting.Overviews -> hideOverviews + } + + fun with(setting: EpisodeSpoilerSetting, enabled: Boolean): EpisodeSpoilerState = when (setting) { + EpisodeSpoilerSetting.Images -> copy(hideImages = enabled) + EpisodeSpoilerSetting.Overviews -> copy(hideOverviews = enabled) + } +} + +/** + * The signed-in profile's spoiler protection for unwatched episodes + * (`catalog.hide_unwatched_episode_images` and + * `catalog.hide_unwatched_episode_overviews`, settings contract revision 17), + * shared by every phone and TV surface through `LocalEpisodeSpoilerPrefs`. + * + * Both keys allow only `scope=profile`, so writes go straight to the profile + * row (never through [ServerSettingsFlusher], which writes `profile_device`). + * Same lifecycle as [CardPresentationStore]: lazy hydration seeded from a + * last-known cache, refresh on foreground and reconnect through + * [ServerDrivenConfigRefresher], optimistic writes with rollback, and [clear] + * at sign-out and profile-switch boundaries. + * + * Like [SeekIntervalStore], it also resets and re-resolves on its own when the + * active server or profile changes, and a hydration only counts for the + * identity it ran against. Signing in to another server through Add Server + * clears nothing, so a store that trusted a plain "already hydrated" flag + * kept the previous server's answer until the next cold start. + */ +interface EpisodeSpoilerStore { + val state: StateFlow<EpisodeSpoilerState> + val lastError: StateFlow<String?> + + /** Why the last switch change wasn't saved, for Settings; cleared by the next save. */ + val saveError: StateFlow<String?> + + /** Idempotent first load. Safe to call from every provider mount. */ + suspend fun hydrateIfNeeded() + + /** Re-probe capabilities and re-resolve both keys. */ + suspend fun refresh() + + /** + * Apply [enabled] locally, then write it at profile scope; restores the + * last server-confirmed value if the write fails. Ignored unless the + * server is known to support the keys. + */ + fun set(setting: EpisodeSpoilerSetting, enabled: Boolean) + + /** Session boundary: drop state so the next profile starts clean. */ + fun clear() +} + +class DefaultEpisodeSpoilerStore private constructor( + private val repository: SettingsRepository, + private val scope: CoroutineScope, + private val getActiveProfileId: suspend () -> String?, + private val getServerUrl: suspend () -> String?, + private val captureAuthority: suspend () -> AuthScopeSnapshot?, + private val cache: EpisodeSpoilerCache, + identityChanges: Flow<Unit>, +) : EpisodeSpoilerStore { + + constructor( + context: Context, + repository: SettingsRepository, + scope: CoroutineScope, + getActiveProfileId: suspend () -> String?, + getServerUrl: suspend () -> String?, + captureAuthority: suspend () -> AuthScopeSnapshot?, + identityChanges: Flow<Unit> = emptyFlow(), + ) : this( + repository = repository, + scope = scope, + getActiveProfileId = getActiveProfileId, + getServerUrl = getServerUrl, + captureAuthority = captureAuthority, + cache = SharedPreferencesEpisodeSpoilerCache( + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), + ), + identityChanges = identityChanges, + ) + + private val _state = MutableStateFlow(EpisodeSpoilerState()) + private val _lastError = MutableStateFlow<String?>(null) + override val state: StateFlow<EpisodeSpoilerState> = _state.asStateFlow() + override val lastError: StateFlow<String?> = _lastError.asStateFlow() + private val _saveError = MutableStateFlow<String?>(null) + override val saveError: StateFlow<String?> = _saveError.asStateFlow() + + private val lock = Any() + + /** Bumped at every session boundary; stale work compares and bails. */ + @Volatile + private var generation = 0 + + /** Bumped by every local mutation; a refresh that started earlier must + * not paint its older server answer over the newer local value. */ + private var mutationEpoch = 0L + + @Volatile + private var hasHydrated = false + + /** The server|profile the shown state belongs to; null after a reset. */ + @Volatile + private var stateIdentity: String? = null + + /** The auth scope captured with [stateIdentity]; writes are pinned to it so + * a server or profile switch cannot retarget a queued toggle. */ + private var stateAuthority: AuthScopeSnapshot? = null + + /** The last values the server confirmed; a failed write restores these. */ + private var confirmed = EpisodeSpoilerState() + + /** Latest local request per setting. Only that request may change what is + * shown when it settles; an older one only updates [confirmed]. */ + private val latestRequest = mutableMapOf<EpisodeSpoilerSetting, Long>() + private var requestCounter = 0L + + private val refreshLock = Mutex() + + // Serializes writes so the wire order matches the order of the taps. + private val writeLock = Mutex() + + init { + scope.launch { + identityChanges.collectLatest { onIdentityChanged() } + } + } + + private suspend fun onIdentityChanged() { + val identity = currentIdentity() + val authority = captureAuthority() + val startGeneration = synchronized(lock) { + resetLocked() + stateIdentity = identity + stateAuthority = authority + generation + } + // A refresh for the previous identity can hold refreshLock through a + // network call; show this identity's last-known answer meanwhile. + if (identity != null) seedFromCache(identity, startGeneration) + hydrateIfNeeded() + } + + override suspend fun hydrateIfNeeded() { + if (hasHydrated && stateIdentity == currentIdentity()) return + refresh() + } + + override suspend fun refresh(): Unit = refreshLock.withLock { + val identity = currentIdentity() ?: return + val authority = captureAuthority() + val startGeneration: Int + val startEpoch: Long + synchronized(lock) { + // Another server or profile: the shown answer is not ours. + if (stateIdentity != identity) { + resetLocked() + stateIdentity = identity + } + stateAuthority = authority + startGeneration = generation + startEpoch = mutationEpoch + } + seedFromCache(identity, startGeneration) + + val supported = when (val caps = repository.contractCapabilities()) { + is ApiResult.Success -> EpisodeSpoilers.isSupported(caps.data) + // A server without the contract routes answers 404: that is an + // older server, not a transient failure. + is ApiResult.Error -> + if (caps.code == 404) false else return commitError(caps.message, startGeneration) + is ApiResult.NetworkError -> return commitError(caps.exception.message, startGeneration) + } + // Superseded by a session boundary: release refreshLock now rather + // than after a second request whose answer would be discarded. + if (generation != startGeneration) return + val resolved = if (!supported) { + EpisodeSpoilerState(support = EpisodeSpoilerSupport.Unsupported) + } else { + when (val result = repository.getEffectiveValues(EpisodeSpoilers.KEYS, authority = authority)) { + is ApiResult.Success -> EpisodeSpoilerState( + support = EpisodeSpoilerSupport.Supported, + hideImages = decode(result.data, EpisodeSpoilerSetting.Images), + hideOverviews = decode(result.data, EpisodeSpoilerSetting.Overviews), + ) + // A server can reach the revision without these keys when + // another setting took that revision number; it refuses them + // as unknown_setting (404). That is unsupported, not a + // transient failure to retry forever. + is ApiResult.Error -> + if (result.code == 404) EpisodeSpoilerState(support = EpisodeSpoilerSupport.Unsupported) + else return commitError(result.message, startGeneration) + is ApiResult.NetworkError -> + return commitError(result.exception.message, startGeneration) + } + } + val committed = synchronized(lock) { + if (generation != startGeneration || mutationEpoch != startEpoch || latestRequest.isNotEmpty()) { + return@synchronized false + } + _state.value = resolved + confirmed = resolved + _lastError.value = null + hasHydrated = true + true + } + if (committed) cache.write(identity, resolved) + } + + /** Apply the last-known answer while nothing better is on screen. */ + private fun seedFromCache(identity: String, startGeneration: Int) { + if (hasHydrated) return + val cached = cache.read(identity) ?: return + synchronized(lock) { + if (!hasHydrated && generation == startGeneration && + _state.value.support == EpisodeSpoilerSupport.Unknown + ) { + _state.value = cached + confirmed = cached + } + } + } + + /** A transient failure keeps whatever answer (cached or earlier) is shown. */ + private fun commitError(message: String?, startGeneration: Int) { + synchronized(lock) { + if (generation == startGeneration) _lastError.value = message + } + } + + override fun set(setting: EpisodeSpoilerSetting, enabled: Boolean) { + val startGeneration: Int + val request: Long + val identity: String + val authority: AuthScopeSnapshot? + synchronized(lock) { + val current = _state.value + if (!current.isSupported) return + identity = stateIdentity ?: return + authority = stateAuthority + startGeneration = generation + request = ++requestCounter + latestRequest[setting] = request + mutationEpoch += 1 + _state.value = current.with(setting, enabled) + } + scope.launch { + val result = writeLock.withLock { + if (generation != startGeneration) return@launch + repository.setProfileValue(setting.key, JsonPrimitive(enabled), authority) + } + val error = when (result) { + is ApiResult.Success -> null + is ApiResult.Error -> result.message.ifBlank { SAVE_FAILED_MESSAGE } + is ApiResult.NetworkError -> result.exception.message ?: SAVE_FAILED_MESSAGE + } + val snapshot = synchronized(lock) { + if (generation != startGeneration) return@launch + if (error == null) confirmed = confirmed.with(setting, enabled) + if (latestRequest[setting] == request) { + latestRequest.remove(setting) + mutationEpoch += 1 + // Saved: re-assert the value. Failed: return to what the + // server last confirmed. + val shown = if (error == null) enabled else confirmed.value(setting) + _state.value = _state.value.with(setting, shown) + } + _lastError.value = error + _saveError.value = error?.let(::saveErrorText) + confirmed + } + if (error == null) synchronized(lock) { + if (generation == startGeneration && stateIdentity == identity) cache.write(identity, snapshot) + } + } + } + + override fun clear() { + synchronized(lock) { resetLocked() } + } + + private fun resetLocked() { + generation += 1 + mutationEpoch += 1 + hasHydrated = false + stateIdentity = null + stateAuthority = null + _saveError.value = null + _state.value = EpisodeSpoilerState() + confirmed = EpisodeSpoilerState() + latestRequest.clear() + _lastError.value = null + } + + private suspend fun currentIdentity(): String? { + val profileId = getActiveProfileId()?.takeIf { it.isNotBlank() } ?: return null + return "${getServerUrl().orEmpty()}|$profileId" + } + + internal companion object { + const val PREFS_NAME = "silo_episode_spoilers" + const val SAVE_FAILED_MESSAGE = "Could not save the setting." + + /** Same wording as the title art and skip-interval save errors. */ + fun saveErrorText(message: String): String = "Couldn't save spoiler settings: $message" + + /** Only a JSON boolean `true` turns a switch on; anything else is off. */ + fun decode(effective: Map<String, EffectiveSettingValue>, setting: EpisodeSpoilerSetting): Boolean = + decodeValue(effective[setting.key]?.value) + + private fun decodeValue(value: JsonElement?): Boolean { + val primitive = value as? JsonPrimitive ?: return false + return !primitive.isString && primitive.booleanOrNull == true + } + + /** Test seam: no Android Context, in-memory cache. */ + internal fun forTest( + repository: SettingsRepository, + scope: CoroutineScope, + getActiveProfileId: suspend () -> String? = { "profile-1" }, + getServerUrl: suspend () -> String? = { "https://server.test" }, + captureAuthority: suspend () -> AuthScopeSnapshot? = { null }, + cache: EpisodeSpoilerCache = InMemoryEpisodeSpoilerCache(), + identityChanges: Flow<Unit> = emptyFlow(), + ): DefaultEpisodeSpoilerStore = DefaultEpisodeSpoilerStore( + repository = repository, + scope = scope, + getActiveProfileId = getActiveProfileId, + getServerUrl = getServerUrl, + captureAuthority = captureAuthority, + cache = cache, + identityChanges = identityChanges, + ) + } +} + +/** + * Last-known answer per server and profile, so a cold start hides spoilers + * before the network answers instead of flashing them first. + */ +internal interface EpisodeSpoilerCache { + fun read(identity: String): EpisodeSpoilerState? + fun write(identity: String, state: EpisodeSpoilerState) +} + +internal class InMemoryEpisodeSpoilerCache : EpisodeSpoilerCache { + private val entries = mutableMapOf<String, EpisodeSpoilerState>() + override fun read(identity: String): EpisodeSpoilerState? = synchronized(entries) { entries[identity] } + override fun write(identity: String, state: EpisodeSpoilerState) { + if (state.support == EpisodeSpoilerSupport.Unknown) return + synchronized(entries) { entries[identity] = state } + } +} + +private class SharedPreferencesEpisodeSpoilerCache( + private val prefs: SharedPreferences, +) : EpisodeSpoilerCache { + + override fun read(identity: String): EpisodeSpoilerState? { + val prefix = prefix(identity) + val support = when (prefs.getString("$prefix.support", null)) { + SUPPORTED -> EpisodeSpoilerSupport.Supported + UNSUPPORTED -> EpisodeSpoilerSupport.Unsupported + else -> return null + } + return EpisodeSpoilerState( + support = support, + hideImages = prefs.getBoolean("$prefix.images", false), + hideOverviews = prefs.getBoolean("$prefix.overviews", false), + ) + } + + override fun write(identity: String, state: EpisodeSpoilerState) { + val support = when (state.support) { + EpisodeSpoilerSupport.Supported -> SUPPORTED + EpisodeSpoilerSupport.Unsupported -> UNSUPPORTED + EpisodeSpoilerSupport.Unknown -> return + } + val prefix = prefix(identity) + prefs.edit { + putString("$prefix.support", support) + putBoolean("$prefix.images", state.hideImages) + putBoolean("$prefix.overviews", state.hideOverviews) + } + } + + private fun prefix(identity: String): String = + "es_" + java.security.MessageDigest.getInstance("SHA-256") + .digest(identity.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { "%02x".format(it) } + .take(24) + + private companion object { + const val SUPPORTED = "supported" + const val UNSUPPORTED = "unsupported" + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt index 456bbcfb85..9df14c229f 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/settings/ServerDrivenConfigRefresher.kt @@ -18,6 +18,7 @@ class ServerDrivenConfigRefresher( private val playerSettingsStore: PlayerSettingsStore, private val hasAuthenticatedProfile: suspend () -> Boolean, private val seekIntervalStore: SeekIntervalStore? = null, + private val episodeSpoilerStore: EpisodeSpoilerStore? = null, private val titleArtStore: TitleArtStore? = null, private val hiddenLibrariesStore: HiddenLibrariesStore? = null, private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, @@ -43,6 +44,7 @@ class ServerDrivenConfigRefresher( libraryPlaybackPrefsStore.refresh() playerSettingsStore.refreshFromServer() seekIntervalStore?.refresh() + episodeSpoilerStore?.refresh() titleArtStore?.refresh() hiddenLibrariesStore?.refresh() lastRefreshAtMs = now diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/SpoilerImage.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/SpoilerImage.kt new file mode 100644 index 0000000000..6b0a0180f2 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/SpoilerImage.kt @@ -0,0 +1,106 @@ +package org.siloserver.silo.common.ui.components + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp + +/** + * [ThumbhashImage] that can hide its picture behind a blur, for the still of + * an episode the profile has not started (spoiler protection). The frame, and + * anything the caller draws over it (badges, progress, play button), is + * unchanged; only the pixels are obscured. + * + * `Modifier.blur` does nothing below API 31 (Android 11 TVs such as the + * NVIDIA Shield), and a sharp still there would defeat the setting. So on + * those devices a hidden image shows only the decoded ThumbHash, which is + * already a blurry colour field, or a neutral box when there is no hash. The + * real still is never loaded in that case. + */ +@Composable +fun SpoilerImage( + url: String?, + thumbhash: String?, + hidden: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + decodeSizePx: Int? = null, + crossfadeMillis: Int = 300, + onSuccess: (() -> Unit)? = null, + onError: (() -> Unit)? = null, + colorFilter: ColorFilter? = null, + defaultArtwork: DefaultArtworkKind? = null, +) { + if (!hidden) { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + decodeSizePx = decodeSizePx, + crossfadeMillis = crossfadeMillis, + onSuccess = onSuccess, + onError = onError, + colorFilter = colorFilter, + defaultArtwork = defaultArtwork, + ) + return + } + if (!canBlurSpoilers || url.isNullOrBlank()) { + ThumbhashImage( + url = null, + thumbhash = thumbhash, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + colorFilter = colorFilter, + defaultArtwork = defaultArtwork, + ) + return + } + // Clip so the scaled, blurred layer stays inside the caller's frame. + Box(modifier = modifier.clipToBounds()) { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = contentDescription, + contentScale = contentScale, + // The detail is destroyed by the blur, so a small decode is enough. + decodeSizePx = SpoilerDecodeSizePx, + crossfadeMillis = crossfadeMillis, + onSuccess = onSuccess, + onError = onError, + colorFilter = colorFilter, + defaultArtwork = defaultArtwork, + modifier = Modifier + .fillMaxSize() + // Scaled past the edges so the blur samples real pixels + // instead of smearing the border inward. + .graphicsLayer { + scaleX = SpoilerScale + scaleY = SpoilerScale + } + .blur(SpoilerBlurRadius, BlurredEdgeTreatment.Rectangle), + ) + } +} + +/** True where `Modifier.blur` renders (API 31+). */ +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +val canBlurSpoilers: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +private const val SpoilerScale = 1.18f +private val SpoilerBlurRadius = 24.dp +private const val SpoilerDecodeSizePx = 256 diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt index fc64031c6b..b3a4ff03e7 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt @@ -11,6 +11,43 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class SiloDatabaseMigrationTest { + @Test + fun migration14To15PreservesDownloadsWithUnknownArtworkState() { + val name = "migration-14-to-15" + migrationHelper.createDatabase(name, 14).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, status, kind, " + + "fileSize, bytesSent, createdAt, updatedAtMs) VALUES ('s', 'p', 42, 'row', 'episode', 'Episode', 'tv', " + + "'completed', 'queued', 1024, 1024, '2026-10-01T00:00:00Z', 123)", + ) + database.execSQL("INSERT INTO content_item_state (serverId, profileId, contentId, watched, clientUpdatedAtMs) " + + "VALUES ('s', 'p', 'episode', 1, 1000)") + database.execSQL("INSERT INTO user_item_state (serverId, profileId, contentId, fileId, positionSeconds, audioFingerprint, clientUpdatedAtMs) " + + "VALUES ('s', 'p', 'episode', 42, 30, 'audio', 1000)") + } + migrationHelper.runMigrationsAndValidate(name, 15, true).use { database -> + database.query("SELECT watched, watchedUpdatedAtMs FROM content_item_state").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals(1, cursor.getInt(0)) + assertEquals(true, cursor.isNull(1)) + } + database.query("SELECT positionSeconds, audioFingerprint, positionUpdatedAtMs FROM user_item_state").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals(30.0, cursor.getDouble(0)) + assertEquals("audio", cursor.getString(1)) + assertEquals(true, cursor.isNull(2)) + } + database.query("SELECT recordId, status, posterIsEpisodeStill, episodeUserDataJson FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("row", cursor.getString(0)) + assertEquals("completed", cursor.getString(1)) + assertEquals(true, cursor.isNull(2)) + assertEquals(true, cursor.isNull(3)) + assertEquals(false, cursor.moveToNext()) + } + } + } + @get:Rule val migrationHelper = MigrationTestHelper( diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepositoryTest.kt index 98c586e8ba..1ad64cc8f3 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomUserItemStateRepositoryTest.kt @@ -46,6 +46,78 @@ class RoomUserItemStateRepositoryTest { @AfterTest fun tearDown() = db.close() + @Test + fun remoteWatchReadsDeferOnlyToPendingOrNewerLocalWrites() = runTest { + val watched = repo.recordWatched("c1", true) + assertEquals(setOf("c1"), repo.contentIdsWithPendingOrNewerUserState(listOf("c1"), 2000L)) + repo.resolve(watched, WriteOutcome.SYNCED) + assertEquals(emptySet(), repo.contentIdsWithPendingOrNewerUserState(listOf("c1"), 2000L)) + assertEquals(setOf("c1"), repo.contentIdsWithPendingOrNewerUserState(listOf("c1"), 500L)) + repo.recordPosition("c2", 7, 5.0, 60.0) + assertEquals(setOf("c2"), repo.contentIdsWithPendingOrNewerUserState(listOf("c1", "c2"), 2000L)) + currentSnapshot = AuthScopeSnapshot("s1", "p2", "https://s1.example", "pt2") + assertEquals(emptySet(), repo.contentIdsWithPendingOrNewerUserState(listOf("c1", "c2"), 0L)) + } + + @Test + fun ratingsAndTrackChangesDoNotPromoteStaleWatchState() = runTest { + var time = 1000L + val repository = RoomUserItemStateRepository(db, { currentSnapshot }, now = { time }) + val watched = repository.recordWatched("episode", true) + repository.resolve(watched, WriteOutcome.SYNCED) + repository.recordPosition("episode", 7, 30.0, 60.0) + db.dirtyOperationDao().dueBatch("s1", "p1", 1000L, 10).forEach { + repository.resolve(OutboxHandle(it.id), WriteOutcome.SYNCED) + } + time = 3000L + val rating = repository.recordRating("episode", 5) + repository.recordAudioTrackSelection("episode", 7, "audio") + assertEquals(emptySet(), repository.contentIdsWithPendingOrNewerUserState(listOf("episode"), 2000L)) + repository.resolve(rating, WriteOutcome.SYNCED) + assertEquals(emptySet(), repository.contentIdsWithPendingOrNewerUserState(listOf("episode"), 2000L)) + val reset = repository.recordWatched("episode", false) + repository.resolve(reset, WriteOutcome.SYNCED) + assertEquals(setOf("episode"), repository.contentIdsWithPendingOrNewerUserState(listOf("episode"), 2000L)) + } + + @Test + fun trackChoicesDoNotPreventWatchedResetsOrRejectedResetRecovery() = runTest { + var time = 1000L + val repository = RoomUserItemStateRepository(db, { currentSnapshot }, now = { time }) + repository.recordPosition("episode", 7, 30.0, 60.0) + time = 3000L + repository.recordAudioTrackSelection("episode", 7, "audio") + // A watched write admitted at 2000 must clear the older progress, even + // though an unrelated track choice updated the row afterward. + db.userItemStateDao().clearPlaybackProgressBefore("s1", "p1", "episode", 2000L, 4000L) + assertNull(repository.localPlaybackProgress("episode")) + time = 5000L + repository.recordSubtitleTrackSelection("episode", 7, "subtitle") + db.userItemStateDao().restorePlaybackProgressIfUnchanged("s1", "p1", "episode", 7, 30.0, 3000L, 1000L, 4000L) + val row = db.userItemStateDao().get("s1", "p1", "episode", 7)!! + assertEquals(30.0, row.positionSeconds) + assertEquals("audio", row.audioFingerprint) + assertEquals("subtitle", row.subtitleFingerprint) + assertEquals(5000L, row.clientUpdatedAtMs) + assertEquals(1000L, row.positionUpdatedAtMs) + } + + @Test + fun rowsWrittenBeforeThePositionTimestampKeepTheirGuards() = runTest { + val repository = RoomUserItemStateRepository(db, { currentSnapshot }, now = { 1000L }) + repository.recordPosition("episode", 7, 30.0, 60.0) + val legacy = "UPDATE user_item_state SET positionUpdatedAtMs = NULL" + db.openHelper.writableDatabase.execSQL(legacy) + // A resume sample saved after the watched op was admitted survives its late acknowledgement. + db.userItemStateDao().clearPlaybackProgressBefore("s1", "p1", "episode", 500L, 2000L) + assertEquals(30.0, db.userItemStateDao().get("s1", "p1", "episode", 7)!!.positionSeconds) + // A reset made before the upgrade is still undone when its op is rejected. + db.userItemStateDao().clearPlaybackProgress("s1", "p1", "episode", 4000L) + db.openHelper.writableDatabase.execSQL(legacy) + db.userItemStateDao().restorePlaybackProgressIfUnchanged("s1", "p1", "episode", 7, 30.0, 1000L, null, 4000L) + assertEquals(30.0, db.userItemStateDao().get("s1", "p1", "episode", 7)!!.positionSeconds) + } + @Test fun recordWatchedWritesProjectionAndContentScopedOutboxOp() = runTest { val handle = repo.recordWatched("c1", watched = true) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt index 7110e87959..c1f07ab55e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadMetadataStoreTest.kt @@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider import org.siloserver.silo.common.data.db.SiloDatabase import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadSidecar +import org.siloserver.silo.model.catalog.LeafItemUserData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch @@ -67,6 +68,18 @@ class DownloadMetadataStoreTest { assertEquals(sidecar, read) } + @Test + fun `episode artwork provenance and watch state survive Room storage`() = runTest { + for (isStill in listOf(true, false, null)) { + val sidecar = stubSidecar(7).copy( + posterIsEpisodeStill = isStill, + episodeUserData = LeafItemUserData(played = false, isInProgress = true, positionSeconds = 42.0), + ) + store.writeSidecar("srv1", "profA", sidecar) + assertEquals(sidecar, store.readSidecar("srv1", "profA", 7)) + } + } + @Test fun `writeSidecar upserts on re-write`() = runTest { store.writeSidecar("srv1", "profA", stubSidecar(7, contentId = "old")) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt index bbcff5735f..393875f697 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarArtworkTest.kt @@ -51,7 +51,7 @@ class DownloadSidecarArtworkTest { fun movieTilesUseTheSavedPosterThenTheCatalogUrl() { val movie = sidecar.copy(mediaType = "movie", posterThumbhash = "MOVIE", offlinePosterPath = "/art/poster") - assertEquals(DownloadTileArtwork("file:///art/poster", "MOVIE"), movie.tileArtwork { true }) + assertEquals(DownloadTileArtwork("file:///art/poster", "MOVIE", isEpisodeStill = false), movie.tileArtwork { true }) // A file removed behind the app's back falls back to the catalog URL. assertEquals( DownloadTileArtwork("https://silo.example/api/v2/artwork/poster", "MOVIE"), @@ -69,7 +69,7 @@ class DownloadSidecarArtworkTest { seriesPosterThumbhash = "SERIES", ) - assertEquals(DownloadTileArtwork("file:///art/series_poster", "SERIES"), episode.tileArtwork { true }) + assertEquals(DownloadTileArtwork("file:///art/series_poster", "SERIES", isEpisodeStill = false), episode.tileArtwork { true }) // The saved still exists, but the tile falls back to the catalog series poster. assertEquals( DownloadTileArtwork("https://silo.example/api/v2/artwork/poster", "SERIES"), @@ -94,7 +94,7 @@ class DownloadSidecarArtworkTest { ) assertEquals( - DownloadTileArtwork("file:///art/2/series_poster", "SERIES"), + DownloadTileArtwork("file:///art/2/series_poster", "SERIES", isEpisodeStill = false), listOf(first, second).tileArtwork { true }, ) // No series poster on disk: the catalog URL, not an episode still. diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStoreTest.kt new file mode 100644 index 0000000000..86e412b059 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/settings/EpisodeSpoilerStoreTest.kt @@ -0,0 +1,419 @@ +package org.siloserver.silo.common.settings + +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.EffectiveSettingValuesResponse +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.model.settings.SettingScope +import org.siloserver.silo.model.settings.SettingScopeIdentity +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.model.settings.StoredSettingValue +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.api.SettingsApi +import org.siloserver.silo.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EpisodeSpoilerStoreTest { + + @Test + fun `a revision 17 server resolves both switches`() = runTest { + val api = FakeSpoilerSettingsApi( + values = mapOf(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES to JsonPrimitive(true)), + ) + val store = storeFor(api) + + store.refresh() + + assertEquals(EpisodeSpoilerSupport.Supported, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = false), store.state.value.prefs) + assertEquals(listOf(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS), api.effectiveReads.single()) + } + + @Test + fun `an older server hides nothing, never reads the keys, and never receives writes`() = runTest { + val api = FakeSpoilerSettingsApi( + revision = 16, + values = mapOf(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES to JsonPrimitive(true)), + ) + val store = storeFor(api) + + store.refresh() + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + assertTrue(api.effectiveReads.isEmpty()) + assertTrue(api.puts.isEmpty()) + } + + @Test + fun `a server without the contract routes is unsupported`() = runTest { + val api = FakeSpoilerSettingsApi(capabilities = ApiResult.Error(404, "not_found", "Not Found")) + val store = storeFor(api) + + store.refresh() + + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + } + + @Test + fun `a server at the revision without the keys is unsupported`() = runTest { + val api = FakeSpoilerSettingsApi( + effectiveError = ApiResult.Error(404, "unknown_setting", "No setting named catalog.hide_unwatched_episode_images"), + ) + val store = storeFor(api) + + store.refresh() + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + assertTrue(api.puts.isEmpty()) + } + + @Test + fun `nothing is written or hidden while support is unknown`() = runTest { + val api = FakeSpoilerSettingsApi(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val store = storeFor(api) + + store.refresh() + store.set(EpisodeSpoilerSetting.Overviews, true) + runCurrent() + + assertEquals(EpisodeSpoilerSupport.Unknown, store.state.value.support) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + assertTrue(api.puts.isEmpty()) + assertTrue(store.lastError.value != null) + } + + @Test + fun `a write goes to profile scope and is cached`() = runTest { + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + val store = storeFor(api, cache) + store.refresh() + + store.set(EpisodeSpoilerSetting.Overviews, true) + assertTrue(store.state.value.hideOverviews) + runCurrent() + + assertEquals( + listOf(Triple(SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, SettingScope.PROFILE, JsonPrimitive(true) as JsonElement)), + api.puts, + ) + assertTrue(store.state.value.hideOverviews) + assertEquals(true, cache.read("https://server.test|profile-1")?.hideOverviews) + } + + @Test + fun `a failed write rolls back to the confirmed value`() = runTest { + val api = FakeSpoilerSettingsApi(failPuts = true) + val store = storeFor(api) + store.refresh() + + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + assertFalse(store.state.value.hideImages) + assertTrue(store.lastError.value != null) + assertEquals("Couldn't save spoiler settings: Server error", store.saveError.value) + + api.failPuts = false + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + assertEquals(null, store.saveError.value) + } + + @Test + fun `a cached answer applies before the network answers and clear drops it`() = runTest { + val cache = InMemoryEpisodeSpoilerCache() + cache.write( + "https://server.test|profile-1", + EpisodeSpoilerState(EpisodeSpoilerSupport.Supported, hideImages = true, hideOverviews = true), + ) + val api = FakeSpoilerSettingsApi(capabilities = ApiResult.NetworkError(RuntimeException("offline"))) + val store = storeFor(api, cache) + + store.hydrateIfNeeded() + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true), store.state.value.prefs) + + store.clear() + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + } + + @Test + fun `hydrating for another server re-resolves without a clear`() = runTest { + // Add Server → sign in → pick profile: nothing calls clear(), and the + // old server's Unsupported answer must not survive the switch. + var serverUrl = "https://old.test" + var profileId = "old-profile" + val api = FakeSpoilerSettingsApi(revision = 16) + val store = storeFor(api, serverUrl = { serverUrl }, profileId = { profileId }) + store.hydrateIfNeeded() + assertEquals(EpisodeSpoilerSupport.Unsupported, store.state.value.support) + + serverUrl = "https://new.test" + profileId = "new-profile" + api.serve(revision = 17, values = bothOn) + store.hydrateIfNeeded() + + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true), store.state.value.prefs) + } + + @Test + fun `an active server or profile change resets and re-resolves`() = runTest { + var serverUrl = "https://old.test" + val identityChanges = MutableSharedFlow<Unit>() + val api = FakeSpoilerSettingsApi(revision = 16) + val store = storeFor(api, serverUrl = { serverUrl }, identityChanges = identityChanges) + runCurrent() + store.hydrateIfNeeded() + + serverUrl = "https://new.test" + api.serve(revision = 17, values = bothOn) + identityChanges.emit(Unit) + runCurrent() + + assertEquals(EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true), store.state.value.prefs) + } + + @Test + fun `refresh preserves a toggle while its write is pending`() = runTest { + val api = FakeSpoilerSettingsApi() + val store = storeFor(api) + store.refresh() + val finishWrite = CompletableDeferred<Unit>() + api.beforePut = { finishWrite.await() } + + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + store.refresh() + + assertTrue(store.state.value.hideImages) + finishWrite.complete(Unit) + runCurrent() + assertTrue(store.state.value.hideImages) + } + + @Test + fun `a completed write never caches its values for a later profile`() = runTest { + var profileId = "profile-1" + var delayIdentityRead = false + val finishIdentityRead = CompletableDeferred<Unit>() + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + val store = storeFor(api, cache, profileId = { + if (delayIdentityRead) finishIdentityRead.await() + profileId + }) + store.refresh() + delayIdentityRead = true + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + + profileId = "profile-2" + delayIdentityRead = false + store.clear() + store.hydrateIfNeeded() + finishIdentityRead.complete(Unit) + runCurrent() + + assertEquals(false, cache.read("https://server.test|profile-2")?.hideImages) + assertEquals(true, cache.read("https://server.test|profile-1")?.hideImages) + } + + @Test + fun `a second identity change cancels hydration and loads its own cache`() = runTest { + var serverUrl = "https://old.test" + val changes = MutableSharedFlow<Unit>() + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + cache.write("https://new.test|profile-1", EpisodeSpoilerState( + EpisodeSpoilerSupport.Supported, hideImages = true, hideOverviews = true, + )) + val store = storeFor(api, cache, serverUrl = { serverUrl }, identityChanges = changes) + runCurrent() + store.refresh() + val oldHydration = CompletableDeferred<Unit>() + api.beforeCapabilities = { oldHydration.await() } + changes.emit(Unit) + runCurrent() + + serverUrl = "https://new.test" + api.beforeCapabilities = { } + api.serve(revision = 17, values = bothOn) + backgroundScope.launch { changes.emit(Unit) } + runCurrent() + + assertEquals(EpisodeSpoilerPrefs(true, true), store.state.value.prefs) + } + + @Test + fun `an identity change shows its cache while another caller's refresh is in flight`() = runTest { + var serverUrl = "https://old.test" + val changes = MutableSharedFlow<Unit>() + val api = FakeSpoilerSettingsApi() + val cache = InMemoryEpisodeSpoilerCache() + cache.write("https://new.test|profile-1", EpisodeSpoilerState( + EpisodeSpoilerSupport.Supported, hideImages = true, hideOverviews = true, + )) + val store = storeFor(api, cache, serverUrl = { serverUrl }, identityChanges = changes) + runCurrent() + val staleCapabilities = CompletableDeferred<Unit>() + api.beforeCapabilities = { staleCapabilities.await() } + // The Watch Next worker, say, refreshing the old server. + backgroundScope.launch { store.refresh() } + runCurrent() + + serverUrl = "https://new.test" + api.beforeCapabilities = { } + backgroundScope.launch { changes.emit(Unit) } + runCurrent() + assertEquals(EpisodeSpoilerPrefs(true, true), store.state.value.prefs) + + staleCapabilities.complete(Unit) + runCurrent() + // Only the new server's refresh read the values. + assertEquals(1, api.effectiveReads.size) + assertEquals(EpisodeSpoilerPrefs.NONE, store.state.value.prefs) + } + + @Test + fun `a queued write stays pinned to the profile that made it`() = runTest { + val original = authorityFor("profile-1") + var active = original + val api = FakeSpoilerSettingsApi() + val store = storeFor(api, captureAuthority = { active }) + store.refresh() + val finishFirstWrite = CompletableDeferred<Unit>() + api.beforePut = { finishFirstWrite.await() } + store.set(EpisodeSpoilerSetting.Images, true) + runCurrent() + // Queued behind the first write while the active profile changes. + store.set(EpisodeSpoilerSetting.Overviews, true) + runCurrent() + + active = authorityFor("profile-2") + api.beforePut = { } + finishFirstWrite.complete(Unit) + runCurrent() + + assertEquals(listOf<AuthScopeSnapshot?>(original, original), api.putAuthorities.toList()) + assertEquals(listOf<AuthScopeSnapshot?>(original), api.effectiveAuthorities.toList()) + } + + private fun authorityFor(profileId: String) = AuthScopeSnapshot( + serverId = "server", profileId = profileId, serverUrl = "https://server.test", profileToken = "token-$profileId", + ) + + private val bothOn = mapOf( + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES to JsonPrimitive(true), + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS to JsonPrimitive(true), + ) + + private fun TestScope.storeFor( + api: FakeSpoilerSettingsApi, + cache: EpisodeSpoilerCache = InMemoryEpisodeSpoilerCache(), + serverUrl: suspend () -> String? = { "https://server.test" }, + profileId: suspend () -> String? = { "profile-1" }, + identityChanges: Flow<Unit> = emptyFlow(), + captureAuthority: suspend () -> AuthScopeSnapshot? = { null }, + ) = DefaultEpisodeSpoilerStore.forTest( + repository = SettingsRepository(api), + scope = backgroundScope, + getActiveProfileId = profileId, + getServerUrl = serverUrl, + captureAuthority = captureAuthority, + cache = cache, + identityChanges = identityChanges, + ) +} + +private class FakeSpoilerSettingsApi( + revision: Int = 17, + var capabilities: ApiResult<SettingsContractCapabilities> = capabilitiesAt(revision), + private var values: Map<String, JsonElement> = emptyMap(), + var failPuts: Boolean = false, + private val effectiveError: ApiResult.Error? = null, +) : SettingsApi( + org.siloserver.silo.network.apiv2.SettingsV2Api( + HttpClient(), + org.siloserver.silo.network.TokenManagerImpl(), + org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted, + ), +) { + val puts = mutableListOf<Triple<String, SettingScope, JsonElement>>() + val putAuthorities = mutableListOf<AuthScopeSnapshot?>() + val effectiveReads = mutableListOf<List<String>>() + val effectiveAuthorities = mutableListOf<AuthScopeSnapshot?>() + var beforeCapabilities: suspend () -> Unit = { } + var beforePut: suspend () -> Unit = { } + + /** Become a different server: [revision] and stored [values]. */ + fun serve(revision: Int, values: Map<String, JsonElement>) { + capabilities = capabilitiesAt(revision) + this.values = values + } + + override suspend fun getContractCapabilities(): ApiResult<SettingsContractCapabilities> { + beforeCapabilities() + return capabilities + } + + override suspend fun getEffectiveValues( + keys: List<String>, + libraryIds: List<Int>, + seriesIds: List<String>, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult<EffectiveSettingValuesResponse> { + effectiveReads += keys + effectiveAuthorities += authority + effectiveError?.let { return it } + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = keys.map { key -> + EffectiveSettingValue( + key = key, + value = values[key] ?: JsonPrimitive(false), + source = if (key in values) "profile" else "default", + ) + }, + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + profileId: String?, + authority: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult<StoredSettingValue> { + puts += Triple(key, scope.scope, value) + putAuthorities += authority + beforePut() + if (failPuts) return ApiResult.Error(500, "boom", "Server error") + return ApiResult.Success(StoredSettingValue(key = key, scope = scope.scope.wire)) + } +} + +private fun capabilitiesAt(revision: Int): ApiResult<SettingsContractCapabilities> = ApiResult.Success( + SettingsContractCapabilities(apiVersion = 1, manifestRevision = revision, supportsBatchedEffective = true), +) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt index ad9adc59c8..8804a68e40 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardown.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withTimeoutOrNull import org.siloserver.silo.common.settings.CardPresentationStore +import org.siloserver.silo.common.settings.EpisodeSpoilerStore import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.common.settings.SeekIntervalStore @@ -28,6 +29,7 @@ class ProfileSwitchTeardown( private val cardPresentationStore: CardPresentationStore, private val seekIntervalStore: SeekIntervalStore, private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, private val flushTimeoutMs: Long = FLUSH_TIMEOUT_MS, ) { private val _switching = MutableStateFlow(false) @@ -59,6 +61,7 @@ class ProfileSwitchTeardown( cardPresentationStore.clear() seekIntervalStore.clear() titleArtStore.clear() + episodeSpoilerStore.clear() } finally { _switching.value = false } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/SignOutTeardown.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/SignOutTeardown.kt index 249bb4166c..a5e769bc63 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/SignOutTeardown.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/SignOutTeardown.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.android.auth import org.siloserver.silo.common.settings.CardPresentationStore +import org.siloserver.silo.common.settings.EpisodeSpoilerStore import org.siloserver.silo.common.settings.LibraryPlaybackPrefsStore import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.common.settings.PlayerSettingsStore @@ -33,6 +34,7 @@ class SignOutTeardown( private val cardPresentationStore: CardPresentationStore, private val seekIntervalStore: SeekIntervalStore, private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, private val requestsFeatureStore: RequestsFeatureStore, private val metadataAiFeatureStore: MetadataAiFeatureStore, private val serverRegistry: ServerRegistry, @@ -65,6 +67,7 @@ class SignOutTeardown( cardPresentationStore.clear() seekIntervalStore.clear() titleArtStore.clear() + episodeSpoilerStore.clear() requestsFeatureStore.reset() metadataAiFeatureStore.reset() } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 01ca331ed9..d5ff4f7bef 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -528,14 +528,14 @@ val androidModule = module { ) } single { - org.siloserver.silo.android.auth.SignOutTeardown(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) + org.siloserver.silo.android.auth.SignOutTeardown(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { - org.siloserver.silo.android.auth.ProfileSwitchTeardown(get(), get(), get(), get(), get(), get()) + org.siloserver.silo.android.auth.ProfileSwitchTeardown(get(), get(), get(), get(), get(), get(), get()) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } - viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { org.siloserver.silo.android.ui.screens.pairing.CompanionPairingViewModel(get(), get(), get()) } viewModel { val tokens = get<org.siloserver.silo.network.TokenManager>() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt index 1fab437480..547b3f92ef 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/BackdropCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.components +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.ExperimentalFoundationApi @@ -77,6 +78,8 @@ fun BackdropCard( title: String, backdropUrl: String?, backdropThumbhash: String?, + /** Spoiler protection: blur the artwork (an unstarted episode's still). */ + hideArtwork: Boolean = false, seriesTitle: String? = null, seasonNumber: Int? = null, episodeNumber: Int? = null, @@ -116,9 +119,10 @@ fun BackdropCard( shape = RoundedCornerShape(10.dp), ), ) { - ThumbhashImage( + SpoilerImage( url = backdropUrl, thumbhash = backdropThumbhash, + hidden = hideArtwork, contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/CardActionsHelpers.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/CardActionsHelpers.kt index 09a9f83c9e..be3e41b1b3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/CardActionsHelpers.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/CardActionsHelpers.kt @@ -9,15 +9,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import org.siloserver.silo.domain.MediaActionsCoordinator import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.withWatched import org.siloserver.silo.model.catalog.MediaItemUserState import kotlinx.coroutines.launch import org.koin.compose.koinInject /** - * Returns a [MediaCardActions] that delegates to a Koin-injected - * [MediaActionsCoordinator]. Each invocation also exposes the *current* state - * (after any optimistic update) via the trailing [view] block so callers can - * render the up-to-date watched / favorite / watchlist state on the card. + * Returns actions, current optimistic user state, and resume position. + * Watched changes clear progress together; failed writes restore the snapshot. * * Use this for grid screens (search, catalog, library, person detail) whose * ViewModels don't yet manage these actions. The Home screen wires actions @@ -27,7 +26,7 @@ import org.koin.compose.koinInject @Composable fun rememberBrowseItemCardActions( item: BrowseItem, -): Pair<MediaCardActions, MediaItemUserState> { +): Triple<MediaCardActions, MediaItemUserState, Double?> { val coordinator: MediaActionsCoordinator = koinInject() val scope = rememberCoroutineScope() @@ -35,16 +34,16 @@ fun rememberBrowseItemCardActions( // (local watched/favorite applied), the card must pick it up — keying on // contentId alone kept the stale state and hid the overlay. The card's own // optimistic toggles mutate `state` (not item.userState), so they aren't reset. - var state by remember(item.contentId, item.userState) { - mutableStateOf(item.userState ?: MediaItemUserState()) + var state by remember(item.contentId, item.userState, item.positionSeconds) { + mutableStateOf(item) } - val actions = remember(item.contentId, coordinator, scope) { + val actions = remember(item.contentId, item.userState, item.positionSeconds, coordinator, scope) { MediaCardActions( onSetWatched = { watched -> val writeIntent = coordinator.beginWatched(item.contentId, watched) val previous = state - state = state.copy(played = watched) + state = state.withWatched(watched) scope.launch { if (coordinator.performPersonalWrite(writeIntent).isFailure()) { if (coordinator.isCurrent(writeIntent)) state = previous @@ -63,12 +62,12 @@ fun rememberBrowseItemCardActions( } val membershipActions by coordinator.memberships.actions.collectAsState() - var displayed = state + var displayed = state.userState ?: MediaItemUserState() membershipActions.values.filter { it.intent.key.itemId == item.contentId && it.baseline != null && coordinator.memberships.current(it.intent) }.forEach { displayed = if (it.intent.key.kind == org.siloserver.silo.repository.port.MembershipPort.Kind.FAVORITE) displayed.copy(isFavorite = it.baseline!!.present) else displayed.copy(inWatchlist = it.baseline!!.present) } - return actions to displayed + return Triple(actions, displayed, state.positionSeconds) } private fun org.siloserver.silo.network.ApiResult<Unit>.isFailure(): Boolean = diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt index 90a600cebd..3fd01eaff4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.components +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.ExperimentalFoundationApi @@ -71,6 +72,7 @@ object MediaGridDefaults { @Composable fun MediaCard( title: String, + hideArtwork: Boolean = false, posterUrl: String?, posterThumbhash: String?, year: Int? = null, @@ -93,6 +95,7 @@ fun MediaCard( sharedContentId: String? = null, detailBackdropUrl: String? = null, detailBackdropThumbhash: String? = null, + hideDetailArtwork: Boolean = hideArtwork, ) { val overlayState = LocalCardOverlayUiState.current var menuExpanded by remember { mutableStateOf(false) } @@ -114,8 +117,8 @@ fun MediaCard( // pairs with this card, not a duplicate elsewhere on screen. if (heroKey != null) heroHandoff?.pendingKey = heroKey // Match the detail hero from its first loading frame. - heroHandoff?.pendingArtworkUrl = detailBackdropUrl ?: posterUrl - heroHandoff?.pendingArtworkThumbhash = if (detailBackdropUrl != null) { + heroHandoff?.pendingArtworkUrl = (detailBackdropUrl ?: posterUrl).takeUnless { hideDetailArtwork } + heroHandoff?.pendingArtworkThumbhash = if (hideDetailArtwork) null else if (detailBackdropUrl != null) { detailBackdropThumbhash } else { posterThumbhash @@ -136,7 +139,8 @@ fun MediaCard( .heroSource(heroKey) .clip(MaterialTheme.shapes.small), ) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = posterUrl, thumbhash = posterThumbhash, contentDescription = title, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaRow.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaRow.kt index a7463d6c98..fee1f79ee1 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaRow.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaRow.kt @@ -40,7 +40,10 @@ import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrol import org.siloserver.silo.common.diagnostics.DiagnosticsKeyAnomalyLogger import org.siloserver.silo.common.diagnostics.DiagnosticsKeyCollection import org.siloserver.silo.common.diagnostics.DiagnosticsListSnapshot +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.overlays.OverlayData import org.siloserver.silo.overlays.OverlayDataExtractor import org.siloserver.silo.android.ui.navigation.LocalHeroSourceHandoff @@ -57,8 +60,19 @@ private data class MediaRowItemModel( val overlay: OverlayData, val isBook: Boolean, val contentType: String, + /** Spoiler protection: blur this card's episode still. */ + val hidesArtwork: Boolean, ) +/** + * True when [item] is an episode the profile has not started and [prefs] + * blur such stills. For episodes the backdrop is the still, so the card and + * the detail hand-off both treat it as a spoiler. + */ +private fun hidesEpisodeArtwork(item: SectionItem, prefs: EpisodeSpoilerPrefs): Boolean = + item.type.equals("episode", ignoreCase = true) && + prefs.hidesImage(EpisodeSpoilers.isUnwatched(item), EpisodeSpoilers.selectedImageIsStill(item)) + /** * Horizontal row of media cards with a section headline above. * @@ -94,7 +108,8 @@ fun MediaRow( diagnosticsKeySnapshot, ) } - val rowItems = remember(uniqueItems, showProgress, cardStyle) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val rowItems = remember(uniqueItems, showProgress, cardStyle, spoilerPrefs) { uniqueItems.map { item -> val pos = item.positionSeconds val dur = item.durationSeconds @@ -108,13 +123,10 @@ fun MediaRow( } else { null } - // Landscape cards take the backdrop first for every item type - // (iOS EpisodeThumbCard). For episodes the server's backdrop_url - // IS the episode still (falling back to the series backdrop), - // while poster_url is the season/series portrait — which the - // 16:9 frame used to crop down to a sliver of the title art. - val imageUrl = item.backdropUrl ?: item.posterUrl - val imageThumbhash = item.backdropThumbhash ?: item.posterThumbhash + // Landscape cards prefer the backdrop. Episode provenance tells + // us whether the selected artwork is a still or series fallback. + val imageUrl = item.backdropUrl?.takeIf { it.isNotBlank() } ?: item.posterUrl + val imageThumbhash = if (!item.backdropUrl.isNullOrBlank()) item.backdropThumbhash else item.posterThumbhash MediaRowItemModel( item = item, progress = progress, @@ -124,6 +136,7 @@ fun MediaRow( overlay = OverlayDataExtractor.fromSectionItem(item), isBook = isBookLikeItemType(item.type), contentType = "${cardStyle.name}:${item.type}", + hidesArtwork = hidesEpisodeArtwork(item, spoilerPrefs), ) } } @@ -207,6 +220,15 @@ fun MediaRow( // vertical fling (the helper ORs in any deferral already in scope). val rowState = rememberLazyListState() fun openDetail(item: SectionItem) { + // A blurred episode still must not reappear sharp as the detail + // page's loading artwork, so it is neither warmed nor handed off. + // (Home swaps in the parent series artwork when it knows it.) + if (hidesEpisodeArtwork(item, spoilerPrefs)) { + heroHandoff?.pendingArtworkUrl = null + heroHandoff?.pendingArtworkThumbhash = null + onItemClick(item.contentId) + return + } // Keep one full-size backdrop request alive across the loading- // skeleton -> detail-content composition swap. Without this, the // skeleton's differently-sized request can be cancelled as soon as @@ -273,6 +295,7 @@ fun MediaRow( title = item.title, backdropUrl = rowItem.backdropUrl, backdropThumbhash = rowItem.backdropThumbhash, + hideArtwork = rowItem.hidesArtwork, seriesTitle = item.seriesTitle, seasonNumber = item.seasonNumber, episodeNumber = item.episodeNumber, @@ -295,6 +318,7 @@ fun MediaRow( CardStyle.Poster -> { MediaCard( title = item.title, + hideArtwork = item.type.equals("episode", ignoreCase = true) && spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(item), item.posterIsEpisodeStill), posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, year = item.year, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index 95a4320cb8..a6d856db4e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -119,6 +119,7 @@ import org.siloserver.silo.android.ui.screens.settings.diagnostics.DiagnosticsSe import org.siloserver.silo.android.ui.screens.settings.diagnostics.DiagnosticsViewModel import org.siloserver.silo.cast.SiloCastPlaybackRequest import org.siloserver.silo.common.cards.ProvideCardPresentation +import org.siloserver.silo.common.cards.ProvideEpisodeSpoilerPrefs import org.siloserver.silo.common.settings.ProvideTitleArt import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.common.overlays.ProvideCardOverlays @@ -213,6 +214,7 @@ fun AppNavigation( val overlayPrefsStore: OverlayPrefsStore = koinInject() val activeProfileStore: ActiveProfileStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() + val episodeSpoilerStore: org.siloserver.silo.common.settings.EpisodeSpoilerStore = koinInject() val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore = koinInject() val titleArtStore: TitleArtStore = koinInject() val signOutTeardown: org.siloserver.silo.android.auth.SignOutTeardown = koinInject() @@ -338,6 +340,7 @@ fun AppNavigation( activeProfileStore.reset() cardPresentationStore.clear() seekIntervalStore.clear() + episodeSpoilerStore.clear() titleArtStore.clear() } } @@ -477,6 +480,7 @@ fun AppNavigation( ProvideCardOverlays(store = overlayPrefsStore, sessionKey = overlaySessionKey) { ProvideCardPresentation(store = cardPresentationStore, sessionKey = overlaySessionKey) { + ProvideEpisodeSpoilerPrefs(store = episodeSpoilerStore, sessionKey = overlaySessionKey) { ProvideTitleArt(store = titleArtStore, sessionKey = overlaySessionKey) { // Shared-element host: lets a tapped poster morph into the item-detail // backdrop. The scope is published via CompositionLocal so deep descendants @@ -891,9 +895,10 @@ fun AppNavigation( overlayPrefsStore.clear() activeProfileStore.reset() cardPresentationStore.clear() - // Not seekIntervalStore.clear(): the registry switch has already - // reset it and started hydrating the new server's profile through - // its identity flow, and a clear here would discard that load. + // Not seekIntervalStore.clear() or episodeSpoilerStore.clear(): + // the registry switch has already reset them and started + // hydrating the new server's profile through their identity + // flow, and a clear here would discard that load. navController.navigate(target) { popUpTo(0) { inclusive = true } launchSingleTop = true @@ -934,6 +939,7 @@ fun AppNavigation( overlayPrefsStore.hydrateIfNeeded() cardPresentationStore.hydrateIfNeeded() seekIntervalStore.hydrateIfNeeded() + episodeSpoilerStore.hydrateIfNeeded() titleArtStore.hydrateIfNeeded() } // Route through the tour gate: OnboardingTourScreen checks @@ -1825,6 +1831,7 @@ fun AppNavigation( } } } + } } /** Route placeholder for a party player; the room, not the route, decides what plays. */ diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/CatalogGrid.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/CatalogGrid.kt index 4fb9b899b7..47f8e8233e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/CatalogGrid.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/browse/CatalogGrid.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.android.ui.screens.browse +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers + import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -165,8 +168,13 @@ fun CatalogGrid( key = { it.contentId }, contentType = { item -> item.type }, ) { item -> - val (actions, userState) = rememberBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) + val hideDetailArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, selectBackdrop = true, positionSeconds = positionSeconds) MediaCard( + hideArtwork = hideArtwork, + hideDetailArtwork = hideDetailArtwork, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, @@ -175,8 +183,8 @@ fun CatalogGrid( type = item.type, userState = userState, onClick = { - heroHandoff?.pendingArtworkUrl = item.backdropUrl ?: item.posterUrl - heroHandoff?.pendingArtworkThumbhash = item.backdropThumbhash ?: item.posterThumbhash + heroHandoff?.pendingArtworkUrl = (item.backdropUrl ?: item.posterUrl).takeUnless { hideDetailArtwork } + heroHandoff?.pendingArtworkThumbhash = if (hideDetailArtwork) null else item.backdropThumbhash ?: item.posterThumbhash onItemClick(item.contentId) }, width = cardWidth, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt index c0af99d6bf..4d0d15c9f9 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailSharedComponents.kt @@ -83,6 +83,8 @@ import org.siloserver.silo.android.ui.theme.SiloOpaqueControlBorder import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated import org.siloserver.silo.android.ui.theme.PillShape +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.settings.titleLogoUrl import org.siloserver.silo.common.ui.RatingEntry import org.siloserver.silo.common.ui.WholeTokenRow @@ -94,6 +96,7 @@ import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.titleRatings import org.siloserver.silo.model.catalog.Season import org.siloserver.silo.model.catalog.isSpecialsForDisplay +import org.siloserver.silo.model.settings.EpisodeSpoilers // ── Tokens ──────────────────────────────────────────────────── @@ -235,6 +238,7 @@ private fun ExpandedDetailHero( ExpandedDetailHeroBackdrop( artworkUrl = detail.backdropUrl, artworkThumbhash = detail.backdropThumbhash, + hidden = detail.hidesEpisodeArtwork(detail.backdropIsEpisodeStill), pageSurface = pageSurface, ) { Row( @@ -359,12 +363,14 @@ internal fun ExpandedDetailHeroBackdrop( artworkUrl: String?, artworkThumbhash: String?, pageSurface: Color, + hidden: Boolean = false, content: @Composable () -> Unit, ) { Box(modifier = Modifier.fillMaxWidth()) { - ThumbhashImage( + SpoilerImage( url = artworkUrl, thumbhash = artworkThumbhash, + hidden = hidden, contentDescription = null, contentScale = ContentScale.Crop, crossfadeMillis = DetailArtworkCrossfadeMs, @@ -529,6 +535,9 @@ fun DetailHero( DetailHeroArtwork( artworkUrl = detail.backdropUrl ?: detail.posterUrl, artworkThumbhash = detail.backdropThumbhash ?: detail.posterThumbhash, + hidden = detail.hidesEpisodeArtwork( + if (detail.backdropUrl != null) detail.backdropIsEpisodeStill else detail.posterIsEpisodeStill, + ), contentDescription = detail.title, ) { HeroTitle(detail = detail) @@ -580,6 +589,7 @@ internal fun DetailHeroArtwork( artworkUrl: String?, artworkThumbhash: String?, contentDescription: String? = null, + hidden: Boolean = false, title: @Composable () -> Unit, ) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { @@ -626,9 +636,10 @@ internal fun DetailHeroArtwork( ) }, ) { - ThumbhashImage( + SpoilerImage( url = artworkUrl, thumbhash = artworkThumbhash, + hidden = hidden, contentDescription = contentDescription, contentScale = ContentScale.Crop, crossfadeMillis = DetailArtworkCrossfadeMs, @@ -781,6 +792,12 @@ private fun Backdrop( } } +/** Whether spoiler protection hides artwork of this episode with the given provenance. */ +@Composable +private fun ItemDetail.hidesEpisodeArtwork(artworkIsEpisodeStill: Boolean?): Boolean = + type.equals("episode", ignoreCase = true) && + LocalEpisodeSpoilerPrefs.current.hidesImage(EpisodeSpoilers.isUnwatched(userData), artworkIsEpisodeStill) + @Composable private fun HeroTitle(detail: ItemDetail) { val isEpisode = detail.type == "episode" diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt index 29dfc16b0a..e8b8fedbeb 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt @@ -53,9 +53,12 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.distinctUntilChanged import org.siloserver.silo.android.ui.util.formatCardDate import org.siloserver.silo.android.ui.util.playbackResumePosition +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.EpisodeListItem +import org.siloserver.silo.model.settings.EpisodeSpoilers import kotlin.math.abs /** @@ -173,6 +176,8 @@ private fun EpisodeRailCard( var menuExpanded by remember { mutableStateOf(false) } val isWatched = episode.userData?.played == true val stillShape = RoundedCornerShape(8.dp) + val spoilers = LocalEpisodeSpoilerPrefs.current + val unwatched = EpisodeSpoilers.isUnwatched(episode.userData) val progress = episodeProgressFraction( positionSeconds = episode.userData?.positionSeconds, durationSeconds = episode.userData?.durationSeconds, @@ -198,9 +203,10 @@ private fun EpisodeRailCard( shape = stillShape, ), ) { - ThumbhashImage( + SpoilerImage( url = episode.stillUrl, thumbhash = episode.stillThumbhash, + hidden = spoilers.hidesImage(unwatched, episode.stillIsEpisodeStill), contentDescription = episode.title, modifier = Modifier.fillMaxSize(), // The play button covers the centre, so no mark under it. @@ -314,7 +320,11 @@ private fun EpisodeRailCard( overflow = TextOverflow.Ellipsis, ) } - episode.overview?.takeIf { it.isNotBlank() }?.let { overview -> + // Spoiler protection omits the text; the fixed-height column + // keeps the card the same size. + val overview = episode.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } + if (overview != null) { Text( text = overview, style = MaterialTheme.typography.bodySmall, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index e2b89a14a2..13f060f884 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -70,6 +70,8 @@ import org.siloserver.silo.android.ui.screens.watchparty.rememberWatchPartyDetai import org.siloserver.silo.android.ui.screens.watchparty.rememberWatchPartySoloGuard import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.model.settings.EpisodeSpoilers +import org.siloserver.silo.viewmodel.WatchPartySpoilers import org.siloserver.silo.android.ui.util.playbackResumePosition import org.siloserver.silo.common.downloads.DownloadEnqueuer import org.siloserver.silo.common.downloads.DownloadOpenTarget @@ -838,6 +840,12 @@ fun ItemDetailScreen( fileId = selectedEpisodeVersion?.fileId ?.takeIf { episode.contentId == selectedEpisode?.contentId }, libraryId = libraryId, + // The series poster stands in for the episode. + spoilers = WatchPartySpoilers( + unwatchedEpisode = EpisodeSpoilers.isUnwatched(episode.userData), + posterIsEpisodeStill = false, + backdropIsEpisodeStill = false, + ), ) }, ), @@ -1007,6 +1015,10 @@ fun ItemDetailScreen( posterUrl = portraitArtwork.url ?: detail.posterUrl, fileId = selectedVersion?.fileId, libraryId = libraryId, + spoilers = WatchPartySpoilers.of(detail).let { spoilers -> + // A season or series poster stands in for an episode's still. + if (portraitArtwork.url != null) spoilers.copy(posterIsEpisodeStill = false) else spoilers + }, ) }, ), diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt index 20a201fcdc..f71a2bbb3d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt @@ -348,6 +348,8 @@ class ItemDetailViewModel( episodeNumber = item.episodeNumber, episodeTitle = item.title, posterUrl = knownPoster ?: parent?.posterUrl ?: pageDetail?.posterUrl, + posterIsEpisodeStill = if (knownPoster != null || parent?.posterUrl != null) false + else pageDetail?.posterIsEpisodeStill, downloadQualityOverride = downloadQuality, ) } @@ -1255,14 +1257,14 @@ class ItemDetailViewModel( fun toggleWatched() { val currentDetail = _uiState.value.detail ?: return - val current = currentDetail.userData?.played == true - val target = !current + val previous = currentDetail.userData + val target = previous?.played != true val generation = ++watchedMutationGeneration val isSeries = currentDetail.type == "series" if (isSeries) seasonsRefreshGeneration++ val episodeGenerationsAtStart = episodeWatchedMutationGenerations.toMap() val admittedAtMs = System.currentTimeMillis() - updatePlayedState(target) + updateOwnUserData { it.withPlayed(target) } val writeIntent = personalDataRepository.beginWatched(contentId, target) viewModelScope.launch { val writeResult = personalDataRepository.performPersonalWrite(writeIntent) @@ -1297,7 +1299,7 @@ class ItemDetailViewModel( } else -> if (generation == watchedMutationGeneration) { if (isSeries) failedSeriesWatchedGeneration = generation - updatePlayedState(current) + updateOwnUserData { it.withoutPlayed(target, previous) } } } } @@ -1486,18 +1488,16 @@ class ItemDetailViewModel( /** Marks one episode from the in-page rail without navigating away. */ fun setEpisodeWatched(episodeContentId: String, watched: Boolean) { val state = _uiState.value - val previous = state.episodes.firstOrNull { it.contentId == episodeContentId } - ?.userData?.played + val previous = (state.episodes.firstOrNull { it.contentId == episodeContentId } ?: state.episodesBySeason.values.asSequence() .flatten() - .firstOrNull { it.contentId == episodeContentId } - ?.userData?.played - ?: false - if (previous == watched) return + .firstOrNull { it.contentId == episodeContentId }) + ?.userData + if ((previous?.played ?: false) == watched) return val generation = (episodeWatchedMutationGenerations[episodeContentId] ?: 0) + 1 episodeWatchedMutationGenerations[episodeContentId] = generation - updateEpisodePlayedState(episodeContentId, watched) + updateEpisodeUserData(episodeContentId) { it.withPlayed(watched) } val writeIntent = personalDataRepository.beginWatched(episodeContentId, watched) viewModelScope.launch { val writeResult = personalDataRepository.performPersonalWrite(writeIntent) @@ -1512,28 +1512,24 @@ class ItemDetailViewModel( } else -> if (episodeWatchedMutationGenerations[episodeContentId] == generation) { failedEpisodeWatchedGenerations[episodeContentId] = generation - updateEpisodePlayedState(episodeContentId, previous) + updateEpisodeUserData(episodeContentId) { it.withoutPlayed(watched, previous) } } } } } - private fun updateEpisodePlayedState(episodeContentId: String, played: Boolean) { + private fun updateEpisodeUserData(episodeContentId: String, transform: (LeafItemUserData?) -> LeafItemUserData?) { fun EpisodeListItem.updated(): EpisodeListItem = - if (contentId != episodeContentId) this else copy( - userData = (userData ?: LeafItemUserData()).copy(played = played), - ) + if (contentId != episodeContentId) this else copy(userData = transform(userData)) _uiState.update { state -> val selectedDetail = state.selectedEpisodeDetail?.let { episodeDetail -> - if (episodeDetail.contentId != episodeContentId) episodeDetail else episodeDetail.copy( - userData = (episodeDetail.userData ?: LeafItemUserData()).copy(played = played), - ) + if (episodeDetail.contentId != episodeContentId) episodeDetail + else episodeDetail.copy(userData = transform(episodeDetail.userData)) } val ownDetail = state.detail?.let { itemDetail -> - if (itemDetail.contentId != episodeContentId) itemDetail else itemDetail.copy( - userData = (itemDetail.userData ?: LeafItemUserData()).copy(played = played), - ) + if (itemDetail.contentId != episodeContentId) itemDetail + else itemDetail.copy(userData = transform(itemDetail.userData)) } state.copy( detail = ownDetail, @@ -1546,11 +1542,31 @@ class ItemDetailViewModel( } } - private fun updatePlayedState(played: Boolean) { + private fun updateOwnUserData(transform: (LeafItemUserData?) -> LeafItemUserData?) { _uiState.update { state -> val detail = state.detail ?: return@update state - val userData = detail.userData ?: LeafItemUserData() - state.copy(detail = detail.copy(userData = userData.copy(played = played))) + state.copy(detail = detail.copy(userData = transform(detail.userData))) } } } + +/** + * Watched and unwatched both reset resume progress, as the server does, so + * spoiler protection sees the reset episode as not started. + */ +internal fun LeafItemUserData?.withPlayed(played: Boolean): LeafItemUserData = + (this ?: LeafItemUserData()).copy(played = played, isInProgress = false, positionSeconds = null) + +/** + * Undoes a failed [withPlayed] field by field, only where it still shows: a + * reload's watched state, or progress recorded since the toggle, stays. + */ +internal fun LeafItemUserData?.withoutPlayed(played: Boolean, previous: LeafItemUserData?): LeafItemUserData? { + if (this == null || this.played != played) return this + val progressUntouched = isInProgress == false && positionSeconds == null + return copy( + played = previous?.played ?: false, + isInProgress = if (progressUntouched) previous?.isInProgress else isInProgress, + positionSeconds = if (progressUntouched) previous?.positionSeconds else positionSeconds, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt index c68bd4820c..3c12ce02db 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MovieDetailContent.kt @@ -36,6 +36,7 @@ import org.siloserver.silo.android.ui.theme.SiloBackground import org.siloserver.silo.android.ui.theme.SiloDetailActionControl import org.siloserver.silo.android.ui.theme.SiloDetailActionControlActive import org.siloserver.silo.android.ui.util.rememberDominantColor +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.common.ui.movieDirectorCredit import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.ItemDetail @@ -43,6 +44,7 @@ import org.siloserver.silo.model.catalog.ItemExtra import org.siloserver.silo.model.catalog.Season import org.siloserver.silo.model.catalog.trailerRailEntries import org.siloserver.silo.model.catalog.selectedMediaRuntimeMinutes +import org.siloserver.silo.model.settings.EpisodeSpoilers import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow @@ -122,6 +124,9 @@ fun MovieDetailContent( } else { null } + // Spoiler protection: an unwatched episode's page drops its description. + val hidesEpisodeOverview = detail.type == "episode" && + LocalEpisodeSpoilerPrefs.current.hidesOverview(EpisodeSpoilers.isUnwatched(detail.userData)) val sourceTokens = HeroMetadata.movieSourceTokens(detail) val selectedRuntimeMinutes = selectedMediaRuntimeMinutes(detail, selectedVersion) val factsLine = HeroMetadata.movieFactsLine(detail, selectedRuntimeMinutes) @@ -166,7 +171,9 @@ fun MovieDetailContent( portraitArtwork = portraitArtwork, dominantColor = dominantColor, directorText = movieDirectorCredit(detail), - translation = translation, + overviewText = if (hidesEpisodeOverview) null else detail.overview, + // No "Translate description" under a hidden description. + translation = if (hidesEpisodeOverview) null else translation, belowOverview = { // PR #212 places the grouped playback card after overview, // credits, and translation—not inside the action stack. diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt index d4a68afc13..dbde09a0e6 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt @@ -31,11 +31,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.siloserver.silo.android.ui.theme.SiloBackground import org.siloserver.silo.android.ui.util.rememberDominantColor +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.ItemExtra import org.siloserver.silo.model.catalog.Season import org.siloserver.silo.model.catalog.trailerRailEntries +import org.siloserver.silo.model.settings.EpisodeSpoilers import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow @@ -113,9 +115,20 @@ fun SeriesDetailContent( val loadedSelectedEpisodeDetail = selectedEpisodeDetail ?.takeIf { it.contentId == selectedEpisodeContentId } val usesEpisodeEditorial = selectedEpisode != null || selectedEpisodeContentId != null - val selectedEpisodeOverview = loadedSelectedEpisodeDetail?.overview - ?.takeIf { it.isNotBlank() } - ?: selectedEpisode?.overview?.takeIf { it.isNotBlank() } + // Spoiler protection: the rail row's watch state is the one the long-press + // "Mark as Watched" updates, so it wins over the loaded episode detail. + val selectedEpisodeUnwatched = EpisodeSpoilers.isUnwatched( + selectedEpisode?.userData ?: loadedSelectedEpisodeDetail?.userData, + ) + val hidesSelectedEpisodeOverview = + LocalEpisodeSpoilerPrefs.current.hidesOverview(selectedEpisodeUnwatched) + val selectedEpisodeOverview = if (hidesSelectedEpisodeOverview) { + null + } else { + loadedSelectedEpisodeDetail?.overview + ?.takeIf { it.isNotBlank() } + ?: selectedEpisode?.overview?.takeIf { it.isNotBlank() } + } // iOS keeps the series cast credit stable while the selected episode's // overview and playback options change. Reusing the series credit avoids // replacing it with a skeleton (and repainting different names) on every @@ -273,7 +286,10 @@ fun SeriesDetailContent( } else { detail.overview }, - reserveOverviewSpace = !isExpandedDetailLayout && usesEpisodeEditorial, + // A hidden (spoiler) overview collapses instead of leaving + // the reserved block empty between the actions and the credit. + reserveOverviewSpace = !isExpandedDetailLayout && usesEpisodeEditorial && + !hidesSelectedEpisodeOverview, directorText = fixedSeriesCredit, isCreditLoading = false, reserveCreditSpace = !isExpandedDetailLayout && usesEpisodeEditorial, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadEntryRows.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadEntryRows.kt index d45191c07d..a044148ab1 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadEntryRows.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadEntryRows.kt @@ -40,7 +40,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.common.ui.components.SpoilerImage +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.model.download.DownloadMediaType import org.siloserver.silo.model.download.DownloadStatus import org.siloserver.silo.model.ebook.ebookFormatKey @@ -53,6 +56,28 @@ internal enum class DownloadRowAction { OpenInAppPlayer, } +internal fun downloadItemHidesArtwork(item: DownloadItem, prefs: EpisodeSpoilerPrefs): Boolean = + item.mediaType == DownloadMediaType.TvShow && + prefs.hidesImage(EpisodeSpoilers.isUnwatched(item.episodeUserData), item.posterIsEpisodeStill) + +internal fun downloadEntryHidesArtwork(entry: DownloadEntry, prefs: EpisodeSpoilerPrefs): Boolean { + fun items(node: DownloadEntry): List<DownloadItem> = when (node) { + is DownloadEntry.Single -> listOf(node.item) + is DownloadEntry.Season -> node.episodes.map { it.item } + is DownloadEntry.Series -> node.seasons.flatMap { it.episodes }.map { it.item } + is DownloadEntry.Author -> node.books.map { it.item } + } + // A season row shows the series-wide poster, which may come from another + // season's episode, so grouped rows name the download it came from. + val declared = when (entry) { + is DownloadEntry.Season -> entry.posterSource + is DownloadEntry.Series -> entry.posterSource + else -> null + } + val source = declared ?: items(entry).firstOrNull { it.posterUrl == entry.posterUrl } ?: return false + return downloadItemHidesArtwork(source, prefs) +} + internal fun downloadRowAction(item: DownloadItem): DownloadRowAction { if (!item.isComplete || item.localUri.isNullOrBlank()) return DownloadRowAction.None if (item.mediaType == DownloadMediaType.Ebook) { @@ -323,9 +348,10 @@ private fun SingleRow( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - ThumbhashImage( + SpoilerImage( url = item.posterUrl, thumbhash = item.posterThumbhash, + hidden = downloadItemHidesArtwork(item, LocalEpisodeSpoilerPrefs.current), contentDescription = item.title, modifier = Modifier .width(56.dp) @@ -443,6 +469,7 @@ private fun ExpandableAggregateRow( isComplete = entry.isComplete, posterUrl = entry.posterUrl, posterThumbhash = entry.posterThumbhash, + hidden = downloadEntryHidesArtwork(entry, LocalEpisodeSpoilerPrefs.current), expanded = expanded, onToggleExpand = { expanded = !expanded }, onDelete = { onDeleteEntry(entry) }, @@ -478,6 +505,7 @@ private fun AggregateRow( isComplete: Boolean, posterUrl: String?, posterThumbhash: String?, + hidden: Boolean, expanded: Boolean, onToggleExpand: () -> Unit, onDelete: () -> Unit, @@ -489,9 +517,10 @@ private fun AggregateRow( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - ThumbhashImage( + SpoilerImage( url = posterUrl, thumbhash = posterThumbhash, + hidden = hidden, contentDescription = title, modifier = Modifier .width(56.dp) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt index b70202acbd..55a8705ea1 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsScreen.kt @@ -6,6 +6,9 @@ import org.siloserver.silo.android.ui.components.SiloDialog import org.siloserver.silo.android.ui.components.SiloConfirmDialog import org.siloserver.silo.android.ui.util.formatBytes import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities @@ -90,6 +93,15 @@ fun DownloadsScreen( ) { val state by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(viewModel) { viewModel.refreshLocalUserState() } + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshLocalUserState() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } // Multi-select delete (mirrors iOS's edit-mode selection). Selection unit // is a top-level entry — selecting a series selects all its episodes. diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt index 0a1fa9153d..0de34b9aa8 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt @@ -13,6 +13,7 @@ import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory import org.siloserver.silo.common.downloads.tileArtwork import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.model.download.DownloadMediaType +import org.siloserver.silo.model.catalog.LeafItemUserData import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadSidecar import org.siloserver.silo.model.download.DownloadStatus @@ -22,14 +23,27 @@ import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.errorMessage import org.siloserver.silo.repository.DownloadSubscriptionRepository +import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.repository.DownloadsRepository import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.port.UserItemStatePort +import org.siloserver.silo.repository.port.LocalContentState +import org.siloserver.silo.repository.port.LocalPlaybackProgress import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -51,6 +65,8 @@ data class DownloadItem( val subtitle: String? = null, val posterUrl: String? = null, val posterThumbhash: String? = null, + val posterIsEpisodeStill: Boolean? = null, + val episodeUserData: LeafItemUserData? = null, val fileSizeBytes: Long = 0, val progress: Float = 0f, val isComplete: Boolean = false, @@ -120,6 +136,28 @@ internal fun downloadItemDisplayProgress( return rawProgress.coerceIn(0f, 1f) } +internal fun resolveDownloadEpisodeUserData( + saved: LeafItemUserData?, remote: LeafItemUserData?, localState: LocalContentState?, + progress: LocalPlaybackProgress?, localOverridesRemote: Boolean, +): LeafItemUserData = if (remote != null && !localOverridesRemote) remote + else overlayDownloadEpisodeUserData(remote ?: saved, localState, progress) + +internal fun overlayDownloadEpisodeUserData( + saved: LeafItemUserData?, + localState: LocalContentState?, + progress: LocalPlaybackProgress?, +): LeafItemUserData { + val original = saved ?: LeafItemUserData() + // Watched mutations clear local resume state. Do not resurrect progress + // captured when the download started after the user marks it unwatched. + val resetProgress = localState?.watched != null && progress == null + return original.copy( + played = localState?.watched ?: original.played, + positionSeconds = progress?.positionSeconds ?: if (resetProgress) 0.0 else original.positionSeconds, + isInProgress = progress?.let { it.positionSeconds > 0 } ?: if (resetProgress) false else original.isInProgress, + ) +} + /** * Recursive hierarchy of downloaded content for the Downloads tab. * @@ -166,6 +204,8 @@ sealed class DownloadEntry { val episodes: List<Single>, override val posterUrl: String?, override val posterThumbhash: String?, + /** The download whose artwork [posterUrl] is; it may be in another season. */ + val posterSource: DownloadItem? = null, ) : DownloadEntry() { override val id = "season:$seriesKey:$seasonNumber" override val title = "Season $seasonNumber" @@ -185,6 +225,8 @@ sealed class DownloadEntry { val seasons: List<Season>, override val posterUrl: String?, override val posterThumbhash: String?, + /** The download whose artwork [posterUrl] is. */ + val posterSource: DownloadItem? = null, ) : DownloadEntry() { override val id = "series:$seriesKey" override val title = seriesTitle @@ -281,6 +323,8 @@ class DownloadsViewModel( private val subscriptionEvaluatorFactory: DownloadSubscriptionEvaluatorFactory, private val userItemStatePort: UserItemStatePort, private val playerSettingsStore: PlayerSettingsStore, + private val catalogRepository: CatalogRepository, + private val episodeSpoilerStore: org.siloserver.silo.common.settings.EpisodeSpoilerStore, ) : ViewModel() { private val _uiState = MutableStateFlow(DownloadsUiState(isLoading = true)) @@ -295,11 +339,29 @@ class DownloadsViewModel( * [toItem] doesn't re-walk the filesystem per record per emission. */ @Volatile private var scopeByFileId: Map<Int, Pair<String, String>> = emptyMap() + // All publication tokens are owned by the main-thread ViewModel scope. + private var sectionsGeneration = 0L + // One section build at a time, so a build never starts from a sidecar map + // that an in-flight reload is about to replace and then wins publication. + private val sectionsLock = Mutex() + private val watchRefreshLock = Mutex() + private data class EpisodeWatchSnapshot(val userData: LeafItemUserData, val requestedAtMs: Long) + private data class ServerEpisodeWatchState( + val scope: Pair<String, String>, val snapshots: Map<String, EpisodeWatchSnapshot>, + ) + @Volatile private var serverEpisodeWatchState: ServerEpisodeWatchState? = null + init { playerSettingsStore.keepWatchedDownloadsFlow.onEach { keepWatched -> _uiState.update { it.copy(keepWatchedDownloads = keepWatched) } }.launchIn(viewModelScope) + // Episode watch state only feeds image protection; fetch it once the + // profile turns protection on. + episodeSpoilerStore.state.map { it.prefs.hideImages }.distinctUntilChanged().drop(1).onEach { hidden -> + if (hidden) refreshLocalUserState() + }.launchIn(viewModelScope) + viewModelScope.launch { // Bootstrap: finish the one-time legacy sidecar→Room import before the // first metadata read, so pre-cutover downloads aren't briefly missing @@ -322,6 +384,7 @@ class DownloadsViewModel( refreshSubscriptionsInternal() val seeded = metadataByRecordId.values.toList() repository.seedFromSidecars(seeded.map { it.record }) + launch { refreshEpisodeWatchState() } val keep = seeded.map { it.record.id }.toSet() val (refreshServerId, refreshProfileId) = activeDownloadScope() @@ -342,7 +405,8 @@ class DownloadsViewModel( * [forceReload] re-reads the sidecars even when no record is new or newly * completed (a later sidecar write, such as saved artwork). */ - private suspend fun publishSections(records: List<DownloadRecord>, forceReload: Boolean = false) { + private suspend fun publishSections(records: List<DownloadRecord>, forceReload: Boolean = false) = sectionsLock.withLock { + val generation = ++sectionsGeneration // Room is the source of truth for what's downloaded locally; the // tab is built from the Room sidecars (which always carry // title/poster/mediaType/series), NOT from server records joined @@ -368,6 +432,7 @@ class DownloadsViewModel( val sects = buildSections(records.associateBy { it.id }) sects to sects.sumOf { it.totalBytesUsed } } + if (generation != sectionsGeneration) return@withLock _uiState.update { it.copy( isLoading = false, @@ -381,10 +446,72 @@ class DownloadsViewModel( } } + fun refreshLocalUserState() { + viewModelScope.launch { + // Before the first load the sidecars aren't read yet, and + // publishing would show the empty state: wait for that load. + if (_uiState.value.isLoading) _uiState.first { !it.isLoading } + else sectionsLock.withLock { + val generation = ++sectionsGeneration + val sections = withContext(Dispatchers.IO) { + buildSections(repository.records.value.associateBy { it.id }) + } + if (generation == sectionsGeneration) { + _uiState.update { it.copy(sections = sections, isLoading = false, + totalBytesUsed = sections.sumOf { section -> section.totalBytesUsed }) } + } + } + refreshEpisodeWatchState() + } + } + + /** Episode watch state is read only for image protection. */ + private fun protectsEpisodeArtwork(): Boolean = episodeSpoilerStore.state.value.prefs.hideImages + + private suspend fun refreshEpisodeWatchState() { + // One read per downloaded episode: skip it unless it can change a tile. + if (!protectsEpisodeArtwork()) return + // Entry and ON_RESUME can arrive together; share the in-flight refresh. + if (!watchRefreshLock.tryLock()) return + try { + val scope = activeDownloadScope() + val owner = catalogRepository.captureWatchAuthority() ?: return + val ids = metadataByRecordId.values.filter { it.resolveSidecarMediaType() == DownloadMediaType.TvShow } + .map { it.record.episodeId ?: it.record.contentId }.distinct() + if (ids.isEmpty()) return + val startedAt = System.currentTimeMillis() + val limit = Semaphore(4) + val snapshots = coroutineScope { + ids.map { id -> async { + limit.withPermit { + val response = catalogRepository.getWatchDetail(id, owner) + val data = (response as? ApiResult.Success)?.data?.let { it.userData ?: LeafItemUserData() } + data?.let { id to EpisodeWatchSnapshot(it, startedAt) } + } + } }.awaitAll().filterNotNull().toMap() + } + if (activeDownloadScope() != scope || !catalogRepository.isWatchAuthorityCurrent(owner)) return + serverEpisodeWatchState = ServerEpisodeWatchState(scope, + serverEpisodeWatchState?.takeIf { it.scope == scope }?.snapshots.orEmpty() + snapshots) + sectionsLock.withLock { + val generation = ++sectionsGeneration + val sections = withContext(Dispatchers.IO) { buildSections(repository.records.value.associateBy { it.id }) } + if (activeDownloadScope() == scope && catalogRepository.isWatchAuthorityCurrent(owner) && + generation == sectionsGeneration) { + _uiState.update { it.copy(sections = sections, isLoading = false, + totalBytesUsed = sections.sumOf { section -> section.totalBytesUsed }) } + } + } + } finally { + watchRefreshLock.unlock() + } + } + fun refresh() { viewModelScope.launch { reloadSidecarMetadata() refreshSubscriptionsInternal() + launch { refreshEpisodeWatchState() } val keep = metadataByRecordId.keys val (serverId, profileId) = activeDownloadScope() when (val r = repository.refresh(keepIdsAbsentFromServer = keep, serverId = serverId, profileId = profileId)) { @@ -532,6 +659,7 @@ class DownloadsViewModel( private suspend fun removeRecords(ids: List<String>) { if (ids.isEmpty()) return + ++sectionsGeneration val activeScope = activeDownloadScope() var firstError: String? = null @@ -595,12 +723,13 @@ class DownloadsViewModel( // repository.delete() filters an already-filtered list, so the records // StateFlow conflates it and the collector never re-emits — rebuild the tree // here or the deleted rows stay on screen until the next refresh. + val generation = ++sectionsGeneration val (sections, bytesUsed) = withContext(Dispatchers.IO) { buildSections(repository.records.value.associateBy { it.id }) to storage.totalBytesUsed(serverId, profileId) } _uiState.update { - it.copy( + if (generation != sectionsGeneration) it.copy(error = firstError ?: it.error) else it.copy( error = firstError, sections = sections, totalBytesUsed = bytesUsed, @@ -746,6 +875,8 @@ class DownloadsViewModel( subtitle = subtitle, posterUrl = artwork.url, posterThumbhash = artwork.thumbhash, + posterIsEpisodeStill = artwork.isEpisodeStill, + episodeUserData = episodeUserData, fileSizeBytes = shownBytes, progress = displayProgress, isComplete = fileState.isComplete, @@ -774,10 +905,36 @@ class DownloadsViewModel( * Top-level: build per-mediaType sections from the Room sidecars (source of * truth), in stable display order. [liveById] overlays in-flight progress. */ - private fun buildSections(liveById: Map<String, DownloadRecord>): List<DownloadTypeSection> { + /** Each episode download's current user data: saved, server, and local state merged. */ + private suspend fun currentEpisodeUserData(sidecars: List<DownloadSidecar>): Map<String, LeafItemUserData> { + val episodes = sidecars.filter { it.resolveSidecarMediaType() == DownloadMediaType.TvShow } + val ids = episodes.map { it.record.episodeId ?: it.record.contentId } + val states = userItemStatePort.localContentStates(ids) + val positions = userItemStatePort.localPlaybackProgressForContent(ids) + val serverState = serverEpisodeWatchState?.takeIf { it.scope == activeDownloadScope() }?.snapshots.orEmpty() + val localOverrides = serverState.entries.groupBy { it.value.requestedAtMs }.flatMap { (startedAt, snapshots) -> + userItemStatePort.contentIdsWithPendingOrNewerUserState(snapshots.map { it.key }, startedAt) + }.toSet() + return episodes.associate { sidecar -> + val id = sidecar.record.episodeId ?: sidecar.record.contentId + val data = resolveDownloadEpisodeUserData(sidecar.episodeUserData, serverState[id]?.userData, + states[id], positions[id], id in localOverrides) + sidecar.record.id to data + } + } + + private suspend fun buildSections(liveById: Map<String, DownloadRecord>): List<DownloadTypeSection> { val sidecars = metadataByRecordId.values.toList() if (sidecars.isEmpty()) return emptyList() - val byType = sidecars.groupBy { it.resolveSidecarMediaType() } + // Watch state only decides image protection: with it off, skip the + // Room reads that would otherwise run on every progress tick. + val episodeUserDataByRecordId = + if (protectsEpisodeArtwork()) currentEpisodeUserData(sidecars) else emptyMap() + val byType = sidecars.map { sidecar -> + if (sidecar.record.id in episodeUserDataByRecordId) { + sidecar.copy(episodeUserData = episodeUserDataByRecordId[sidecar.record.id]) + } else sidecar + }.groupBy { it.resolveSidecarMediaType() } val sectionOrder = listOf( DownloadMediaType.Movie, DownloadMediaType.TvShow, @@ -822,6 +979,11 @@ class DownloadsViewModel( val artwork = group.tileArtwork() val poster = artwork.url val thumb = artwork.thumbhash + // The download the rows' artwork belongs to; a saved series + // poster is never a still, whichever download saved it. + val posterSource = group.firstOrNull { it.posterUrl != null }?.let { + it.toDownloadItem(liveById[it.record.id]).copy(posterIsEpisodeStill = artwork.isEpisodeStill) + } val bySeason = LinkedHashMap<Int, MutableList<DownloadSidecar>>() for (sc in group) bySeason.getOrPut(sc.seasonNumber ?: -1) { mutableListOf() } += sc val seasons = bySeason.toSortedMap().map { (season, seasonScs) -> @@ -835,6 +997,7 @@ class DownloadsViewModel( episodes = episodes, posterUrl = poster, posterThumbhash = thumb, + posterSource = posterSource, ) } DownloadEntry.Series( @@ -843,6 +1006,7 @@ class DownloadsViewModel( seasons = seasons, posterUrl = poster, posterThumbhash = thumb, + posterSource = posterSource, ) } return seriesEntries + orphans.map { DownloadEntry.Single(it.toDownloadItem(liveById[it.record.id])) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/personal/PersonalMediaGridContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/personal/PersonalMediaGridContent.kt index 2831d1e06a..fb42cc3773 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/personal/PersonalMediaGridContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/personal/PersonalMediaGridContent.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.android.ui.screens.personal +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers + import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -63,7 +66,7 @@ import org.siloserver.silo.android.ui.components.WatchedBadge import org.siloserver.silo.android.ui.components.rememberBrowseItemCardActions import org.siloserver.silo.common.cards.LocalCardPresentation import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling -import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.overlays.CardOverlayVariant import org.siloserver.silo.common.overlays.CardOverlays import org.siloserver.silo.common.overlays.LocalCardOverlayUiState @@ -392,7 +395,10 @@ fun MediaGridItem( onWatchlistToggle: (() -> Unit)? = null, isInWatchlist: Boolean = false, ) { - val (actions, userState) = rememberBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) + val hideDetailArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, selectBackdrop = true, positionSeconds = positionSeconds) val overlayState = LocalCardOverlayUiState.current var menuExpanded by remember { mutableStateOf(false) } val heroHandoff = LocalHeroSourceHandoff.current @@ -400,8 +406,8 @@ fun MediaGridItem( androidx.compose.foundation.layout.Column( modifier = modifier.combinedClickable( onClick = { - heroHandoff?.pendingArtworkUrl = item.backdropUrl ?: item.posterUrl - heroHandoff?.pendingArtworkThumbhash = item.backdropThumbhash ?: item.posterThumbhash + heroHandoff?.pendingArtworkUrl = (item.backdropUrl ?: item.posterUrl).takeUnless { hideDetailArtwork } + heroHandoff?.pendingArtworkThumbhash = if (hideDetailArtwork) null else item.backdropThumbhash ?: item.posterThumbhash onClick() }, onLongClick = { menuExpanded = true }, @@ -414,7 +420,8 @@ fun MediaGridItem( .aspectRatio(2f / 3.3f) .clip(RoundedCornerShape(8.dp)), ) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = item.posterUrl, thumbhash = item.posterThumbhash, contentDescription = item.title, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OnDeckItems.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OnDeckItems.kt index 3bbbf10132..eaca209244 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OnDeckItems.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OnDeckItems.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.android.ui.screens.player import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.settings.EpisodeSpoilers /** Existing On Deck projection: ordered, bounded cards with usable landscape art. */ internal fun List<ResolvedSection>.toOnDeckItems(contentId: String, seriesId: String?): List<PlayerViewModel.OnDeckItem> { @@ -33,6 +34,9 @@ internal fun List<ResolvedSection>.toOnDeckItems(contentId: String, seriesId: St artUrl = item.backdropUrl, artThumbhash = item.backdropThumbhash, progressFraction = progress, + imageIsEpisodeStill = item.backdropIsEpisodeStill, + isUnwatchedEpisode = item.type.equals("episode", ignoreCase = true) && + EpisodeSpoilers.isUnwatched(item), ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt index a15f689cbc..fb51ea3568 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerNextUpScreen.kt @@ -54,7 +54,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.siloserver.silo.common.player.videoViewportBounds import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrolling -import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.common.ui.components.SpoilerImage /** Episode details and controls beside the mounted player's preview pane. */ @OptIn(ExperimentalLayoutApi::class) @@ -157,7 +158,10 @@ fun PlayerNextUpScreen( fontSize = 13.sp, ) } - nextEpisode.overview?.takeIf { it.isNotBlank() }?.let { overview -> + val hidesOverview = + LocalEpisodeSpoilerPrefs.current.hidesOverview(nextEpisode.isUnwatched) + val overview = nextEpisode.overview?.takeIf { it.isNotBlank() && !hidesOverview } + if (overview != null) { Text( text = overview, color = Color.White.copy(alpha = 0.58f), @@ -347,9 +351,10 @@ private fun OnDeckCard( .aspectRatio(16f / 9f) .clip(RoundedCornerShape(8.dp)), ) { - ThumbhashImage( + SpoilerImage( url = item.artUrl, thumbhash = item.artThumbhash, + hidden = LocalEpisodeSpoilerPrefs.current.hidesImage(item.isUnwatchedEpisode, item.imageIsEpisodeStill), contentDescription = item.title, modifier = Modifier.fillMaxSize(), ) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 65192e6490..5077510772 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -373,6 +373,9 @@ class PlayerViewModel( val artUrl: String?, val artThumbhash: String?, val progressFraction: Float?, + /** An episode the profile has not started (spoiler protection). */ + val isUnwatchedEpisode: Boolean = false, + val imageIsEpisodeStill: Boolean? = null, ) data class NextEpisodeInfo( @@ -385,6 +388,8 @@ class PlayerViewModel( val runtimeMinutes: Int, val seriesTitle: String? = null, val overview: String? = null, + /** Not started by this profile; spoiler protection may hide [overview]. */ + val isUnwatched: Boolean = true, /** False for a shuffled movie, which has no season or episode line. */ val isEpisode: Boolean = true, ) { @@ -4733,6 +4738,7 @@ class PlayerViewModel( runtimeMinutes = next.runtime, seriesTitle = state.seriesTitle, overview = next.overview, + isUnwatched = org.siloserver.silo.model.settings.EpisodeSpoilers.isUnwatched(next.userData), ) _uiState.update { // Drop the result if the player has since moved to another item. @@ -5016,6 +5022,7 @@ class PlayerViewModel( runtimeMinutes = pick.runtimeMinutes, seriesTitle = pick.seriesTitle, overview = pick.overview, + isUnwatched = pick.isUnwatched, isEpisode = pick.isEpisode, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchResults.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchResults.kt index b89b13c344..bd63fededd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchResults.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/search/SearchResults.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.android.ui.screens.search +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -126,8 +129,13 @@ fun SearchResults( key = { it.contentId }, contentType = { item -> item.type }, ) { item -> - val (actions, userState) = rememberBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) + val hideDetailArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, selectBackdrop = true, positionSeconds = positionSeconds) MediaCard( + hideArtwork = hideArtwork, + hideDetailArtwork = hideDetailArtwork, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/EpisodeSpoilerSettings.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/EpisodeSpoilerSettings.kt new file mode 100644 index 0000000000..364aa676e5 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/EpisodeSpoilerSettings.kt @@ -0,0 +1,38 @@ +package org.siloserver.silo.android.ui.screens.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Spoiler protection for unwatched episodes (see EpisodeSpoilers.MIN_CONTRACT_REVISION). Both + * switches are profile-wide, so the section says they follow the profile to + * every device. Shown only when the server supports the keys. + */ +@Composable +fun EpisodeSpoilerSettings( + hideImages: Boolean, + hideOverviews: Boolean, + onHideImagesChanged: (Boolean) -> Unit, + onHideOverviewsChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + saveError: String? = null, +) { + SettingsSection(title = "Spoilers", modifier = modifier) { + SettingsSwitchRow( + label = "Blur unwatched episode images", + description = "Blur an episode's thumbnail until you start watching it, so the image does not give away the story.", + checked = hideImages, + onCheckedChange = onHideImagesChanged, + ) + SettingsSwitchRow( + label = "Hide unwatched episode descriptions", + description = "Hide an episode's description until you start watching it.", + checked = hideOverviews, + onCheckedChange = onHideOverviewsChanged, + ) + SettingsProse( + body = "Applies to episodes you have not started, on every device that uses this profile.", + ) + saveError?.let { SettingsProse(body = it) } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt index 5902004ab2..21084277cd 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt @@ -168,6 +168,17 @@ fun SettingsPlaybackScreen(viewModel: SettingsViewModel, onBackClick: () -> Unit onUseProfileSetting = viewModel::useProfileSetting, ) } + if (state.episodeSpoilers.isSupported) { + item(key = "spoilers") { + EpisodeSpoilerSettings( + hideImages = state.episodeSpoilers.hideImages, + hideOverviews = state.episodeSpoilers.hideOverviews, + onHideImagesChanged = viewModel::setHideUnwatchedEpisodeImages, + onHideOverviewsChanged = viewModel::setHideUnwatchedEpisodeOverviews, + saveError = state.episodeSpoilerSaveError, + ) + } + } SeekMedia.entries.forEach { media -> item(key = "seek-$media") { SeekIntervalSettings( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index 1cc4a13257..0bbca453fc 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -135,6 +135,7 @@ fun SettingsScreen( val matchesPlayback = search.matches( "playback", "quality", "audio", "dolby vision", "picture-in-picture", "episodes", "next up", "skip", "intros", "credits", "rewind", "still watching", "skip interval", "audiobooks", + "spoilers", "unwatched", "blur", "descriptions", ) val matchesSubtitles = search.matches( "subtitles", "captions", "language", "behavior", "appearance", "forced", "metadata", diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 323b48b9ba..19445f4485 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -7,6 +7,11 @@ import org.siloserver.silo.android.auth.SignOutTeardown import org.siloserver.silo.common.settings.CardPresentationSource import org.siloserver.silo.common.settings.CardPresentationStore import org.siloserver.silo.common.settings.CardPresentationUiState +import org.siloserver.silo.common.settings.EpisodeSpoilerSetting +import org.siloserver.silo.common.settings.EpisodeSpoilerState +import org.siloserver.silo.common.settings.EpisodeSpoilerStore +import org.siloserver.silo.common.settings.LibraryPlaybackPrefsStore +import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.common.settings.SeekIntervalSettingsModel import org.siloserver.silo.common.settings.SeekIntervalStore @@ -140,6 +145,10 @@ data class SettingsUiState( // Media cards: the effective `ui.card_presentation` value plus where it // resolved from and whether the server supports the key at all. val cardPresentation: CardPresentationUiState = CardPresentationUiState(), + /** Spoiler protection for unwatched episodes; toggles show only when supported. */ + val episodeSpoilers: EpisodeSpoilerState = EpisodeSpoilerState(), + /** Why the last spoiler switch change wasn't saved. */ + val episodeSpoilerSaveError: String? = null, // Notifications (in-app). Section is hidden entirely unless the server // reports in-app notifications are enabled AND preferences load. @@ -159,6 +168,7 @@ class SettingsViewModel( private val profileSettings: ProfileSettingsController, private val cardPresentationStore: CardPresentationStore, private val seekIntervalStore: SeekIntervalStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, private val signOutTeardown: SignOutTeardown, audiobookSettingsStore: AudiobookSettingsStore, private val downloadsRepository: DownloadsRepository? = null, @@ -185,6 +195,7 @@ class SettingsViewModel( observePlaybackBehaviorSettings() observeNotifications() observeCardPresentation() + observeEpisodeSpoilers() // Opening Settings is a refresh edge for the seek-interval support probe. seekIntervals.refresh() } @@ -471,6 +482,28 @@ class SettingsViewModel( viewModelScope.launch { cardPresentationStore.useProfileDefault() } } + // -- Spoilers -- + + private fun observeEpisodeSpoilers() { + episodeSpoilerStore.state.onEach { state -> + _uiState.update { it.copy(episodeSpoilers = state) } + }.launchIn(viewModelScope) + episodeSpoilerStore.saveError.onEach { error -> + _uiState.update { it.copy(episodeSpoilerSaveError = error) } + }.launchIn(viewModelScope) + // Opening Settings is a refresh edge, so a change made on another + // device shows here without waiting for the next foreground. + viewModelScope.launch { episodeSpoilerStore.refresh() } + } + + fun setHideUnwatchedEpisodeImages(enabled: Boolean) { + episodeSpoilerStore.set(EpisodeSpoilerSetting.Images, enabled) + } + + fun setHideUnwatchedEpisodeOverviews(enabled: Boolean) { + episodeSpoilerStore.set(EpisodeSpoilerSetting.Overviews, enabled) + } + fun setNotificationsEnabled(value: Boolean) { updateNotificationPreferences(NotificationPreferencesUpdate(enabled = value)) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt index cd2a1c8fad..18651a5403 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyPickerSheet.kt @@ -73,6 +73,7 @@ import org.siloserver.silo.android.ui.components.MediaCard import org.siloserver.silo.android.ui.navigation.LocalHeroSourceHandoff import org.siloserver.silo.android.ui.navigation.LocalSharedTransitionScope import org.siloserver.silo.android.ui.theme.SiloOnSurface +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.android.ui.theme.SiloPageBackground import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.model.catalog.BrowseItem @@ -86,6 +87,7 @@ import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.repository.SectionRepository import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.viewmodel.WatchPartySpoilers import org.siloserver.silo.viewmodel.WatchPartyPickerViewModel /** What choosing a title in the picker does. */ @@ -194,6 +196,7 @@ private data class WatchPartyChoice( /** The series to browse from here, for an episode. */ val seriesId: String?, val lobbyItem: WatchPartyItem, + val spoilers: WatchPartySpoilers = lobbyItem.spoilers, ) { companion object { fun title(item: SectionItem) = WatchPartyChoice( @@ -218,6 +221,7 @@ private data class WatchPartyChoice( title = item.title, subtitle = item.year.takeIf { it > 0 }?.toString(), posterUrl = item.posterUrl, + spoilers = WatchPartySpoilers.of(item), ), ) @@ -248,6 +252,7 @@ private data class WatchPartyChoice( series }, posterUrl = item.posterUrl, + spoilers = WatchPartySpoilers.of(item), ), ) } @@ -273,6 +278,8 @@ private data class WatchPartyChoice( title = episodeTitle, subtitle = "${series.title} · S${next.seasonNumber}:E${next.episodeNumber}", posterUrl = series.posterUrl, + // Series artwork; the episode's own watch state isn't known here. + spoilers = WatchPartySpoilers(unwatchedEpisode = true, posterIsEpisodeStill = false, backdropIsEpisodeStill = false), ), ) } @@ -292,6 +299,9 @@ private fun BrowseItem.toSectionItem() = SectionItem( posterThumbhash = posterThumbhash, backdropUrl = backdropUrl, backdropThumbhash = backdropThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + backdropIsEpisodeStill = backdropIsEpisodeStill, + positionSeconds = positionSeconds, userState = userState, overlaySummary = overlaySummary, ) @@ -516,6 +526,7 @@ private fun PickerSearchField(query: String, onQuery: (String) -> Unit, modifier @Composable private fun PickerShelfRow(shelf: PickerShelf, onOpen: (SectionItem) -> Unit) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { // Home's row heading: 20sp, with the resume row's play glyph. Row( @@ -550,6 +561,7 @@ private fun PickerShelfRow(shelf: PickerShelf, onOpen: (SectionItem) -> Unit) { }, userState = item.userState, onClick = { onOpen(item) }, + hideArtwork = WatchPartySpoilers.of(item).hidesBackdrop(spoilerPrefs, item.backdropUrl), ) } else { MediaCard( @@ -562,6 +574,7 @@ private fun PickerShelfRow(shelf: PickerShelf, onOpen: (SectionItem) -> Unit) { userState = item.userState, progress = shelf.progress[item.contentId], onClick = { onOpen(item) }, + hideArtwork = WatchPartySpoilers.of(item).hidesPoster(spoilerPrefs), ) } } @@ -571,6 +584,7 @@ private fun PickerShelfRow(shelf: PickerShelf, onOpen: (SectionItem) -> Unit) { @Composable private fun PickerSearchResults(state: WatchPartyPickerViewModel.PickerState, onOpen: (SectionItem) -> Unit) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current when { state.results.isNotEmpty() -> LazyVerticalGrid( columns = GridCells.Fixed(3), @@ -589,6 +603,7 @@ private fun PickerSearchResults(state: WatchPartyPickerViewModel.PickerState, on type = item.type, userState = item.userState, onClick = { onOpen(item) }, + hideArtwork = WatchPartySpoilers.of(item).hidesPoster(spoilerPrefs), modifier = Modifier.fillMaxWidth(), width = androidx.compose.ui.unit.Dp.Unspecified, ) @@ -631,11 +646,13 @@ private fun PickerChoicePage( history = memberState(choice.contentId) loadingHistory = false } + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current Box(Modifier.fillMaxSize()) { WatchPartyBackdrop( url = choice.backdropUrl ?: choice.posterUrl, thumbhash = if (choice.backdropUrl != null) choice.backdropThumbhash else choice.posterThumbhash, isPoster = choice.backdropUrl == null, + hidden = choice.spoilers.hidesBackdrop(spoilerPrefs, choice.backdropUrl), ) Column( Modifier @@ -662,7 +679,7 @@ private fun PickerChoicePage( .padding(top = 150.dp, bottom = WatchPartyMetrics.pageInset), ) { Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(WatchPartyMetrics.BODY.dp)) { - WatchPartyHeroPoster(choice.posterUrl, choice.posterThumbhash, 120.dp) + WatchPartyHeroPoster(choice.posterUrl, choice.posterThumbhash, 120.dp, choice.spoilers.hidesPoster(spoilerPrefs)) Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { WatchPartyEyebrow(mode.eyebrow) WatchPartyHeroTitle(choice.title, size = WatchPartyMetrics.HERO_TITLE * 0.9f) @@ -672,7 +689,7 @@ private fun PickerChoicePage( } } } - choice.overview?.takeIf { it.isNotBlank() }?.let { + choice.overview?.takeIf { it.isNotBlank() && !choice.spoilers.hidesOverview(spoilerPrefs) }?.let { Text(it, fontSize = WatchPartyMetrics.BODY.sp, color = SiloSecondaryText, maxLines = 6) } WatchPartyButton( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt index 9aeb3142c6..93bb275918 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyRoomContent.kt @@ -91,7 +91,9 @@ import org.siloserver.silo.model.watchtogether.RoomSnapshot import org.siloserver.silo.model.watchtogether.Suggestion import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.viewmodel.WatchPartySpoilers import org.siloserver.silo.watchtogether.WatchPartyConnectionTone import org.siloserver.silo.watchtogether.WatchPartyEligibility import org.siloserver.silo.watchtogether.WatchPartyFeatures @@ -146,6 +148,7 @@ internal data class WatchPartyStagedTitle( val overview: String?, /** The catalog refused this profile the title (403/404). */ val unavailable: Boolean, + val spoilers: WatchPartySpoilers = WatchPartySpoilers.NONE, ) @Composable @@ -156,7 +159,9 @@ internal fun rememberWatchPartyStagedTitle(room: RoomSnapshot?, suggestions: Lis val libraryId = room?.selectedLibraryId var detail by remember(stagedId) { mutableStateOf<ItemDetail?>(null) } var unavailable by remember(stagedId) { mutableStateOf(false) } - LaunchedEffect(stagedId, libraryId) { + // Re-read on each phase change: after the party plays the title, its watch + // state decides whether spoiler protection still hides it. + LaunchedEffect(stagedId, libraryId, room?.phase) { val id = stagedId ?: return@LaunchedEffect when (val result = catalog.getItemDetail(id, libraryId)) { is ApiResult.Success -> detail = result.data @@ -179,9 +184,11 @@ internal fun rememberWatchPartyStagedTitle(room: RoomSnapshot?, suggestions: Lis chips = emptyList(), overview = null, unavailable = unavailable, + spoilers = preview?.spoilers ?: WatchPartySpoilers.NONE, ) } val episode = loaded.type == "episode" + val spoilers = WatchPartySpoilers.of(loaded) val versions = loaded.versions val version = versions.firstOrNull { it.fileId == room.selectedFileId } ?: versions.singleOrNull() return WatchPartyStagedTitle( @@ -196,8 +203,9 @@ internal fun rememberWatchPartyStagedTitle(room: RoomSnapshot?, suggestions: Lis watchPartyRuntime(loaded.runtime), ), chips = watchPartyQualityChips(version), - overview = loaded.overview?.takeIf { it.isNotBlank() }, + overview = loaded.overview?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(LocalEpisodeSpoilerPrefs.current) }, unavailable = false, + spoilers = spoilers, ) } @@ -241,13 +249,19 @@ internal fun WatchPartyRoomContent( val winner = roomVoteWinner(suggestions) // The film is the room: its backdrop, else its poster, else a voting // room's leader, blurred. - val leaderPoster = if (room.selectionMode == RoomSelectionMode.Vote) { - (winner ?: suggestions.firstOrNull())?.posterUrl?.takeIf { it.isNotBlank() } + val leader = if (room.selectionMode == RoomSelectionMode.Vote) { + (winner ?: suggestions.firstOrNull())?.takeIf { it.posterUrl.isNotBlank() } } else { null } - val backdrop = staged?.backdropUrl ?: staged?.posterUrl ?: leaderPoster + val backdrop = staged?.backdropUrl ?: staged?.posterUrl ?: leader?.posterUrl val backdropIsPoster = staged?.backdropUrl == null + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val backdropHidden = if (staged != null && (staged.backdropUrl != null || staged.posterUrl != null)) { + staged.spoilers.hidesBackdrop(spoilerPrefs, staged.backdropUrl) + } else { + leader?.let { WatchPartySpoilers.of(it).hidesPoster(spoilerPrefs) } ?: false + } val primary = watchPartyPrimaryAction(room, features, suggestions, unavailable) val secondary = watchPartySecondaryAction(room, primary) @@ -308,6 +322,7 @@ internal fun WatchPartyRoomContent( url = backdrop, thumbhash = if (backdropIsPoster) staged?.posterThumbhash else staged?.backdropThumbhash, isPoster = backdropIsPoster, + hidden = backdropHidden, ) Column( Modifier @@ -415,7 +430,12 @@ private fun RoomHero(room: RoomSnapshot, staged: WatchPartyStagedTitle?, suggest modifier = Modifier.padding(top = if (compact) 4.dp else if (showsPoster) 120.dp else 40.dp), ) { if (showsPoster) { - WatchPartyHeroPoster(staged?.posterUrl, staged?.posterThumbhash, if (compact) 72.dp else 112.dp) + WatchPartyHeroPoster( + staged?.posterUrl, + staged?.posterThumbhash, + if (compact) 72.dp else 112.dp, + hidden = staged?.spoilers?.hidesPoster(LocalEpisodeSpoilerPrefs.current) == true, + ) } Column(verticalArrangement = Arrangement.spacedBy((titleSize * 0.28f).dp), modifier = Modifier.weight(1f)) { WatchPartyEyebrow(hero.eyebrow) @@ -781,7 +801,13 @@ private fun CandidateRow( } .padding(8.dp), ) { - WatchPartyPoster(suggestion.posterUrl, null, WatchPartyMetrics.ballotPosterWidth, cornerRadius = 6.dp) + WatchPartyPoster( + suggestion.posterUrl, + null, + WatchPartyMetrics.ballotPosterWidth, + cornerRadius = 6.dp, + hidden = WatchPartySpoilers.of(suggestion).hidesPoster(LocalEpisodeSpoilerPrefs.current), + ) SuggestionText(suggestion, Modifier.weight(1f)) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(min = 48.dp)) { Text( @@ -941,7 +967,13 @@ private fun HostPickSuggestionRow( } .padding(8.dp), ) { - WatchPartyPoster(suggestion.posterUrl, null, WatchPartyMetrics.ballotPosterWidth, cornerRadius = 6.dp) + WatchPartyPoster( + suggestion.posterUrl, + null, + WatchPartyMetrics.ballotPosterWidth, + cornerRadius = 6.dp, + hidden = WatchPartySpoilers.of(suggestion).hidesPoster(LocalEpisodeSpoilerPrefs.current), + ) SuggestionText(suggestion, Modifier.weight(1f)) when { queued -> Text( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt index 89715eb923..3a4db0e556 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/watchparty/WatchPartyStyle.kt @@ -67,7 +67,7 @@ import org.siloserver.silo.android.ui.theme.SiloBackground import org.siloserver.silo.android.ui.theme.SiloOnSurface import org.siloserver.silo.android.ui.theme.SiloSecondaryText import org.siloserver.silo.android.ui.theme.SiloSurfaceElevated -import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.model.watchtogether.RoomMember import org.siloserver.silo.model.watchtogether.RoomPhase import org.siloserver.silo.watchtogether.WatchPartySeatState @@ -128,18 +128,25 @@ internal fun WatchPartyBackdrop( thumbhash: String? = null, isPoster: Boolean = false, modifier: Modifier = Modifier, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, ) { Box(modifier.fillMaxSize().background(SiloBackground)) { - Crossfade(targetState = url?.takeIf { it.isNotBlank() }, animationSpec = tween(400), label = "backdrop") { art -> + // Each artwork keeps its own treatment while it fades out, so an + // outgoing protected still is never drawn unhidden. A layer showing + // the current artwork is hidden at once when protection turns on. + val target = url?.takeIf { it.isNotBlank() }?.let { BackdropArt(it, thumbhash, isPoster, hidden) } + Crossfade(targetState = target, animationSpec = tween(400), label = "backdrop") { art -> if (art != null) { - ThumbhashImage( - url = art, - thumbhash = thumbhash, + SpoilerImage( + url = art.url, + thumbhash = art.thumbhash, + hidden = art.hidden || (target?.hidden == true && art.url == target.url), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() - .then(if (isPoster) Modifier.blur(28.dp).alpha(0.7f) else Modifier), + .then(if (art.isPoster) Modifier.blur(28.dp).alpha(0.7f) else Modifier), ) } } @@ -159,6 +166,8 @@ internal fun WatchPartyBackdrop( } } +private data class BackdropArt(val url: String, val thumbhash: String?, val isPoster: Boolean, val hidden: Boolean) + /** Transparent bar: a round Back and the centred page title. */ @Composable internal fun WatchPartyTopBar(onBack: (() -> Unit)?, modifier: Modifier = Modifier, title: String = "Watch Party") { @@ -546,6 +555,8 @@ internal fun WatchPartyPoster( width: Dp, modifier: Modifier = Modifier, cornerRadius: Dp = 8.dp, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, ) { val shape = RoundedCornerShape(cornerRadius) Box( @@ -555,9 +566,10 @@ internal fun WatchPartyPoster( if (url.isNullOrBlank()) { Icon(Icons.Outlined.Movie, contentDescription = null, tint = SiloSecondaryText) } else { - ThumbhashImage( + SpoilerImage( url = url, thumbhash = thumbhash, + hidden = hidden, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -568,11 +580,12 @@ internal fun WatchPartyPoster( /** A poster with Apple's lifted shadow, for the lobby hero and the confirmation page. */ @Composable -internal fun WatchPartyHeroPoster(url: String?, thumbhash: String?, width: Dp) { +internal fun WatchPartyHeroPoster(url: String?, thumbhash: String?, width: Dp, hidden: Boolean = false) { WatchPartyPoster( url = url, thumbhash = thumbhash, width = width, + hidden = hidden, modifier = Modifier.shadow(16.dp, RoundedCornerShape(8.dp), ambientColor = Color.Black, spotColor = Color.Black), ) } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt index e49f071df9..c3f8dbe718 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/ProfileSwitchTeardownTest.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import org.siloserver.silo.common.settings.EpisodeSpoilerStore import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.common.settings.SeekIntervalStore import org.siloserver.silo.common.settings.TitleArtStore @@ -52,7 +53,7 @@ class ProfileSwitchTeardownTest { assertEquals("flush", events.first()) assertEquals("leave", events[1], "the shell is left only after the flush") assertEquals( - setOf("clear:overlays", "clear:cards", "clear:seek", "clear:titleArt"), + setOf("clear:overlays", "clear:cards", "clear:seek", "clear:titleArt", "clear:spoilers"), events.drop(2).toSet(), ) } @@ -106,6 +107,7 @@ class ProfileSwitchTeardownTest { cardPresentationStore = recordingClear("cards", onClear), seekIntervalStore = recordingClear<SeekIntervalStore>("seek", onClear), titleArtStore = recordingClear<TitleArtStore>("titleArt", onClear), + episodeSpoilerStore = recordingClear<EpisodeSpoilerStore>("spoilers", onClear), flushTimeoutMs = flushTimeoutMs, ) diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt index 99385a1ef2..18c47075e2 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/auth/SignOutTeardownTest.kt @@ -56,6 +56,7 @@ class SignOutTeardownTest { cardPresentationStore = idle(), seekIntervalStore = idle(), titleArtStore = idle(), + episodeSpoilerStore = idle(), requestsFeatureStore = RequestsFeatureStore(RequestsRepository(idle())), metadataAiFeatureStore = MetadataAiFeatureStore(MetadataAiRepository(idle())), serverRegistry = registry, diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/WatchedRollbackTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/WatchedRollbackTest.kt new file mode 100644 index 0000000000..d52b21016e --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/WatchedRollbackTest.kt @@ -0,0 +1,28 @@ +package org.siloserver.silo.android.ui.screens.detail + +import org.siloserver.silo.model.catalog.LeafItemUserData +import kotlin.test.Test +import kotlin.test.assertEquals + +class WatchedRollbackTest { + private val started = LeafItemUserData(played = false, isInProgress = true, positionSeconds = 300.0, durationSeconds = 3000.0) + + @Test + fun anUntouchedToggleRestoresTheSnapshot() { + assertEquals(started, started.withPlayed(true).withoutPlayed(true, started)) + } + + @Test + fun progressRecordedSinceTheToggleStaysWhileWatchedIsUndone() { + val sampled = started.withPlayed(true).copy(isInProgress = true, positionSeconds = 900.0) + + assertEquals(sampled.copy(played = false), sampled.withoutPlayed(true, started)) + } + + @Test + fun aReloadThatNoLongerShowsTheToggleIsKept() { + val reloaded = LeafItemUserData(played = false, isInProgress = true, positionSeconds = 1200.0) + + assertEquals(reloaded, reloaded.withoutPlayed(true, started)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadArtworkTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadArtworkTest.kt new file mode 100644 index 0000000000..04dde5201e --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadArtworkTest.kt @@ -0,0 +1,84 @@ +package org.siloserver.silo.android.ui.screens.downloads + +import org.siloserver.silo.model.catalog.LeafItemUserData +import org.siloserver.silo.model.download.DownloadMediaType +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.repository.port.LocalContentState +import org.siloserver.silo.repository.port.LocalPlaybackProgress +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DownloadArtworkTest { + private val prefs = EpisodeSpoilerPrefs(hideImages = true) + private val episode = DownloadItem( + id = "download", contentId = "episode", title = "Episode", posterUrl = "still", + mediaType = DownloadMediaType.TvShow, posterIsEpisodeStill = true, + ) + + @Test + fun unwatchedStillsAndLegacyUnknownArtAreProtected() { + assertTrue(downloadItemHidesArtwork(episode, prefs)) + assertTrue(downloadItemHidesArtwork(episode.copy(posterIsEpisodeStill = null), prefs)) + assertFalse(downloadItemHidesArtwork(episode.copy(posterIsEpisodeStill = false), prefs)) + assertFalse(downloadItemHidesArtwork(episode, EpisodeSpoilerPrefs.NONE)) + assertFalse(downloadItemHidesArtwork(episode.copy(mediaType = DownloadMediaType.Movie), prefs)) + } + + @Test + fun watchedAndStartedEpisodeStillsRemainVisible() { + for (state in listOf( + LeafItemUserData(played = true), + LeafItemUserData(isInProgress = true), + LeafItemUserData(positionSeconds = 1.0), + )) { + assertFalse(downloadItemHidesArtwork(episode.copy(episodeUserData = state), prefs)) + } + } + + @Test + fun markingUnwatchedClearsTheSavedProgressUntilPlaybackStartsAgain() { + val saved = LeafItemUserData(played = true, isInProgress = true, positionSeconds = 30.0) + val reset = overlayDownloadEpisodeUserData(saved, LocalContentState(watched = false, favorite = null), null) + assertTrue(downloadItemHidesArtwork(episode.copy(episodeUserData = reset), prefs)) + val resumed = overlayDownloadEpisodeUserData(saved, + LocalContentState(watched = false, favorite = null), LocalPlaybackProgress(1, 5.0, 60.0)) + assertFalse(downloadItemHidesArtwork(episode.copy(episodeUserData = resumed), prefs)) + } + + @Test + fun currentServerWatchStateReplacesSavedAndAcknowledgedLocalState() { + val watched = LeafItemUserData(played = true, isInProgress = true, positionSeconds = 30.0) + val unwatched = LeafItemUserData(played = false, isInProgress = false, positionSeconds = 0.0) + val reset = resolveDownloadEpisodeUserData(watched, unwatched, + LocalContentState(watched = true, favorite = null), LocalPlaybackProgress(1, 30.0, 60.0), false) + assertTrue(downloadItemHidesArtwork(episode.copy(episodeUserData = reset), prefs)) + val remoteWatched = resolveDownloadEpisodeUserData(unwatched, watched, null, null, false) + assertFalse(downloadItemHidesArtwork(episode.copy(episodeUserData = remoteWatched), prefs)) + val localReset = resolveDownloadEpisodeUserData(watched, watched, + LocalContentState(watched = false, favorite = null), null, true) + assertTrue(downloadItemHidesArtwork(episode.copy(episodeUserData = localReset), prefs)) + } + + @Test + fun groupedHeadersProtectTheEpisodeWhosePosterTheyUse() { + val season = DownloadEntry.Season( + "series", "Series", 1, listOf(DownloadEntry.Single(episode)), "still", null, + ) + val series = DownloadEntry.Series("series", "Series", listOf(season), "still", null) + assertTrue(downloadEntryHidesArtwork(season, prefs)) + assertTrue(downloadEntryHidesArtwork(series, prefs)) + assertFalse(downloadEntryHidesArtwork(series.copy(posterUrl = "series-art"), prefs)) + val safeFirst = season.copy(episodes = listOf( + DownloadEntry.Single(episode.copy(posterIsEpisodeStill = false)), + DownloadEntry.Single(episode.copy(id = "legacy", posterIsEpisodeStill = null)), + )) + assertFalse(downloadEntryHidesArtwork(safeFirst, prefs)) + val otherSeason = season.copy( + seasonNumber = 2, + episodes = listOf(DownloadEntry.Single(episode.copy(id = "s2", posterUrl = "other-still"))), + posterSource = episode, + ) + assertTrue(downloadEntryHidesArtwork(otherSeason, prefs)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt index 4a6e54add5..d60de9e27e 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -183,6 +183,10 @@ class SettingsViewModelOfflinePreferencesTest { "getState" to MutableStateFlow(SeekIntervalState()), "getLastError" to MutableStateFlow<String?>(null), )), + episodeSpoilerStore = idleStore(mapOf( + "getState" to MutableStateFlow(org.siloserver.silo.common.settings.EpisodeSpoilerState()), + "getSaveError" to MutableStateFlow<String?>(null), + )), signOutTeardown = SignOutTeardown( authRepository = authRepository, playerSettingsStore = idleStore(), @@ -192,6 +196,7 @@ class SettingsViewModelOfflinePreferencesTest { cardPresentationStore = idleStore(), seekIntervalStore = idleStore(), titleArtStore = idleStore(), + episodeSpoilerStore = idleStore(), requestsFeatureStore = RequestsFeatureStore(RequestsRepository(idleStore())), metadataAiFeatureStore = MetadataAiFeatureStore(MetadataAiRepository(idleStore())), serverRegistry = idleStore(mapOf("getActiveServerId" to MutableStateFlow<String?>(null))), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index ae0ebf5a31..c984818c99 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -334,7 +334,7 @@ val androidTvModule = module { // reset every path uses to drop the active profile. single { TvProfileLaunchPreferences(androidContext()) } single { TvProfileAwayTracker(get(), get(), get()) } - single { TvActiveProfileReset(get(), get(), get(), get(), get(), get(), get()) } + single { TvActiveProfileReset(get(), get(), get(), get(), get(), get(), get(), get()) } // Deep-link bridge between MainTvActivity (producer) and TvAppNavigation // (consumer). The Activity writes incoming Silo app-scheme URIs here on @@ -627,6 +627,7 @@ val androidTvModule = module { profileSettings = get(), tvLibraryScopeStore = getOrNull(), seekIntervalStore = get(), + episodeSpoilerStore = get(), audiobookSettingsStore = get(), profileLaunchPreferences = get(), watchNextSeeder = get(), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt index 0aab21798f..f2684e7fad 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvActiveProfileReset.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.tv.profiles import org.siloserver.silo.common.settings.CardPresentationStore +import org.siloserver.silo.common.settings.EpisodeSpoilerStore import org.siloserver.silo.common.settings.LibraryPlaybackPrefsStore import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.common.settings.SeekIntervalStore @@ -20,6 +21,7 @@ class TvActiveProfileReset( private val cardPresentationStore: CardPresentationStore, private val seekIntervalStore: SeekIntervalStore, private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, private val watchNextSeeder: WatchNextSeeder, ) { suspend fun clearActiveProfile() { @@ -35,6 +37,7 @@ class TvActiveProfileReset( cardPresentationStore.clear() seekIntervalStore.clear() titleArtStore.clear() + episodeSpoilerStore.clear() // Clear the previous profile's Watch Next rows before // landing on the picker; the new profile will re-seed // when it is selected. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCardActionsHelpers.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCardActionsHelpers.kt index 16489ec300..9fbcbd2ec9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCardActionsHelpers.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCardActionsHelpers.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import org.siloserver.silo.domain.MediaActionsCoordinator import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.withWatched import org.siloserver.silo.model.catalog.MediaItemUserState import org.siloserver.silo.network.ApiResult import kotlinx.coroutines.launch @@ -24,23 +25,23 @@ import org.koin.compose.koinInject @Composable fun rememberTvBrowseItemCardActions( item: BrowseItem, -): Pair<TvMediaCardActions, MediaItemUserState> { +): Triple<TvMediaCardActions, MediaItemUserState, Double?> { val coordinator: MediaActionsCoordinator = koinInject() val scope = rememberCoroutineScope() // Key on userState too (phone parity): when refreshed data re-emits with a // changed userState, the card must reflect new watched/favorite/watchlist // badges instead of keeping the stale snapshot. - var state by remember(item.contentId, item.userState) { - mutableStateOf(item.userState ?: MediaItemUserState()) + var state by remember(item.contentId, item.userState, item.positionSeconds) { + mutableStateOf(item) } - val actions = remember(item.contentId, coordinator, scope) { + val actions = remember(item.contentId, item.userState, item.positionSeconds, coordinator, scope) { TvMediaCardActions( onSetWatched = { watched -> val writeIntent = coordinator.beginWatched(item.contentId, watched) val previous = state - state = state.copy(played = watched) + state = state.withWatched(watched) scope.launch { if (coordinator.performPersonalWrite(writeIntent) !is ApiResult.Success) { if (coordinator.isCurrent(writeIntent)) state = previous @@ -59,10 +60,10 @@ fun rememberTvBrowseItemCardActions( } val membershipActions by coordinator.memberships.actions.collectAsState() - var displayed = state + var displayed = state.userState ?: MediaItemUserState() membershipActions.values.filter { it.intent.key.itemId == item.contentId && it.baseline != null && coordinator.memberships.current(it.intent) }.forEach { displayed = if (it.intent.key.kind == org.siloserver.silo.repository.port.MembershipPort.Kind.FAVORITE) displayed.copy(isFavorite = it.baseline!!.present) else displayed.copy(inWatchlist = it.baseline!!.present) } - return actions to displayed + return Triple(actions, displayed, state.positionSeconds) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCatalogGrid.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCatalogGrid.kt index 8b7dc31feb..c0a3152e5f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCatalogGrid.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvCatalogGrid.kt @@ -1,5 +1,8 @@ package org.siloserver.silo.tv.ui.components +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers + import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement @@ -265,7 +268,9 @@ fun TvCatalogGrid( key = { _, item -> item.contentId }, contentType = { _, item -> item.type }, ) { index, item -> - val (actions, userState) = rememberTvBrowseItemCardActions(item) + val (actions, userState, positionSeconds) = rememberTvBrowseItemCardActions(item) + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hideArtwork = EpisodeSpoilers.hidesBrowseArtwork(item, spoilerPrefs, userState.played, positionSeconds = positionSeconds) val isRestoreTarget = restoreItemFocusRequester != null && index == resolvedRestoreItemIndex if (isRestoreTarget) { @@ -296,6 +301,7 @@ fun TvCatalogGrid( } } TvMediaCard( + hideArtwork = hideArtwork, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt index 701767ee3f..9509360da4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvEpisodeCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.components +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.background @@ -64,6 +65,8 @@ fun TvEpisodeCard( stillUrl: String?, stillThumbhash: String?, onClick: () -> Unit, + /** Spoiler protection: blur the still of an episode not yet started. */ + hideStill: Boolean = false, modifier: Modifier = Modifier, seriesTitle: String? = null, year: Int? = null, @@ -116,9 +119,10 @@ fun TvEpisodeCard( .aspectRatio(16f / 9f), ) { Box(modifier = Modifier.fillMaxSize()) { - ThumbhashImage( + SpoilerImage( url = stillUrl, thumbhash = stillThumbhash, + hidden = hideStill, contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt index d028977e29..0df84bfdf9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt @@ -40,6 +40,7 @@ import org.siloserver.silo.common.settings.titleLogoUrl import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.tv.ui.theme.SiloOnSurface import org.siloserver.silo.tv.ui.theme.SiloSecondaryText +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs /** * Passive Skyline billboard, anchored bottom-left, that previews whichever row @@ -79,6 +80,7 @@ fun TvFocusMarquee( // animation — a cold entry paints the finished marquee block instead // of fading it up. Focus-driven swaps keep the crossfade. var hasDisplayedContent by remember { mutableStateOf(false) } + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current val snapInitialContent = content != null && !hasDisplayedContent LaunchedEffect(content != null) { if (content != null) hasDisplayedContent = true @@ -101,8 +103,15 @@ fun TvFocusMarquee( contentAlignment = Alignment.BottomStart, ) { if (value != null) { + // A layer for the item still shown takes its current + // copy at once, so a synopsis protection now hides + // doesn't fade out. TvMarqueeBlock( - content = value, + content = if (content != null && value.id == content.id) { + content + } else { + value.underPrefs(spoilerPrefs) + }, detailLine = detailLine.takeIf { value.id == content?.id }, footer = footer, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt index 8119ee23ee..d6ebcb1604 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModel.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.tv.ui.components import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -10,7 +11,11 @@ import androidx.compose.runtime.setValue import org.siloserver.silo.model.catalog.ExternalRatings import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.OverlaySummary +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.model.section.ResolvedSection import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Locale @@ -53,6 +58,11 @@ data class TvMarqueeContent( /** The source item, retained so the ambient tint extracts its palette from * the same (debounced) card the marquee + backdrop show. */ val source: SectionItem, + /** Spoiler protection hides this unwatched episode's stills, including an + * enrichment backdrop that is (or may be) one. */ + val hidesStills: Boolean = false, + /** Whether [backdropUrl], from the section item or enrichment, is an episode still; null when unknown. */ + val backdropIsEpisodeStill: Boolean? = null, ) { /** Backdrop art for the root hero. Like tvOS, the section artwork is shown * immediately; an episode can later upgrade to its higher-resolution series @@ -64,6 +74,38 @@ data class TvMarqueeContent( /** Stable per-item key for the §9 enrichment cache + stale-fetch guard. */ val contentId: String get() = source.contentId + /** + * This content under [prefs], so a layer still fading out of a crossfade + * follows a protection change at once: the same instance when nothing + * changes, without what [prefs] now hide, or rebuilt from its source item + * when [prefs] reveal something. + */ + fun underPrefs(prefs: EpisodeSpoilerPrefs): TvMarqueeContent { + if (!isEpisode) return this + val unwatched = EpisodeSpoilers.isUnwatched(source) + val hideStills = prefs.hidesImage(unwatched) + val hasOverview = !source.overview.isNullOrBlank() + val hideSynopsis = prefs.hidesOverview(unwatched) && hasOverview + val synopsisHidden = synopsis == null && hasOverview + if (hideStills == hidesStills && hideSynopsis == synopsisHidden) return this + // Revealing needs the source item's artwork and text back. + if ((hidesStills && !hideStills) || (synopsisHidden && !hideSynopsis)) { + return from(source, rowTitle = "", spoilers = prefs).copy(id = id) + } + // Hiding drops only what is now hidden, so explicit series artwork + // (such as an enrichment backdrop) stays. + val dropBackdrop = hideStills && backdropIsEpisodeStill != false + val dropPoster = hideStills && source.posterIsEpisodeStill != false + return copy( + synopsis = if (hideSynopsis) null else synopsis, + backdropUrl = if (dropBackdrop) null else backdropUrl, + backdropThumbhash = if (dropBackdrop) null else backdropThumbhash, + posterUrl = if (dropPoster) null else posterUrl, + posterThumbhash = if (dropPoster) null else posterThumbhash, + hidesStills = hideStills, + ) + } + /** * Fold a landed [TvMarqueeEnrichment] into this content (tvOS * `TVFocusMarqueeModel.backdropURL` + `detailLine`). The aired/cast line @@ -72,7 +114,8 @@ data class TvMarqueeContent( * preserved, so the swap reads as an in-place refresh, not a new block. */ fun withEnrichment(enrichment: TvMarqueeEnrichment): TvMarqueeContent { - val upgradeBackdrop = isEpisode && !enrichment.backdropUrl.isNullOrBlank() + val upgradeBackdrop = isEpisode && !enrichment.backdropUrl.isNullOrBlank() && + (!hidesStills || enrichment.backdropIsEpisodeStill == false) return copy( detailLine = enrichment.detailLine ?: detailLine, backdropUrl = if (upgradeBackdrop) enrichment.backdropUrl else backdropUrl, @@ -81,16 +124,25 @@ data class TvMarqueeContent( } else { backdropThumbhash }, + backdropIsEpisodeStill = if (upgradeBackdrop) enrichment.backdropIsEpisodeStill else backdropIsEpisodeStill, ) } companion object { + /** + * [spoilers] applies spoiler protection to an episode the profile has + * not started: no synopsis, and no still as the backdrop. The still's + * ThumbHash stays as a colour field until enrichment swaps in the + * series backdrop. + */ fun from( item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle, + spoilers: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE, ): TvMarqueeContent { val isEpisode = item.type.equals("episode", ignoreCase = true) + val unwatchedEpisode = isEpisode && EpisodeSpoilers.isUnwatched(item) val meta = mutableListOf<TvHeroFactToken>() if (isEpisode) { @@ -109,6 +161,8 @@ data class TvMarqueeContent( ?.let(::listOf) .orEmpty() + val hidesBackdrop = spoilers.hidesImage(unwatchedEpisode, item.backdropIsEpisodeStill) + val hidesPoster = spoilers.hidesImage(unwatchedEpisode, item.posterIsEpisodeStill) val sectionBackdropUrl = item.backdropUrl?.takeIf { it.isNotBlank() } val sectionPosterUrl = item.posterUrl?.takeIf { it.isNotBlank() } return TvMarqueeContent( @@ -117,22 +171,30 @@ data class TvMarqueeContent( logoUrl = item.logoUrl?.takeIf { it.isNotBlank() }, badges = badges, metaParts = meta, - synopsis = item.overview?.takeIf { it.isNotBlank() }, + synopsis = item.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatchedEpisode) }, detailLine = null, specLine = specLine(item.overlaySummary), // Match tvOS: a section backdrop (or poster fallback) is always // available for the first rested frame. Episode enrichment may - // replace it with series art later. - backdropUrl = sectionBackdropUrl ?: sectionPosterUrl, - backdropThumbhash = if (sectionBackdropUrl != null) { + // replace it with series art later. Hidden stills also drop + // their ThumbHash so the placeholder cannot reveal them. + backdropUrl = (if (hidesBackdrop) null else sectionBackdropUrl) ?: (if (hidesPoster) null else sectionPosterUrl), + backdropThumbhash = if (!hidesBackdrop && sectionBackdropUrl != null) { item.backdropThumbhash - } else { + } else if (!hidesPoster) { item.posterThumbhash - }, - posterUrl = sectionPosterUrl, - posterThumbhash = item.posterThumbhash, + } else null, + posterUrl = if (hidesPoster) null else sectionPosterUrl, + posterThumbhash = if (hidesPoster) null else item.posterThumbhash, isEpisode = isEpisode, source = item, + hidesStills = spoilers.hidesImage(unwatchedEpisode), + backdropIsEpisodeStill = if (!hidesBackdrop && sectionBackdropUrl != null) { + item.backdropIsEpisodeStill + } else { + item.posterIsEpisodeStill + }, ) } @@ -217,6 +279,8 @@ data class TvMarqueeEnrichment( val detailLine: String?, val backdropUrl: String?, val backdropThumbhash: String?, + /** The detail's provenance for [backdropUrl]; null when unknown. */ + val backdropIsEpisodeStill: Boolean? = null, ) { companion object { fun from(detail: ItemDetail): TvMarqueeEnrichment { @@ -232,6 +296,7 @@ data class TvMarqueeEnrichment( detailLine = if (parts.isEmpty()) null else parts.joinToString(" · "), backdropUrl = detail.backdropUrl?.takeIf { it.isNotBlank() }, backdropThumbhash = detail.backdropThumbhash, + backdropIsEpisodeStill = detail.backdropIsEpisodeStill, ) } @@ -288,9 +353,33 @@ class TvFocusMarqueeState internal constructor() { private val enrichmentCache = mutableMapOf<String, TvMarqueeEnrichment>() private val enrichmentRequests = mutableSetOf<String>() + /** The profile's spoiler protection, kept current by [rememberTvFocusMarqueeState]. */ + internal var spoilerPrefs: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE + set(value) { + if (field == value) return + field = value + content = content?.withCurrentSpoilers() + candidate = candidate?.withCurrentSpoilers() + } + + private fun TvMarqueeContent.withCurrentSpoilers(): TvMarqueeContent = + TvMarqueeContent.from(source, rowTitle = "", spoilers = spoilerPrefs).copy(id = id) + + /** Refresh retained watch state without another focus event or a new detail fetch. */ + internal fun refreshSources(rows: List<ResolvedSection>) { + fun TvMarqueeContent.refreshed(): TvMarqueeContent { + val row = rows.firstOrNull { "${it.id}#$contentId" == id } ?: return this + val item = row.items.firstOrNull { it.contentId == contentId } ?: return this + if (item == source) return this + return TvMarqueeContent.from(item, row.title, row.id, spoilerPrefs) + } + content = content?.refreshed() + candidate = candidate?.refreshed() + } + /** Report card focus. The displayed content swaps on the next composition turn. */ fun preview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { - val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity, spoilerPrefs) 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 @@ -309,7 +398,7 @@ class TvFocusMarqueeState internal constructor() { */ fun seedInitialPreview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { if (focusedMarqueeId != null) return - val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity, spoilerPrefs) if (candidate?.id == next.id || content?.id == next.id) return candidate = next } @@ -318,7 +407,7 @@ class TvFocusMarqueeState internal constructor() { // Apply cached enrichment in the same snapshot as the base-content swap, // so revisiting an item presents one complete frame without a refetch. enrichment = value?.contentId?.let(enrichmentCache::get) - content = value + content = value?.withCurrentSpoilers() } /** True if detail for [contentId] is already cached (skip the fetch). */ @@ -362,12 +451,14 @@ fun rememberTvFocusMarqueeState( fetchDetail: (suspend (String) -> ItemDetail?)? = null, ): TvFocusMarqueeState { val state = remember { TvFocusMarqueeState() } - LaunchedEffect(state.candidate?.id) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + SideEffect { state.spoilerPrefs = spoilerPrefs } + LaunchedEffect(state.candidate) { val candidate = state.candidate ?: return@LaunchedEffect // Page-entry seed stays immediate so the hero never opens blank. Only // subsequent D-pad focus moves wait for the focus-rest interval. if (state.content != null) delay(TvMarqueeFocusRestMillis) - if (state.candidate?.id == candidate.id) state.commit(candidate) + if (state.candidate == candidate) state.commit(candidate) } // Populate the cache and enrich the active hero when identity still diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt index b10fdd5aad..3d74ad7623 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCard.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.tv.ui.components +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import androidx.compose.foundation.background @@ -70,6 +71,7 @@ import org.siloserver.silo.tv.ui.util.tvArtworkAspectRatioForMediaType @Composable fun TvMediaCard( title: String, + hideArtwork: Boolean = false, posterUrl: String?, posterThumbhash: String?, onClick: () -> Unit, @@ -142,7 +144,8 @@ fun TvMediaCard( }, ) { Box(modifier = Modifier.fillMaxSize()) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = posterUrl, thumbhash = posterThumbhash, contentDescription = title, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt index 0b1c337236..0c3f177e5f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaRow.kt @@ -26,7 +26,9 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import androidx.compose.ui.ExperimentalComposeUiApi +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.overlays.OverlayData import org.siloserver.silo.overlays.OverlayDataExtractor import org.siloserver.silo.tv.ui.focus.TvFocusLog @@ -51,6 +53,10 @@ private data class TvMediaRowItemModel( val shelfSubtitle: String?, val overlay: OverlayData, val contentType: String, + /** Spoiler protection: blur this card's still (an unstarted episode). */ + val hidesArtwork: Boolean, + /** The same for the poster layout, which shows the item's own poster. */ + val hidesPoster: Boolean, ) /** @@ -75,6 +81,12 @@ fun TvMediaRow( icon: ImageVector? = null, onSeeAllClick: (() -> Unit)? = null, showProgress: Boolean = false, + /** + * This profile's own unwatched state per content id, when [items] carry + * someone else's progress (a party picker's group progress). Spoiler + * protection uses it instead of the items' position. + */ + unwatchedById: Map<String, Boolean>? = null, style: TvRowStyle = TvRowStyle.Poster, cardLayout: TvRowCardLayout = TvRowCardLayout.Default, horizontalPadding: androidx.compose.ui.unit.Dp = Spacing.safeArea, @@ -135,13 +147,16 @@ fun TvMediaRow( } if (items.isEmpty()) return val rowState = rememberLazyListState() - val rowItems = remember(items, showProgress, style, cardLayout) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val rowItems = remember(items, showProgress, style, cardLayout, spoilerPrefs, unwatchedById) { // Deduplicate before keying. A repeated contentId inside one row makes // the lazy list throw ("Key ... was already used"), which is fatal — // and a row has no reason to show the same title twice anyway. Feeds // can legitimately overlap, so this is a property of the row, not a // bug to fix upstream of it. items.distinctBy { it.contentId }.map { item -> + val unwatchedEpisode = item.type.equals("episode", ignoreCase = true) && + (unwatchedById?.get(item.contentId) ?: EpisodeSpoilers.isUnwatched(item)) TvMediaRowItemModel( item = item, progress = if (showProgress) item.progressFraction() else null, @@ -152,6 +167,9 @@ fun TvMediaRow( shelfSubtitle = item.shelfSubtitle(showProgress = showProgress), overlay = OverlayDataExtractor.fromSectionItem(item), contentType = "${cardLayout.name}:${style.name}:${item.type}", + // Protect the selected still while preserving series fallback art. + hidesArtwork = spoilerPrefs.hidesImage(unwatchedEpisode, EpisodeSpoilers.selectedImageIsStill(item)), + hidesPoster = spoilerPrefs.hidesImage(unwatchedEpisode, item.posterIsEpisodeStill), ) } } @@ -342,6 +360,7 @@ fun TvMediaRow( val itemLongClick = remember(item, longClickAction) { longClickAction(item) } when (cardLayout) { TvRowCardLayout.ReferenceShelf -> TvReferenceShelfCard( + hideArtwork = rowItem.hidesArtwork, title = rowItem.shelfTitle, imageUrl = rowItem.backdropUrl, imageThumbhash = rowItem.backdropThumbhash, @@ -361,6 +380,7 @@ fun TvMediaRow( title = item.title, stillUrl = rowItem.backdropUrl, stillThumbhash = rowItem.backdropThumbhash, + hideStill = rowItem.hidesArtwork, seriesTitle = item.seriesTitle, seasonNumber = item.seasonNumber, episodeNumber = item.episodeNumber, @@ -376,6 +396,7 @@ fun TvMediaRow( onLongClick = itemLongClick, ) TvRowStyle.Poster -> TvMediaCard( + hideArtwork = rowItem.hidesPoster, title = item.title, posterUrl = item.posterUrl, posterThumbhash = item.posterThumbhash, @@ -428,11 +449,11 @@ private fun SectionItem.remainingMinutes(): Int? { /** Prefer wide artwork for 16:9 row cards, falling back to poster only if needed. */ private fun SectionItem.bestBackdropUrl(): String? { - return backdropUrl ?: posterUrl + return backdropUrl?.takeIf { it.isNotBlank() } ?: posterUrl } private fun SectionItem.bestBackdropThumbhash(): String? { - return backdropThumbhash ?: posterThumbhash + return if (!backdropUrl.isNullOrBlank()) backdropThumbhash else posterThumbhash } private fun SectionItem.shelfTitle(showProgress: Boolean): String { @@ -455,4 +476,3 @@ private fun SectionItem.shelfSubtitle(showProgress: Boolean): String? { else -> null } } - diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt index cba2e355a3..e250fd37cf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvPoster.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import org.siloserver.silo.common.ui.components.DefaultArtworkKind -import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.common.ui.components.SpoilerImage /** * Poster image that shows the default artwork when the URL is missing or fails to load. @@ -29,15 +29,18 @@ fun TvPoster( modifier: Modifier = Modifier, cornerRadius: Dp = 8.dp, defaultArtwork: DefaultArtworkKind = DefaultArtworkKind.Video, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, ) { Box( modifier = modifier .clip(RoundedCornerShape(cornerRadius)) .background(MaterialTheme.colorScheme.surfaceVariant), ) { - ThumbhashImage( + SpoilerImage( url = imageUrl, thumbhash = null, + hidden = hidden, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt index 403560fffc..3e6346c21f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvReferenceShelfCard.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.tv.ui.theme.ProgressFill @@ -42,6 +43,7 @@ import org.siloserver.silo.tv.ui.theme.siloCardDefaults @Composable fun TvReferenceShelfCard( title: String, + hideArtwork: Boolean = false, imageUrl: String?, imageThumbhash: String?, onClick: () -> Unit, @@ -80,7 +82,8 @@ fun TvReferenceShelfCard( .fillMaxSize(), ) { Box(modifier = Modifier.fillMaxSize()) { - ThumbhashImage( + SpoilerImage( + hidden = hideArtwork, url = imageUrl, thumbhash = imageThumbhash, contentDescription = title, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvRootHeroBackdrop.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvRootHeroBackdrop.kt index fc52400db4..3b319929ec 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvRootHeroBackdrop.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvRootHeroBackdrop.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.tv.material3.MaterialTheme +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.section.SectionItem @@ -52,6 +53,7 @@ fun TvRootHeroBackdrop( emptyWashColor: Color? = null, animateTransition: Boolean = true, ) { + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current val tintState = LocalAmbientBackdropTint.current val ambientAccent = tintState.accent @@ -144,10 +146,19 @@ fun TvRootHeroBackdrop( ), label = "tvRootHeroBackdropArt", ) { value -> - if (value?.heroBackdropUrl != null) { + val art = when { + value == null -> null + // Protection turning on for the item still shown hides + // its still at once instead of fading the sharp one out. + content != null && value.id == content.id -> + if (content.hidesStills && !value.hidesStills) content else value + // A departing item follows the current preferences too. + else -> value.underPrefs(spoilerPrefs) + } + if (art?.heroBackdropUrl != null) { CornerAnchoredArt( - url = value.heroBackdropUrl, - thumbhash = value.heroBackdropThumbhash, + url = art.heroBackdropUrl, + thumbhash = art.heroBackdropThumbhash, ) } else { Box(modifier = Modifier.fillMaxSize()) @@ -245,8 +256,10 @@ fun TvRootHeroBackdrop( item: SectionItem?, modifier: Modifier = Modifier, ) { - val content = remember(item?.contentId) { - item?.let { TvMarqueeContent.from(it, rowTitle = "") } + val spoilers = LocalEpisodeSpoilerPrefs.current + // Keyed on the whole item: a watch-state change re-decides spoiler hiding. + val content = remember(item, spoilers) { + item?.let { TvMarqueeContent.from(it, rowTitle = "", spoilers = spoilers) } } TvRootHeroBackdrop(content = content, modifier = modifier) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt index 9481cd0eda..e1db94b5f6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -196,6 +197,7 @@ fun TvSkylineSectionFeed( } } val marquee = rememberTvFocusMarqueeState(fetchDetail = fetchDetail) + SideEffect { marquee.refreshSources(rows) } val initialMarqueeSeed = remember(rows) { rows.firstOrNull()?.let { section -> section.items.firstOrNull()?.let { item -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 2e048a9029..3e58009c27 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -64,6 +64,7 @@ import org.siloserver.silo.tv.ui.screens.watchparty.rememberTvWatchPartyPlayGuar import org.siloserver.silo.repository.WatchTogetherRepository import org.siloserver.silo.watchtogether.WatchPartyDestination import org.siloserver.silo.common.cards.ProvideCardPresentation +import org.siloserver.silo.common.cards.ProvideEpisodeSpoilerPrefs import org.siloserver.silo.common.settings.ProvideTitleArt import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.common.overlays.ProvideCardOverlays @@ -471,6 +472,7 @@ fun TvAppNavigation( val profileRepository: ProfileRepository = koinInject() val overlayPrefsStore: OverlayPrefsStore = koinInject() val cardPresentationStore: CardPresentationStore = koinInject() + val episodeSpoilerStore: org.siloserver.silo.common.settings.EpisodeSpoilerStore = koinInject() val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore = koinInject() val titleArtStore: TitleArtStore = koinInject() val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore = koinInject() @@ -818,6 +820,7 @@ fun TvAppNavigation( overlayPrefsStore.clear() cardPresentationStore.clear() seekIntervalStore.clear() + episodeSpoilerStore.clear() titleArtStore.clear() watchNextSeeder.clear() } @@ -842,6 +845,17 @@ fun TvAppNavigation( ProvideCardOverlays(store = overlayPrefsStore, sessionKey = overlaySessionKey) { ProvideCardPresentation(store = cardPresentationStore, sessionKey = overlaySessionKey) { + ProvideEpisodeSpoilerPrefs(store = episodeSpoilerStore, sessionKey = overlaySessionKey) { + val launcherSpoilerState by episodeSpoilerStore.state.collectAsState() + LaunchedEffect(overlaySessionKey, launcherSpoilerState.support, launcherSpoilerState.prefs.hideImages) { + // The launcher row belongs to this TV's own profile: a phone's + // temporary cast identity must not reseed it with its titles. + if (overlaySessionKey != null && !tokenManager.hasTemporaryScope() && + tokenManager.getProfileId() == overlaySessionKey && + launcherSpoilerState.support != org.siloserver.silo.common.settings.EpisodeSpoilerSupport.Unknown) { + watchNextSeeder.updateImageProtection(launcherSpoilerState.prefs.hideImages) + } + } ProvideTitleArt(store = titleArtStore, sessionKey = overlaySessionKey) { Box(modifier = Modifier.fillMaxSize()) { // The first-run screens draw over one shared brand-light backdrop that @@ -941,8 +955,9 @@ fun TvAppNavigation( libraryPlaybackPrefsStore.clear() overlayPrefsStore.clear() cardPresentationStore.clear() - // Not seekIntervalStore.clear(): its identity flow already reset - // it for the new server and is hydrating; clearing would drop that. + // Not seekIntervalStore.clear() or episodeSpoilerStore.clear(): + // their identity flow already reset them for the new server + // and is hydrating; clearing would drop that. watchNextSeeder.clear() watchNextSeeder.seedNow() watchNextSeeder.enqueuePeriodic() @@ -1017,6 +1032,9 @@ fun TvAppNavigation( // re-selecting the same profile needs this. scope.launch { seekIntervalStore.hydrateIfNeeded() + // Same for spoiler protection, which must be back on + // before the new session's rows show episode stills. + episodeSpoilerStore.hydrateIfNeeded() titleArtStore.hydrateIfNeeded() } }, @@ -1132,6 +1150,7 @@ fun TvAppNavigation( overlayPrefsStore.clear() cardPresentationStore.clear() seekIntervalStore.clear() + episodeSpoilerStore.clear() titleArtStore.clear() // Drop our Watch Next rows + cancel the periodic refresh so // the launcher doesn't keep showing the signed-out user's @@ -1832,4 +1851,5 @@ fun TvAppNavigation( } } } + } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt index bb316043f7..31d5e0fb43 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt @@ -65,9 +65,12 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text import org.siloserver.silo.common.cards.LocalCardPresentation +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.EpisodeListItem +import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.tv.ui.components.TvMediaCardActions import org.siloserver.silo.tv.ui.components.TvMediaCardContextMenu import org.siloserver.silo.tv.ui.components.tvEpisodeCardWidth @@ -358,6 +361,8 @@ private fun TvDetailEpisodeCard( // Continue Watching rail. Legacy season/episode routes keep their existing // geometry so this targeted parity change cannot disturb those layouts. val cardWidth = if (usesSeriesGeometry) tvEpisodeCardWidth() else 230.dp.cardScaled() + val spoilers = LocalEpisodeSpoilerPrefs.current + val unwatched = EpisodeSpoilers.isUnwatched(episode.userData) val stillHeight = if (usesSeriesGeometry) cardWidth * (9f / 16f) else 130.dp.cardScaled() val eyebrowFontSize = if (usesSeriesGeometry) 11.sp else 14.sp val eyebrowLineHeight = if (usesSeriesGeometry) 14.sp else 17.sp @@ -429,9 +434,10 @@ private fun TvDetailEpisodeCard( }, ), ) { - ThumbhashImage( + SpoilerImage( url = episode.stillUrl, thumbhash = episode.stillThumbhash, + hidden = spoilers.hidesImage(unwatched, episode.stillIsEpisodeStill), contentDescription = episode.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -556,9 +562,12 @@ private fun TvDetailEpisodeCard( // exactly 3 text lines (minLines/maxLines rather than a fixed dp // clamp so accessibility text scaling can't clip glyphs), and // render even when there is no overview so every card keeps - // identical vertical metrics. + // identical vertical metrics. Spoiler protection blanks it + // the same way. Text( - text = episode.overview?.takeIf { it.isNotBlank() }.orEmpty(), + text = episode.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } + .orEmpty(), fontSize = 16.sp, fontWeight = FontWeight.Normal, color = SiloSecondaryText, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index 155779dcb7..a1cb32e258 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -106,6 +106,7 @@ import org.koin.core.parameter.parametersOf import org.siloserver.silo.audiobook.AudioPlaybackTrack import org.siloserver.silo.audiobook.AudiobookTimeline import org.siloserver.silo.audiobook.buildAudiobookTimeline +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.common.ui.movieDirectorCredit import org.siloserver.silo.common.ui.openYoutubeTrailer import org.siloserver.silo.metadata.DescriptionTranslationPhase @@ -124,6 +125,8 @@ import org.siloserver.silo.model.ebook.MediaRelatedItem import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.metadata.MetadataAiOnView import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.tv.ui.navigation.TvSubtitleLaunchSelection import org.siloserver.silo.tv.ui.navigation.explicitTvSubtitleLaunchSelection import org.siloserver.silo.tv.ui.components.TvDialogOption @@ -150,6 +153,7 @@ import org.siloserver.silo.tv.ui.screens.watchparty.tvWatchPartyItem import org.siloserver.silo.watchtogether.WatchPartyDestination import org.siloserver.silo.tv.ui.theme.Spacing import org.siloserver.silo.tv.ui.theme.TvSmoothBringIntoViewSpec +import org.siloserver.silo.viewmodel.WatchPartySpoilers internal data class TvSeriesDetailRedirect( val seriesContentId: String, @@ -653,9 +657,26 @@ private fun TvDetailContent( ?: episode.title ?: "Episode ${episode.episodeNumber}" } ?: detail.title - val heroOverview = activeSeriesEpisode?.let { episode -> - activeSeriesPlaybackDetail?.overview ?: episode.overview - } ?: detail.overview + // Spoiler protection: the focused episode's (or an episode page's) + // description stays hidden until the profile starts it. The rail row's + // watch state is the one "Mark as Watched" updates, so it wins. + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val hidesHeroOverview = when { + activeSeriesEpisode != null -> spoilerPrefs.hidesOverview( + EpisodeSpoilers.isUnwatched( + activeSeriesEpisode.userData ?: activeSeriesPlaybackDetail?.userData, + ), + ) + detail.type == "episode" -> spoilerPrefs.hidesOverview(EpisodeSpoilers.isUnwatched(detail.userData)) + else -> false + } + val heroOverview = if (hidesHeroOverview) { + null + } else { + activeSeriesEpisode?.let { episode -> + activeSeriesPlaybackDetail?.overview ?: episode.overview + } ?: detail.overview + } // Always derive the Series credit from the Show itself. Episode focus can // replace the synopsis and playback target, but must never make Starring or // the controls underneath it jump to a different position. @@ -688,7 +709,7 @@ private fun TvDetailContent( selectedFileId = heroSelectedFileId, includePlaybackFormats = false, ) - val heroArtwork = resolveTvDetailHeroArtwork(detail, state.nextUpEpisode) + val heroArtwork = resolveTvDetailHeroArtwork(detail, state.nextUpEpisode, spoilerPrefs) val detailPageTint = rememberAmbientBackdropTintState() LaunchedEffect(heroArtwork.url) { detailPageTint.set(item = null, url = heroArtwork.url) @@ -856,7 +877,9 @@ private fun TvDetailContent( tagline = detail.tagline.takeIf { activeSeriesEpisode == null }, factsLine = heroFactsLine, directorText = heroCreditText, - translation = translationSlot.takeIf { activeSeriesEpisode == null }, + translation = translationSlot.takeIf { + activeSeriesEpisode == null && !hidesHeroOverview + }, compactSeries = isSeriesDetail, playbackSummary = { TvDetailPlaybackSelectionSummary( @@ -1661,6 +1684,7 @@ private fun HeroActionRow( posterUrl = detail.posterUrl, fileId = selectedFileId, libraryId = libraryId, + spoilers = WatchPartySpoilers.of(detail), ) detail.type == "series" && nextUp != null && playReady -> tvWatchPartyItem( contentId = nextUp.contentId, @@ -1670,6 +1694,11 @@ private fun HeroActionRow( posterUrl = nextUp.stillUrl ?: detail.posterUrl, fileId = selectedFileId, libraryId = libraryId, + spoilers = WatchPartySpoilers( + unwatchedEpisode = EpisodeSpoilers.isUnwatched(nextUp.userData), + posterIsEpisodeStill = if (!nextUp.stillUrl.isNullOrBlank()) nextUp.stillIsEpisodeStill else false, + backdropIsEpisodeStill = false, + ), ) else -> null } @@ -2637,21 +2666,34 @@ internal data class TvDetailHeroArtwork( * Item detail responses can legitimately omit series/season artwork even * though their loaded episode rows carry landscape stills. Prefer the real * backdrop, then an episodic landscape still; never stretch a portrait poster - * across a movie/series hero. + * across a movie/series hero. A still that spoiler protection hides is never + * shown full-size here: only its ThumbHash colour field is used. */ internal fun resolveTvDetailHeroArtwork( detail: ItemDetail, nextUpEpisode: EpisodeListItem?, + spoilerPrefs: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE, ): TvDetailHeroArtwork { if (!detail.backdropUrl.isNullOrBlank() || !detail.backdropThumbhash.isNullOrBlank()) { - return TvDetailHeroArtwork(detail.backdropUrl, detail.backdropThumbhash) + val hidesBackdrop = detail.type.equals("episode", ignoreCase = true) && + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(detail.userData), detail.backdropIsEpisodeStill) + return TvDetailHeroArtwork(detail.backdropUrl.takeUnless { hidesBackdrop }, detail.backdropThumbhash) } return when (detail.type.lowercase()) { - "series", "season" -> TvDetailHeroArtwork( - nextUpEpisode?.stillUrl, - nextUpEpisode?.stillThumbhash, + "series", "season" -> { + val hidesStill = nextUpEpisode != null && + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(nextUpEpisode.userData), nextUpEpisode.stillIsEpisodeStill) + TvDetailHeroArtwork( + nextUpEpisode?.stillUrl.takeUnless { hidesStill }, + nextUpEpisode?.stillThumbhash, + ) + } + "episode" -> TvDetailHeroArtwork( + detail.posterUrl.takeUnless { + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(detail.userData), detail.posterIsEpisodeStill) + }, + detail.posterThumbhash, ) - "episode" -> TvDetailHeroArtwork(detail.posterUrl, detail.posterThumbhash) else -> TvDetailHeroArtwork(null, null) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt index fca351e896..594728ec25 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -2340,13 +2340,14 @@ class TvItemDetailViewModel( } +/** Watched and unwatched both reset resume progress, as the server does. */ private fun ItemDetail.withWatchedPlaybackState(watched: Boolean): ItemDetail { val current = userData ?: LeafItemUserData() return copy( userData = current.copy( played = watched, - isInProgress = if (watched) false else current.isInProgress, - positionSeconds = if (watched) null else current.positionSeconds, + isInProgress = false, + positionSeconds = null, ), ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index c04a934eb7..f9d51401c8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -3263,7 +3263,10 @@ private fun TvPlayerNextUpOverlay( style = androidx.tv.material3.MaterialTheme.typography.labelMedium, ) } - nextEpisode.overview?.takeIf { it.isNotBlank() }?.let { overview -> + val hidesOverview = org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs.current + .hidesOverview(nextEpisode.isUnwatched) + val overview = nextEpisode.overview?.takeIf { it.isNotBlank() && !hidesOverview } + if (overview != null) { androidx.tv.material3.Text( text = overview, color = Color.White.copy(alpha = 0.58f), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 533d7d78f5..3ef3a04217 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -624,6 +624,8 @@ data class NextEpisodeState( val overview: String? = null, val seriesTitle: String? = null, val runtimeMinutes: Int = 0, + /** Not started by this profile; spoiler protection may hide [overview]. */ + val isUnwatched: Boolean = true, /** False for a shuffled movie, which has no season or episode line. */ val isEpisode: Boolean = true, ) @@ -4459,6 +4461,7 @@ class TvPlayerViewModel( overview = next.overview, seriesTitle = state.seriesTitle, runtimeMinutes = next.runtime, + isUnwatched = org.siloserver.silo.model.settings.EpisodeSpoilers.isUnwatched(next.userData), ) _uiState.update { if (it.contentId != forContentId) it else it.copy(nextEpisode = nextState) @@ -4856,6 +4859,7 @@ class TvPlayerViewModel( overview = pick.overview, seriesTitle = pick.seriesTitle, runtimeMinutes = pick.runtimeMinutes, + isUnwatched = pick.isUnwatched, isEpisode = pick.isEpisode, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt index 0216f7a3c5..401fb66440 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvWatchPartyPlayerOverlays.kt @@ -60,6 +60,7 @@ import org.siloserver.silo.watchtogether.WatchPartyAvailabilityRepository import org.siloserver.silo.watchtogether.watchPartyEligibility import org.siloserver.silo.watchtogether.watchPartyErrorMessage import org.siloserver.silo.watchtogether.watchPartyInviteUrl +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs /** * The Watch Party layer over the TV player: the non-focusable status overlay @@ -235,6 +236,7 @@ private fun TvWatchPartyPanel( fileId = room?.selectedFileId, libraryId = room?.selectedLibraryId, suggestions = suggestions, + refreshKey = room?.phase, ) val focus = rememberTvPartyFocus() val context = LocalContext.current @@ -339,6 +341,7 @@ private fun TvWatchPartyPanel( inviteUrl = inviteUrl, backdropUrl = staged?.backdropUrl, backdropThumbhash = staged?.backdropThumbhash, + backdropHidden = staged?.spoilers?.hidesBackdrop(LocalEpisodeSpoilerPrefs.current, staged.backdropUrl) == true, onDismiss = { inviteOpen = false }, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 23f17d364e..1addcf03cd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -294,6 +294,8 @@ fun TvSettingsScreen( onAutoPlayNextChanged = viewModel::onAutoPlayNextChanged, onIntroSkipModeChanged = viewModel::onIntroSkipModeChanged, onAutoSkipCreditsChanged = viewModel::onAutoSkipCreditsChanged, + onHideUnwatchedEpisodeImagesChanged = viewModel::setHideUnwatchedEpisodeImages, + onHideUnwatchedEpisodeOverviewsChanged = viewModel::setHideUnwatchedEpisodeOverviews, onMatchContentFrameRateChanged = viewModel::onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = viewModel::onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = viewModel::onDvProfile7HDR10FallbackChanged, @@ -445,6 +447,8 @@ private fun SettingsSplitLayout( onAutoPlayNextChanged: (Boolean) -> Unit, onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeImagesChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeOverviewsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, @@ -527,6 +531,8 @@ private fun SettingsSplitLayout( onAutoPlayNextChanged = onAutoPlayNextChanged, onIntroSkipModeChanged = onIntroSkipModeChanged, onAutoSkipCreditsChanged = onAutoSkipCreditsChanged, + onHideUnwatchedEpisodeImagesChanged = onHideUnwatchedEpisodeImagesChanged, + onHideUnwatchedEpisodeOverviewsChanged = onHideUnwatchedEpisodeOverviewsChanged, onMatchContentFrameRateChanged = onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = onDvProfile7HDR10FallbackChanged, @@ -811,6 +817,8 @@ private fun SettingsDetailPane( onAutoPlayNextChanged: (Boolean) -> Unit, onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeImagesChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeOverviewsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, @@ -868,6 +876,8 @@ private fun SettingsDetailPane( onAutoPlayNextChanged = onAutoPlayNextChanged, onIntroSkipModeChanged = onIntroSkipModeChanged, onAutoSkipCreditsChanged = onAutoSkipCreditsChanged, + onHideUnwatchedEpisodeImagesChanged = onHideUnwatchedEpisodeImagesChanged, + onHideUnwatchedEpisodeOverviewsChanged = onHideUnwatchedEpisodeOverviewsChanged, onMatchContentFrameRateChanged = onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, onDvProfile7HDR10FallbackChanged = onDvProfile7HDR10FallbackChanged, @@ -1186,6 +1196,8 @@ private fun TvPlaybackSettingsPane( onAutoPlayNextChanged: (Boolean) -> Unit, onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeImagesChanged: (Boolean) -> Unit, + onHideUnwatchedEpisodeOverviewsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, onDvProfile7HDR10FallbackChanged: (Boolean) -> Unit, @@ -1362,6 +1374,30 @@ private fun TvPlaybackSettingsPane( ) } } + if (state.episodeSpoilers.isSupported) { + item { + // Spoiler protection (see EpisodeSpoilers.MIN_CONTRACT_REVISION). Profile-wide, so + // the footer says it follows the profile to other devices. + // Hidden on servers that do not know the keys. + SettingsGroup(title = "Spoilers") { + SettingsToggleRow( + label = "Blur unwatched episode images", + checked = state.episodeSpoilers.hideImages, + onCheckedChange = onHideUnwatchedEpisodeImagesChanged, + ) + SettingsToggleRow( + label = "Hide unwatched episode descriptions", + checked = state.episodeSpoilers.hideOverviews, + onCheckedChange = onHideUnwatchedEpisodeOverviewsChanged, + ) + SettingsFooterText( + text = "Applies to episodes you have not started, on every device " + + "that uses this profile.", + ) + state.episodeSpoilerSaveError?.let { SettingsFooterText(text = it) } + } + } + } if (seekState != null) { item { TvSeekIntervalGroup( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 1b97fa1510..08109b4694 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -6,6 +6,9 @@ import androidx.lifecycle.viewModelScope import org.siloserver.silo.common.settings.CardPresentationSource import org.siloserver.silo.common.settings.CardPresentationStore import org.siloserver.silo.common.settings.CardPresentationSupport +import org.siloserver.silo.common.settings.EpisodeSpoilerSetting +import org.siloserver.silo.common.settings.EpisodeSpoilerState +import org.siloserver.silo.common.settings.EpisodeSpoilerStore import org.siloserver.silo.common.settings.LibraryPlaybackPrefsStore import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.common.settings.PlayerSettingsStore @@ -68,6 +71,7 @@ class TvSettingsViewModel( private val profileSettings: ProfileSettingsController, private val tvLibraryScopeStore: org.siloserver.silo.tv.data.preferences.TvLibraryScopeStore? = null, private val seekIntervalStore: SeekIntervalStore? = null, + private val episodeSpoilerStore: EpisodeSpoilerStore? = null, audiobookSettingsStore: AudiobookSettingsStore? = null, private val profileLaunchPreferences: TvProfileLaunchPreferences? = null, private val watchNextSeeder: WatchNextSeeder? = null, @@ -159,6 +163,11 @@ class TvSettingsViewModel( val cardPresentation: CardPresentation = CardPresentation.DEFAULT, val cardPresentationSource: CardPresentationSource = CardPresentationSource.Unknown, val cardPresentationSupport: CardPresentationSupport = CardPresentationSupport.Unknown, + // Spoiler protection for unwatched episodes, mirrored + // from EpisodeSpoilerStore. The group is hidden unless supported. + val episodeSpoilers: EpisodeSpoilerState = EpisodeSpoilerState(), + /** Why the last spoiler switch change wasn't saved. */ + val episodeSpoilerSaveError: String? = null, val navAction: NavAction? = null, ) @@ -170,6 +179,7 @@ class TvSettingsViewModel( loadSettings() observePlayerSettings() observeCardPresentation() + observeEpisodeSpoilers() observeProfileLaunch() } @@ -458,6 +468,28 @@ class TvSettingsViewModel( } } + /** Mirror the spoiler store into UI state and re-read it from the server. */ + private fun observeEpisodeSpoilers() { + val store = episodeSpoilerStore ?: return + viewModelScope.launch { + store.state.collect { spoilers -> _uiState.update { it.copy(episodeSpoilers = spoilers) } } + } + viewModelScope.launch { + store.saveError.collect { error -> _uiState.update { it.copy(episodeSpoilerSaveError = error) } } + } + viewModelScope.launch { store.refresh() } + } + + /** Profile-wide; the store writes it optimistically and rolls back on failure. */ + fun setHideUnwatchedEpisodeImages(enabled: Boolean) { + episodeSpoilerStore?.set(EpisodeSpoilerSetting.Images, enabled) + } + + /** Profile-wide; the store writes it optimistically and rolls back on failure. */ + fun setHideUnwatchedEpisodeOverviews(enabled: Boolean) { + episodeSpoilerStore?.set(EpisodeSpoilerSetting.Overviews, enabled) + } + /** Mirror the card-presentation store into UI state (single source of truth). */ private fun observeCardPresentation() { viewModelScope.launch { @@ -787,6 +819,7 @@ class TvSettingsViewModel( overlayPrefsStore.clear() cardPresentationStore.clear() seekIntervalStore?.clear() + episodeSpoilerStore?.clear() _uiState.update { it.copy(navAction = NavAction.SIGNED_OUT) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt index acf384c8ba..2876d8de59 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyLobbyScreen.kt @@ -51,6 +51,7 @@ import org.siloserver.silo.watchtogether.WatchPartyFeatures import org.siloserver.silo.watchtogether.canRemoveSuggestion import org.siloserver.silo.watchtogether.roomVoteWinner import org.siloserver.silo.watchtogether.watchPartyInviteUrl +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs /** * The Watch Party lobby, laid out as the tvOS lobby ([TvWatchPartyRoomView]): @@ -226,6 +227,7 @@ fun TvWatchPartyLobbyScreen( fileId = room?.selectedFileId, libraryId = room?.selectedLibraryId, suggestions = suggestions, + refreshKey = room?.phase, ) val stagedId = room?.selectedContentId?.takeIf { it.isNotBlank() } val memberState by produceState<ItemMemberState?>(initialValue = null, stagedId) { @@ -339,6 +341,7 @@ fun TvWatchPartyLobbyScreen( inviteUrl = inviteUrl, backdropUrl = staged?.backdropUrl, backdropThumbhash = staged?.backdropThumbhash, + backdropHidden = staged?.spoilers?.hidesBackdrop(LocalEpisodeSpoilerPrefs.current, staged.backdropUrl) == true, onDismiss = { inviteOpen = false }, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt index eb21d24902..bf90dfa8f1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyPicker.kt @@ -76,6 +76,8 @@ import org.siloserver.silo.tv.ui.theme.SiloSecondaryText import org.siloserver.silo.tv.ui.theme.TvSkyline import org.siloserver.silo.viewmodel.WatchPartyItem import org.siloserver.silo.viewmodel.WatchPartyPickerViewModel +import org.siloserver.silo.viewmodel.WatchPartySpoilers +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs /** One Home-style shelf of the picker. */ private data class TvPickerShelf( @@ -85,6 +87,10 @@ private data class TvPickerShelf( val showProgress: Boolean = false, ) { val items: List<SectionItem> = entries.map { (item, entry) -> item.toSectionItem(entry) } + + /** The items carry the room's progress; spoiler protection uses this profile's own. */ + val unwatchedById: Map<String, Boolean> = + entries.associate { (item, _) -> item.contentId to WatchPartySpoilers.of(item).unwatchedEpisode } } /** What the confirmation page shows, and the item it stages or suggests. */ @@ -98,6 +104,7 @@ private data class TvPickerChoice( val backdropThumbhash: String?, val overview: String?, val facts: List<String>, + val spoilers: WatchPartySpoilers = item.spoilers, ) /** @@ -166,6 +173,8 @@ internal fun TvWatchPartyPicker( title = nextUp.title.ifBlank { "Episode ${nextUp.episodeNumber}" }, subtitle = "${item.title} · S${nextUp.seasonNumber}E${nextUp.episodeNumber}", posterUrl = item.posterUrl, + // Series artwork; the episode's own watch state isn't known here. + spoilers = WatchPartySpoilers(unwatchedEpisode = true, posterIsEpisodeStill = false, backdropIsEpisodeStill = false), ), title = item.title, subtitle = "S${nextUp.seasonNumber} · E${nextUp.episodeNumber}" + @@ -188,6 +197,7 @@ internal fun TvWatchPartyPicker( title = item.title, subtitle = item.year.takeIf { it > 0 }?.toString(), posterUrl = item.posterUrl, + spoilers = WatchPartySpoilers.of(item), ), title = item.title, subtitle = null, @@ -329,6 +339,7 @@ internal fun TvWatchPartyPicker( items = shelf.items, onItemClick = { contentId -> open(shelf, contentId) }, showProgress = shelf.showProgress, + unwatchedById = shelf.unwatchedById, startPadding = TvSkyline.safeAreaX, endPadding = TvSkyline.safeAreaX, rowTopPadding = 10.dp, @@ -398,6 +409,7 @@ private fun TvPickerConfirmation( onDismiss: () -> Unit, ) { val confirmFocus = remember { FocusRequester() } + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current val contentId = choice.item.contentId val seen by produceState<MemberStateLoad>(initialValue = MemberStateLoad.Loading, contentId) { value = MemberStateLoad.Loading @@ -418,6 +430,7 @@ private fun TvPickerConfirmation( url = choice.backdropUrl ?: choice.posterUrl, thumbhash = if (choice.backdropUrl != null) choice.backdropThumbhash else choice.posterThumbhash, isPoster = choice.backdropUrl == null, + hidden = choice.spoilers.hidesBackdrop(spoilerPrefs, choice.backdropUrl), ) Column( modifier = Modifier @@ -434,6 +447,7 @@ private fun TvPickerConfirmation( contentDescription = null, modifier = Modifier.size(width = 110.dp, height = 165.dp), defaultArtwork = DefaultArtworkKind.forItemType(choice.item.contentType), + hidden = choice.spoilers.hidesPoster(spoilerPrefs), ) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { TvPartyEyebrow(if (stages) "Watch together" else "Suggest to the party") @@ -453,7 +467,7 @@ private fun TvPickerConfirmation( } } } - choice.overview?.let { overview -> + choice.overview?.takeUnless { choice.spoilers.hidesOverview(spoilerPrefs) }?.let { overview -> Text( text = overview, fontSize = TvPartyMetrics.body, @@ -580,6 +594,8 @@ private fun BrowseItem.toSectionItem(entry: PickerEntry?): SectionItem = Section posterThumbhash = posterThumbhash, backdropUrl = backdropUrl, backdropThumbhash = backdropThumbhash, + posterIsEpisodeStill = posterIsEpisodeStill, + backdropIsEpisodeStill = backdropIsEpisodeStill, userState = userState, overlaySummary = overlaySummary, positionSeconds = entry?.members?.mapNotNull { it.positionSeconds }?.maxOrNull(), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt index 8780a01b1d..e3dd693bb5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyRoom.kt @@ -92,6 +92,8 @@ import org.siloserver.silo.watchtogether.watchPartyLobbyHero import org.siloserver.silo.watchtogether.watchPartyLobbyHint import org.siloserver.silo.watchtogether.watchPartyPrimaryAction import org.siloserver.silo.watchtogether.watchPartySecondaryAction +import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs +import org.siloserver.silo.viewmodel.WatchPartySpoilers /** What a room screen can do. The lobby and the in-player panel each supply their own. */ internal class TvPartyRoomActions( @@ -184,9 +186,15 @@ internal fun TvWatchPartyRoomView( ) // The staged film's backdrop, else its poster blurred, else (voting) the leader's poster. - val leaderPoster = if (voting) (winner ?: suggestions.firstOrNull())?.posterUrl?.ifBlank { null } else null - val backdropUrl = staged?.backdropUrl ?: staged?.posterUrl ?: leaderPoster + val leader = if (voting) (winner ?: suggestions.firstOrNull())?.takeIf { it.posterUrl.isNotBlank() } else null + val backdropUrl = staged?.backdropUrl ?: staged?.posterUrl ?: leader?.posterUrl val backdropIsPoster = staged?.backdropUrl == null + val spoilerPrefs = LocalEpisodeSpoilerPrefs.current + val backdropHidden = if (staged != null && (staged.backdropUrl != null || staged.posterUrl != null)) { + staged.spoilers.hidesBackdrop(spoilerPrefs, staged.backdropUrl) + } else { + leader?.let { WatchPartySpoilers.of(it).hidesPoster(spoilerPrefs) } ?: false + } Box( modifier = modifier @@ -206,6 +214,7 @@ internal fun TvWatchPartyRoomView( url = backdropUrl, thumbhash = if (backdropIsPoster) staged?.posterThumbhash else staged?.backdropThumbhash, isPoster = backdropIsPoster, + hidden = backdropHidden, ) Column( modifier = Modifier @@ -278,7 +287,9 @@ internal fun TvWatchPartyRoomView( titleSize = TvPartyMetrics.heroTitle, memberStateLine = memberStateLine, ) - staged?.overview?.takeIf { !room.selectedContentId.isNullOrBlank() }?.let { overview -> + staged?.overview?.takeIf { + !room.selectedContentId.isNullOrBlank() && !staged.spoilers.hidesOverview(spoilerPrefs) + }?.let { overview -> Text( text = overview, fontSize = TvPartyMetrics.overview, @@ -716,6 +727,7 @@ private fun TvPartySuggestionCard( cornerRadius = 6.dp, modifier = Modifier.fillMaxSize(), defaultArtwork = DefaultArtworkKind.forItemType(suggestion.contentType), + hidden = WatchPartySpoilers.of(suggestion).hidesPoster(LocalEpisodeSpoilerPrefs.current), ) if (badge != null) { Box(modifier = Modifier.align(Alignment.TopStart).padding(6.dp)) { badge() } @@ -881,6 +893,8 @@ internal fun TvWatchPartyInvitePage( inviteUrl: String?, backdropUrl: String?, backdropThumbhash: String?, + /** Spoiler protection hides the staged title's backdrop. */ + backdropHidden: Boolean, onDismiss: () -> Unit, ) { val doneFocus = remember { FocusRequester() } @@ -895,7 +909,7 @@ internal fun TvWatchPartyInvitePage( ), ) { Box(modifier = Modifier.fillMaxSize()) { - TvPartyBackdrop(url = backdropUrl, thumbhash = backdropThumbhash) + TvPartyBackdrop(url = backdropUrl, thumbhash = backdropThumbhash, hidden = backdropHidden) Row( modifier = Modifier .fillMaxSize() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt index 5597752ae9..c014d8a47c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStagedItem.kt @@ -9,6 +9,7 @@ import org.siloserver.silo.model.watchtogether.Suggestion import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.viewmodel.WatchPartyItem +import org.siloserver.silo.viewmodel.WatchPartySpoilers import java.util.concurrent.ConcurrentHashMap /** @@ -42,6 +43,7 @@ internal data class TvStagedPreview( val facts: List<String> = emptyList(), /** The chosen version's quality: "4K", "HDR". */ val chips: List<String> = emptyList(), + val spoilers: WatchPartySpoilers = WatchPartySpoilers.NONE, ) /** @@ -55,13 +57,23 @@ internal fun rememberTvStagedPreview( fileId: Int?, libraryId: Int?, suggestions: List<Suggestion>, + /** Re-reads the detail when it changes, such as the room's phase after playback. */ + refreshKey: Any? = null, catalog: CatalogRepository = koinInject(), ): TvStagedPreview? { - val detail by produceState<ItemDetail?>(initialValue = null, contentId, libraryId) { - value = null - val id = contentId?.takeIf { it.isNotBlank() } ?: return@produceState - value = catalog.getCachedItemDetail(id, libraryId) - ?: (catalog.getItemDetail(id, libraryId) as? ApiResult.Success)?.data + val detail by produceState<ItemDetail?>(initialValue = null, contentId, libraryId, refreshKey) { + val id = contentId?.takeIf { it.isNotBlank() } + val shown = value?.takeIf { it.contentId == id } + value = shown + id ?: return@produceState + if (shown == null) { + value = catalog.getCachedItemDetail(id, libraryId) + ?: (catalog.getItemDetail(id, libraryId) as? ApiResult.Success)?.data + } else { + // Same title, new phase: after the party plays it, a fresh read + // carries the watch state spoiler protection needs. + (catalog.getItemDetail(id, libraryId) as? ApiResult.Success)?.data?.let { value = it } + } } val id = contentId?.takeIf { it.isNotBlank() } ?: return null val loaded = detail?.takeIf { it.contentId == id } @@ -83,16 +95,18 @@ internal fun rememberTvStagedPreview( version?.resolution?.let(::tvWatchPartyResolutionChip), "HDR".takeIf { version?.hdr == true }, ), + spoilers = WatchPartySpoilers.of(loaded), ) } TvWatchPartyPreviews.get(id)?.let { item -> - return TvStagedPreview(item.title, item.subtitle, item.posterUrl) + return TvStagedPreview(item.title, item.subtitle, item.posterUrl, spoilers = item.spoilers) } suggestions.firstOrNull { it.contentId == id }?.let { suggestion -> return TvStagedPreview( title = suggestion.title, subtitle = suggestion.subtitle.ifBlank { null }, posterUrl = suggestion.posterUrl.ifBlank { null }, + spoilers = WatchPartySpoilers.of(suggestion), ) } return TvStagedPreview(title = "Loading title…", subtitle = null, posterUrl = null) @@ -125,6 +139,7 @@ internal fun tvWatchPartyItem( posterUrl: String?, fileId: Int?, libraryId: Int?, + spoilers: WatchPartySpoilers = WatchPartySpoilers.forType(contentType), ): WatchPartyItem = WatchPartyItem( contentId = contentId, contentType = contentType, @@ -133,4 +148,5 @@ internal fun tvWatchPartyItem( posterUrl = posterUrl?.takeIf { it.isNotBlank() }, fileId = fileId, libraryId = libraryId, + spoilers = spoilers, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt index 52e3563f05..05313bbea1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/watchparty/TvWatchPartyStyle.kt @@ -67,7 +67,6 @@ import androidx.tv.material3.Glow import androidx.tv.material3.Icon import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.watchtogether.RoomMember import org.siloserver.silo.model.watchtogether.RoomPhase import org.siloserver.silo.tv.ui.components.rememberTvDialogInitialFocus @@ -88,6 +87,7 @@ import org.siloserver.silo.watchtogether.watchPartyPresenceSummary import org.siloserver.silo.watchtogether.watchPartySeatOrder import org.siloserver.silo.watchtogether.watchPartySeatState import org.siloserver.silo.watchtogether.watchPartySeatStatus +import org.siloserver.silo.common.ui.components.SpoilerImage /* * The Watch Party look, ported from the tvOS clients (`WatchPartyComponents` @@ -179,12 +179,15 @@ internal fun TvPartyBackdrop( thumbhash: String? = null, isPoster: Boolean = false, modifier: Modifier = Modifier, + /** Spoiler protection hides this artwork: an unwatched episode's still. */ + hidden: Boolean = false, ) { Box(modifier = modifier.fillMaxSize().background(DarkBackground)) { if (!url.isNullOrBlank() || !thumbhash.isNullOrBlank()) { - ThumbhashImage( + SpoilerImage( url = url, thumbhash = thumbhash, + hidden = hidden, contentDescription = null, modifier = Modifier .fillMaxSize() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt index b75bea70fb..09c774327f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt @@ -15,6 +15,7 @@ import org.siloserver.silo.common.diagnostics.PendingReportStore import org.siloserver.silo.repository.SectionRepository import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences import org.koin.core.context.GlobalContext +import org.siloserver.silo.network.TokenManager /** * Hand-rolled WorkerFactory that constructs DI-dependent workers via Koin. @@ -48,7 +49,10 @@ class TvWorkerFactory : WorkerFactory() { params = workerParameters, sectionRepository = koin.get<SectionRepository>(), repository = koin.get<WatchNextRepository>(), + spoilerStore = koin.get(), + catalogRepository = koin.get(), allowsWatchNext = koin.get<TvProfileLaunchPreferences>()::allowsWatchNext, + hasTemporaryScope = koin.get<TokenManager>()::hasTemporaryScope, ) } SyncWorker::class.java.name -> { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSync.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSync.kt index 13bf263825..97bb7be8b3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSync.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSync.kt @@ -4,28 +4,47 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers -/** Actual worker read branch, with provider dispatch injected for synthetic tests. */ +/** + * Actual worker read branch, with provider dispatch injected for synthetic tests. + * [seriesArtwork] answers null only when the lookup failed; the run then retries + * instead of dropping the tile. + */ internal suspend fun syncWatchNextHome( sections: SectionRepository, gate: WatchNextWriteGate, stopped: () -> Boolean, + spoilerPreferences: suspend () -> EpisodeSpoilerPrefs? = { EpisodeSpoilerPrefs.NONE }, + preferencesCurrent: (EpisodeSpoilerPrefs) -> Boolean = { true }, + seriesArtwork: suspend (String) -> ItemDetail? = { null }, + /** A phone's cast identity is the current scope; the launcher row is never its to write. */ + borrowedIdentity: suspend () -> Boolean = { false }, apply: suspend (List<WatchNextProgramFields>, Long, suspend () -> Boolean) -> Unit, ): Boolean { val run = gate.capture() + if (borrowedIdentity()) return true val owner = sections.captureHomeAuthority() ?: return true + var prefs: EpisodeSpoilerPrefs? = null suspend fun authority(): Boolean { - val valid = sections.isHomeAuthorityCurrent(owner) + // The suspending identity lookup first, so the owner is validated last. + val valid = !borrowedIdentity() && sections.isHomeAuthorityCurrent(owner) currentCoroutineContext().ensureActive() - return valid && !stopped() + return valid && !stopped() && (prefs?.let(preferencesCurrent) ?: true) } suspend fun current() = gate.allowed(run, ::authority) if (!current()) return true + val resolvedPrefs = spoilerPreferences() ?: return false + prefs = resolvedPrefs + if (!current()) return true val response = sections.getHomeSections(owner) if (!current()) return true if (response !is ApiResult.Success) return false val fields = mutableListOf<WatchNextProgramFields>() + val artworkBySeries = mutableMapOf<String, ItemDetail>() for (section in response.data.sections.filter { it.sectionType in setOf("continue_watching", "next_up") }) { var items = section.items if (items.isEmpty() && section.totalCount > 0) { @@ -35,7 +54,24 @@ internal suspend fun syncWatchNextHome( items = fallback.data.items if (items.isEmpty() && (fallback.data.section?.totalCount ?: 0) > 0) return false } - fields += items.mapNotNull { WatchNextProgramMapper.map(it, section.sectionType) } + for (item in items) { + var mapped = WatchNextProgramMapper.map(item, section.sectionType, resolvedPrefs) + if (mapped == null && item.type.equals("episode", ignoreCase = true) && + resolvedPrefs.hidesImage(EpisodeSpoilers.isUnwatched(item))) { + val seriesId = item.seriesId?.takeIf { it.isNotBlank() } + if (seriesId != null) { + if (seriesId !in artworkBySeries) { + val series = seriesArtwork(seriesId) + if (!current()) return true + // A failed lookup is not "no safe artwork": publishing + // without the tile would remove it from the launcher. + artworkBySeries[seriesId] = series ?: return false + } + mapped = WatchNextProgramMapper.map(item, section.sectionType, resolvedPrefs, artworkBySeries[seriesId]) + } + } + mapped?.let(fields::add) + } } if (!current()) return true apply(fields, run, ::authority) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapper.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapper.kt index fcc8ede579..c827022e11 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapper.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapper.kt @@ -2,6 +2,9 @@ package org.siloserver.silo.tv.watchnext import org.siloserver.silo.model.catalog.isAudiobookItemType import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers import java.net.URLEncoder import java.time.Instant import java.time.format.DateTimeParseException @@ -21,8 +24,19 @@ data class WatchNextProgramFields( object WatchNextProgramMapper { - fun map(item: SectionItem, sectionType: String): WatchNextProgramFields? { - val poster = item.backdropUrl ?: item.posterUrl ?: return null + fun map( + item: SectionItem, + sectionType: String, + spoilerPrefs: EpisodeSpoilerPrefs = EpisodeSpoilerPrefs.NONE, + seriesArtwork: ItemDetail? = null, + ): WatchNextProgramFields? { + val protect = item.type.equals("episode", ignoreCase = true) && + spoilerPrefs.hidesImage(EpisodeSpoilers.isUnwatched(item)) + val backdrop = item.backdropUrl?.takeIf { it.isNotBlank() && (!protect || item.backdropIsEpisodeStill == false) } + val portrait = item.posterUrl?.takeIf { it.isNotBlank() && (!protect || item.posterIsEpisodeStill == false) } + val seriesBackdrop = seriesArtwork?.takeIf { it.type == "series" }?.backdropUrl?.takeIf { it.isNotBlank() } + val seriesPoster = seriesArtwork?.takeIf { it.type == "series" }?.posterUrl?.takeIf { it.isNotBlank() } + val poster = backdrop ?: seriesBackdrop ?: portrait ?: seriesPoster ?: return null val watchNextType = when (sectionType) { "continue_watching" -> WATCH_NEXT_TYPE_CONTINUE "next_up" -> WATCH_NEXT_TYPE_NEXT @@ -36,8 +50,12 @@ object WatchNextProgramMapper { isAudiobook -> PROGRAM_TYPE_ALBUM else -> PROGRAM_TYPE_TV_EPISODE } - // Audiobook cover art is square; video art stays 16:9. - val aspectRatio = if (isAudiobook) ASPECT_RATIO_1_1 else ASPECT_RATIO_16_9 + // Safe parent posters keep their portrait aspect ratio. + val aspectRatio = when { + isAudiobook -> ASPECT_RATIO_1_1 + protect && backdrop == null && seriesBackdrop == null -> ASPECT_RATIO_2_3 + else -> ASPECT_RATIO_16_9 + } val intentUri = when (watchNextType) { // Contract with the deep-link handler: tag the play intent with the // item type (URL-encoded) so it can route audiobooks to the audio @@ -78,4 +96,6 @@ object WatchNextProgramMapper { const val PROGRAM_TYPE_ALBUM = 8 const val ASPECT_RATIO_16_9 = 0 const val ASPECT_RATIO_1_1 = 3 + // Mirrors TvContractCompat.WatchNextPrograms.ASPECT_RATIO_2_3. + const val ASPECT_RATIO_2_3 = 4 } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt index 631a5698e1..d243df3f18 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeeder.kt @@ -45,6 +45,36 @@ class WatchNextSeeder( // AFTER the seed and erase the fresh rows it just inserted. private var clearJob: Job? = null + private val protectionPrefs = context.getSharedPreferences("silo_watch_next_protection", Context.MODE_PRIVATE) + + // Bumped by every [updateImageProtection] under the prefs lock; a wipe + // that finishes after a newer call must not record its older setting. + private var protectionGeneration = 0 + + /** Clear exposed tiles only when known image protection changes from off to on. */ + fun updateImageProtection(enabled: Boolean) { + val generation = synchronized(protectionPrefs) { ++protectionGeneration } + val wasEnabled = protectionPrefs.getBoolean("hide_images", false) + if (enabled && !wasEnabled) { + clear() + // Record the change only once the wipe has finished: a process + // death before then must wipe again on the next start. + val wipe = clearJob + scope.launch { + wipe?.join() + synchronized(protectionPrefs) { + if (wipe?.isCancelled != true && generation == protectionGeneration) { + protectionPrefs.edit().putBoolean("hide_images", true).apply() + } + } + } + } else { + protectionPrefs.edit().putBoolean("hide_images", enabled).apply() + } + seedNow() + enqueuePeriodic() + } + fun seedNow() { val pending = clearJob // A clear() after this call supersedes it: clear() invalidates the diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt index a1d56870f4..cce4dcd62a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt @@ -4,6 +4,10 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.common.settings.EpisodeSpoilerStore +import org.siloserver.silo.common.settings.EpisodeSpoilerSupport +import org.siloserver.silo.network.ApiResult import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -22,15 +26,33 @@ class WatchNextSyncWorker( params: WorkerParameters, private val sectionRepository: SectionRepository, private val repository: WatchNextRepository, + private val spoilerStore: EpisodeSpoilerStore, + private val catalogRepository: CatalogRepository, /** Profile Selection's verdict; see [TvProfileLaunchPreferences.allowsWatchNext]. */ private val allowsWatchNext: () -> Boolean = { true }, + /** + * A phone's cast identity is active; its titles never reach this TV's + * launcher. Returning to this TV's profile after the cast seeds again. + */ + private val hasTemporaryScope: suspend () -> Boolean = { false }, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { if (wipeIfHidden()) return@withContext Result.success() if (inputData.getBoolean(KEY_POLICY_CHECK_ONLY, false)) return@withContext Result.success() val stopped = { isStopped || !allowsWatchNext() } - val completed = syncWatchNextHome(sectionRepository, repository.writeGate, stopped) { fields, run, authority -> + val completed = syncWatchNextHome( + sectionRepository, repository.writeGate, stopped, + spoilerPreferences = { + spoilerStore.refresh() + spoilerStore.state.value.takeUnless { it.support == EpisodeSpoilerSupport.Unknown }?.prefs + }, + preferencesCurrent = { prefs -> + spoilerStore.state.value.let { it.support != EpisodeSpoilerSupport.Unknown && it.prefs == prefs } + }, + seriesArtwork = { id -> (catalogRepository.getItemDetail(id) as? ApiResult.Success)?.data }, + borrowedIdentity = hasTemporaryScope, + ) { fields, run, authority -> repository.diffAndApply(fields, run, authority) } // The verdict can flip mid-run (a timed choice expiring); wipe what diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt index 68be999c00..5a9a28133e 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarqueeModelTest.kt @@ -1,15 +1,144 @@ package org.siloserver.silo.tv.ui.components import java.io.File +import org.siloserver.silo.model.catalog.MediaItemUserState import org.siloserver.silo.model.catalog.DisplayRating import org.siloserver.silo.model.catalog.OverlaySummary +import org.siloserver.silo.model.section.ResolvedSection import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class TvFocusMarqueeModelTest { + @Test + fun rowUpdatesRefreshWatchStateWithoutChangingFocusOrEnrichment() { + val state = TvFocusMarqueeState() + state.spoilerPrefs = EpisodeSpoilerPrefs(true, true) + val episode = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + userState = MediaItemUserState(played = true), + ) + val row = ResolvedSection("row", "next_up", "Next Up", items = listOf(episode)) + state.preview(episode, row.title, row.id) + state.commit(state.candidate) + val enrichment = TvMarqueeEnrichment(null, "https://example.test/series.jpg", null, backdropIsEpisodeStill = false) + state.applyEnrichment(episode.contentId, enrichment) + + val unwatched = episode.copy(userState = MediaItemUserState(played = false)) + state.refreshSources(listOf(row.copy(items = listOf(unwatched)))) + + assertEquals(null, state.content?.synopsis) + assertEquals(null, state.content?.heroBackdropUrl) + assertEquals("row#episode", state.focusedMarqueeId) + assertTrue(state.hasSettledRealFocus) + assertEquals(enrichment, state.enrichment) + assertEquals(enrichment.backdropUrl, state.backdropContent?.heroBackdropUrl) + // A detail backdrop that is, or may be, the episode's still stays hidden. + state.applyEnrichment(episode.contentId, enrichment.copy(backdropIsEpisodeStill = null)) + assertEquals(null, state.backdropContent?.heroBackdropUrl) + + state.refreshSources(listOf(row)) + assertEquals("Spoiler", state.content?.synopsis) + assertEquals(episode.backdropUrl, state.content?.heroBackdropUrl) + } + + @Test + fun aDepartingLayerFollowsAProtectionChange() { + val episode = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + ) + val shown = TvMarqueeContent.from(episode, "Next Up", "row") + + val protected = shown.underPrefs(EpisodeSpoilerPrefs(true, true)) + assertEquals(null, protected.heroBackdropUrl) + assertEquals(null, protected.synopsis) + assertEquals(shown.id, protected.id) + assertTrue(shown.underPrefs(EpisodeSpoilerPrefs.NONE) === shown) + val watched = TvMarqueeContent.from(episode.copy(userState = MediaItemUserState(played = true)), "Next Up", "row") + assertTrue(watched.underPrefs(EpisodeSpoilerPrefs(true, true)) === watched) + // An unchanged layer without a description stays the same instance. + val noOverview = TvMarqueeContent.from(episode.copy(overview = null), "Next Up", "row", EpisodeSpoilerPrefs(true, true)) + assertTrue(noOverview.underPrefs(EpisodeSpoilerPrefs(true, true)) === noOverview) + } + + @Test + fun hidingKeepsAnExplicitSeriesBackdropFromEnrichment() { + val episode = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + ) + val shown = TvMarqueeContent.from(episode, "Next Up", "row") + val series = TvMarqueeEnrichment(null, "https://example.test/series.jpg", null, backdropIsEpisodeStill = false) + + val keptSeries = shown.withEnrichment(series).underPrefs(EpisodeSpoilerPrefs(true, true)) + assertEquals("https://example.test/series.jpg", keptSeries.heroBackdropUrl) + assertEquals(null, keptSeries.synopsis) + val unknown = shown.withEnrichment(series.copy(backdropIsEpisodeStill = null)).underPrefs(EpisodeSpoilerPrefs(true, true)) + assertEquals(null, unknown.heroBackdropUrl) + } + + @Test + fun rowUpdatesKeepDisplayedAndPendingCopiesBoundToTheirOwnRows() { + val state = TvFocusMarqueeState() + state.spoilerPrefs = EpisodeSpoilerPrefs(true, true) + val watched = SectionItem( + contentId = "episode", type = "episode", title = "Episode", + overview = "Displayed spoiler", userState = MediaItemUserState(played = true), + ) + val displayedRow = ResolvedSection("displayed", "next_up", "Next Up", items = listOf(watched)) + val pendingItem = watched.copy(overview = "Pending spoiler") + val pendingRow = displayedRow.copy(id = "pending", items = listOf(pendingItem)) + state.preview(watched, displayedRow.title, displayedRow.id) + state.commit(state.candidate) + state.preview(pendingItem, pendingRow.title, pendingRow.id) + + val unwatched = watched.copy(userState = MediaItemUserState(played = false)) + state.refreshSources(listOf(pendingRow, displayedRow.copy(items = listOf(unwatched)))) + + assertEquals(unwatched, state.content?.source) + assertEquals(null, state.content?.synopsis) + assertEquals(pendingItem, state.candidate?.source) + assertEquals("Pending spoiler", state.candidate?.synopsis) + assertEquals("pending#episode", state.focusedMarqueeId) + + val displayed = state.content + val pending = state.candidate + state.refreshSources(emptyList()) + assertEquals(displayed, state.content) + assertEquals(pending, state.candidate) + } + + @Test + fun preferencesReprojectTheDisplayedAndPendingEpisodes() { + val state = TvFocusMarqueeState() + val episode = SectionItem( + contentId = "unstarted", type = "episode", title = "Episode", + overview = "Spoiler", backdropUrl = "https://example.test/still.jpg", + ) + state.preview(episode, "Next Up") + state.commit(state.candidate) + state.preview(episode.copy(contentId = "pending"), "Next Up") + val pendingBeforeHydration = state.candidate + + state.spoilerPrefs = EpisodeSpoilerPrefs(true, true) + + assertEquals(null, state.content?.synopsis) + assertEquals(null, state.backdropContent?.heroBackdropUrl) + assertEquals(null, state.candidate?.synopsis) + state.commit(pendingBeforeHydration) + assertEquals(null, state.content?.synopsis) + assertEquals(null, state.backdropContent?.heroBackdropUrl) + + state.spoilerPrefs = EpisodeSpoilerPrefs.NONE + assertEquals("Spoiler", state.content?.synopsis) + assertEquals(episode.backdropUrl, state.backdropContent?.heroBackdropUrl) + } + @Test fun movieHeroSeparatesEditorialMetadataFromFormatBadges() { val content = TvMarqueeContent.from( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt index 20a2f5c122..441c1eb52e 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHeroArtworkTest.kt @@ -4,12 +4,37 @@ import androidx.compose.ui.graphics.Color import java.io.File import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.catalog.LeafItemUserData +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull class TvDetailHeroArtworkTest { + @Test + fun standaloneEpisodeFallbackProtectsOnlyUnstartedStills() { + val detail = ItemDetail( + contentId = "episode-1", type = "episode", title = "Episode", + posterUrl = "episode-still", posterThumbhash = "still-hash", + ) + val prefs = EpisodeSpoilerPrefs(hideImages = true) + + assertEquals(TvDetailHeroArtwork(null, "still-hash"), resolveTvDetailHeroArtwork(detail, null, prefs)) + assertEquals( + TvDetailHeroArtwork("episode-still", "still-hash"), + resolveTvDetailHeroArtwork(detail.copy(userData = LeafItemUserData(played = true)), null, prefs), + ) + val withBackdrop = detail.copy( + backdropUrl = "series-backdrop", backdropThumbhash = "series-hash", backdropIsEpisodeStill = false, + ) + assertEquals(TvDetailHeroArtwork("series-backdrop", "series-hash"), resolveTvDetailHeroArtwork(withBackdrop, null, prefs)) + assertEquals( + TvDetailHeroArtwork(null, "series-hash"), + resolveTvDetailHeroArtwork(withBackdrop.copy(backdropIsEpisodeStill = true), null, prefs), + ) + } + @Test fun episodicDetailFallsBackToLandscapeEpisodeStill() { val detail = ItemDetail( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSyncTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSyncTest.kt index bf319035ee..8358b3ed67 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSyncTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextHomeSyncTest.kt @@ -13,6 +13,8 @@ import org.siloserver.silo.network.* import org.siloserver.silo.network.api.SectionApi import org.siloserver.silo.network.apiv2.HomeSectionsV2Api import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs import kotlin.test.* @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @@ -27,11 +29,14 @@ class WatchNextHomeSyncTest { private var reads = 0 private var applies = 0 private var hook: () -> Unit = {} + private var episodeRows = false private suspend fun scenario(block: suspend (SectionRepository) -> Unit) { val client = HttpClient(MockEngine { assertEquals(owner, it.attributes[AuthScopeAttributeKey]) val fallback = it.url.encodedPath.endsWith("/items") - val row = """{"id":"row","section_type":"continue_watching","title":"Row","items":[{"content_id":"movie:a","type":"movie","title":"A","backdrop_url":"/image"}]}""" + val row = if (episodeRows) { + """{"id":"row","section_type":"next_up","title":"Row","items":[{"content_id":"episode:a","type":"episode","title":"A","series_id":"series","backdrop_url":"/still-a","backdrop_is_episode_still":true},{"content_id":"episode:b","type":"episode","title":"B","series_id":"series","backdrop_url":"/still-b","backdrop_is_episode_still":true}]}""" + } else """{"id":"row","section_type":"continue_watching","title":"Row","items":[{"content_id":"movie:a","type":"movie","title":"A","backdrop_url":"/image"}]}""" val body = if (fallback) { reads++; row } else { assertEquals("/api/v2/home/sections", it.url.encodedPath); hook() if (empty) """{"sections":[]}""" @@ -48,6 +53,59 @@ class WatchNextHomeSyncTest { assertEquals("silo://play/movie:a?type=movie", fields.single().intentUri) } } } + @Test fun protectedEpisodesFetchSeriesArtOnceAndReplaceBothTiles() = runTest { + episodeRows = true + var artworkReads = 0 + scenario { repo -> + assertTrue(syncWatchNextHome(repo, gate, { false }, + spoilerPreferences = { EpisodeSpoilerPrefs(hideImages = true) }, + seriesArtwork = { id -> + assertEquals("series", id); artworkReads++ + ItemDetail(contentId = id, type = "series", title = "Series", backdropUrl = "/safe") + }, + ) { fields, run, authority -> + gate.write(run, authority) { + applies++ + assertEquals(listOf("/safe", "/safe"), fields.map { it.posterArtUri }) + } + }) + } + assertEquals(1, artworkReads) + assertEquals(1, applies) + } + + @Test fun failedSeriesLookupRetriesInsteadOfDroppingProtectedTiles() = runTest { + episodeRows = true + scenario { repo -> + assertFalse(syncWatchNextHome(repo, gate, { false }, + spoilerPreferences = { EpisodeSpoilerPrefs(hideImages = true) }, + seriesArtwork = { null }, + ) { _, _, _ -> applies++ }) + } + assertEquals(0, applies) + } + + @Test fun aCastIdentityInstalledMidRunNeverWritesTheLauncher() = runTest { + scenario { repo -> + var borrowed = false + hook = { borrowed = true } + assertTrue(syncWatchNextHome(repo, gate, { false }, borrowedIdentity = { borrowed }) { _, run, authority -> + gate.write(run, authority) { applies++ } + }) + assertEquals(0, applies) + } + } + + @Test fun unknownPreferencesRetryWithoutPublishingAndChangedPreferencesFenceWrites() = runTest { + scenario { repo -> + assertFalse(syncWatchNextHome(repo, gate, { false }, spoilerPreferences = { null }) { _, _, _ -> applies++ }) + assertEquals(0, applies) + var current = true + hook = { current = false } + assertTrue(syncWatchNextHome(repo, gate, { false }, preferencesCurrent = { current }) { _, _, _ -> applies++ }) + assertEquals(0, applies) + } + } @Test fun completeAndEmptySuccessMayReconcileButFailuresCannot() = runTest { scenario { repo -> assertTrue(sync(repo)); assertEquals(1, applies); assertEquals(0, reads) fail = true; assertFalse(sync(repo)); assertEquals(1, applies) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapperTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapperTest.kt index cdceeae099..a608f11e2a 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapperTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProgramMapperTest.kt @@ -1,11 +1,38 @@ package org.siloserver.silo.tv.watchnext import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.catalog.MediaItemUserState +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test class WatchNextProgramMapperTest { + @Test + fun `protected episode uses explicit series artwork and never publishes the still`() { + val episode = sectionItem(type = "episode").copy(backdropIsEpisodeStill = true, posterIsEpisodeStill = false) + val prefs = EpisodeSpoilerPrefs(hideImages = true) + assertEquals(episode.posterUrl, WatchNextProgramMapper.map(episode, "next_up", prefs)?.posterArtUri) + assertEquals(WatchNextProgramMapper.ASPECT_RATIO_2_3, + WatchNextProgramMapper.map(episode, "next_up", prefs)?.posterArtAspectRatio) + val noSafeSlot = episode.copy(posterIsEpisodeStill = true) + assertNull(WatchNextProgramMapper.map(noSafeSlot, "next_up", prefs)) + val series = ItemDetail(contentId = "series", type = "series", title = "Series", backdropUrl = "safe-series") + assertEquals("safe-series", WatchNextProgramMapper.map(noSafeSlot, "next_up", prefs, series)?.posterArtUri) + assertNull(WatchNextProgramMapper.map(noSafeSlot, "next_up", prefs, series.copy(type = "episode"))) + } + + @Test + fun `legacy protected artwork is replaced but watched and in progress stills remain`() { + val episode = sectionItem(type = "episode") + val prefs = EpisodeSpoilerPrefs(hideImages = true) + assertNull(WatchNextProgramMapper.map(episode, "next_up", prefs)) + assertEquals(episode.backdropUrl, + WatchNextProgramMapper.map(episode.copy(userState = MediaItemUserState(played = true)), "next_up", prefs)?.posterArtUri) + assertEquals(episode.backdropUrl, + WatchNextProgramMapper.map(episode.copy(positionSeconds = 1.0), "continue_watching", prefs)?.posterArtUri) + } private fun sectionItem( id: String = "tt1234", diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeederTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeederTest.kt new file mode 100644 index 0000000000..e58818f300 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSeederTest.kt @@ -0,0 +1,47 @@ +package org.siloserver.silo.tv.watchnext + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.WorkManager +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class WatchNextSeederTest { + @Test + fun startupKeepsTilesAndOnlyNewlyEnabledProtectionInvalidatesThem() { + val context = ApplicationProvider.getApplicationContext<Application>() + context.getSharedPreferences("silo_watch_next_protection", Context.MODE_PRIVATE).edit().clear().commit() + if (runCatching { WorkManager.getInstance(context) }.isFailure) { + WorkManager.initialize(context, Configuration.Builder().build()) + } + val repository = WatchNextRepository(context) + val seeder = WatchNextSeeder(context, repository) + val original = repository.writeGate.capture() + seeder.updateImageProtection(false) + assertEquals(original, repository.writeGate.capture()) + seeder.updateImageProtection(true) + val protected = repository.writeGate.capture() + assertNotEquals(original, protected) + // The setting is recorded only once the wipe finishes; a restart before + // then must wipe again. + val prefs = context.getSharedPreferences("silo_watch_next_protection", Context.MODE_PRIVATE) + val deadline = System.currentTimeMillis() + 5_000 + while (!prefs.getBoolean("hide_images", false) && System.currentTimeMillis() < deadline) Thread.sleep(10) + assertTrue(prefs.getBoolean("hide_images", false)) + // Recreating the seeder models a process restart with the same known setting. + WatchNextSeeder(context, repository).updateImageProtection(true) + assertEquals(protected, repository.writeGate.capture()) + seeder.updateImageProtection(false) + assertEquals(protected, repository.writeGate.capture()) + WorkManager.getInstance(context).cancelAllWork() + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt index 3252ac0de0..3ab2473e84 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt @@ -39,6 +39,12 @@ data class OverlaySummary( @SerialName("multi_sub") val multiSub: Boolean? = null ) +/** A watched mutation clears resume progress, including optimistic unwatched resets. */ +fun BrowseItem.withWatched(watched: Boolean): BrowseItem = copy( + userState = (userState ?: MediaItemUserState()).copy(played = watched), + positionSeconds = 0.0, +) + @Serializable data class BrowseItem( @SerialName("content_id") val contentId: String, @@ -58,6 +64,8 @@ data class BrowseItem( val networks: List<String> = emptyList(), @SerialName("show_status") val showStatus: String? = null, val overview: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, @@ -196,6 +204,8 @@ data class ItemDetail( @SerialName("release_date") val releaseDate: String? = null, @SerialName("first_air_date") val firstAirDate: String? = null, @SerialName("last_air_date") val lastAirDate: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, @@ -515,6 +525,7 @@ data class EpisodeListItem( @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, + @SerialName("still_is_episode_still") val stillIsEpisodeStill: Boolean? = null, @SerialName("still_url") val stillUrl: String? = null, @SerialName("still_thumbhash") val stillThumbhash: String? = null, @SerialName("user_data") val userData: LeafItemUserData? = null, @@ -585,6 +596,8 @@ data class WatchDetail( // Optional forward-compatible artwork. Current servers expose these on // ItemDetail rather than WatchDetail, so playback clients must fall back // to the full catalog detail when these fields are absent. + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt index bffc6aadf1..0f8fb1b7e6 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt @@ -1,6 +1,7 @@ package org.siloserver.silo.model.download import org.siloserver.silo.model.catalog.VersionChapter +import org.siloserver.silo.model.catalog.LeafItemUserData import kotlinx.serialization.Serializable /** @@ -29,6 +30,9 @@ data class DownloadSidecar( val subtitle: String? = null, val posterUrl: String? = null, val posterThumbhash: String? = null, + /** Null on older downloads; episode artwork is protected conservatively. */ + val posterIsEpisodeStill: Boolean? = null, + val episodeUserData: LeafItemUserData? = null, val year: Int? = null, val seriesTitle: String? = null, /** Stable parent series/show content id — the rename-safe grouping key for the diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt index e94c3c9201..b007693b16 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt @@ -33,6 +33,8 @@ data class SectionItem( @SerialName("position_seconds") val positionSeconds: Double? = null, @SerialName("duration_seconds") val durationSeconds: Double? = null, @SerialName("progress_updated_at") val progressUpdatedAt: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilers.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilers.kt new file mode 100644 index 0000000000..e750cab4ff --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilers.kt @@ -0,0 +1,87 @@ +package org.siloserver.silo.model.settings + +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.LeafItemUserData +import org.siloserver.silo.model.section.SectionItem + +/** + * Spoiler protection for episodes the profile has not started (settings + * contract revision 17). The profile settings + * [SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES] and + * [SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS] choose what to hide; + * this object owns the rule for which episodes they apply to, shared by every + * phone and TV surface. The server never changes catalog responses; clients + * apply the settings. + */ +object EpisodeSpoilers { + /** Contract revision that introduced both keys. */ + const val MIN_CONTRACT_REVISION = 17 + + /** Settings protocol version these keys are defined against. */ + const val SETTINGS_API_VERSION = 1 + + val KEYS: List<String> = listOf( + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, + SettingKeys.CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, + ) + + fun isSupported(capabilities: SettingsContractCapabilities): Boolean = + capabilities.apiVersion == SETTINGS_API_VERSION && + capabilities.manifestRevision >= MIN_CONTRACT_REVISION && + capabilities.supportsBatchedEffective + + /** + * An episode is unwatched while it is neither played nor in progress. + * Missing watch state counts as unwatched, so an episode is never + * revealed just because its payload left the state out. Matches the web + * client's `isEpisodeUnwatched`. + */ + fun isUnwatched( + played: Boolean?, + isInProgress: Boolean?, + positionSeconds: Double?, + ): Boolean { + if (played == true || isInProgress == true) return false + return (positionSeconds ?: 0.0) <= 0.0 + } + + fun isUnwatched(userData: LeafItemUserData?): Boolean = + userData == null || + isUnwatched(userData.played, userData.isInProgress, userData.positionSeconds) + + /** Browse cards use the live played flag returned by their action state. */ + fun hidesBrowseArtwork( + item: BrowseItem, prefs: EpisodeSpoilerPrefs, played: Boolean? = item.userState?.played, + selectBackdrop: Boolean = false, positionSeconds: Double? = item.positionSeconds, + ): Boolean { + val provenance = if (selectBackdrop && !item.backdropUrl.isNullOrBlank()) item.backdropIsEpisodeStill + else item.posterIsEpisodeStill + return item.type.equals("episode", ignoreCase = true) && + prefs.hidesImage(isUnwatched(played, isInProgress = null, positionSeconds = positionSeconds), provenance) + } + + /** Provenance follows the image selected for the wide card; null preserves older-server protection. */ + fun selectedImageIsStill(item: SectionItem): Boolean? = + if (!item.backdropUrl.isNullOrBlank()) item.backdropIsEpisodeStill else item.posterIsEpisodeStill + + /** Section rows carry no in-progress flag; played and position decide. */ + fun isUnwatched(item: SectionItem): Boolean = + isUnwatched(item.userState?.played, isInProgress = null, positionSeconds = item.positionSeconds) +} + +/** What the profile hides for an unwatched episode. Both off by default. */ +data class EpisodeSpoilerPrefs( + /** Blur the still of an episode the profile has not started. */ + val hideImages: Boolean = false, + /** Hide the description of an episode the profile has not started. */ + val hideOverviews: Boolean = false, +) { + fun hidesImage(unwatched: Boolean, isEpisodeStill: Boolean? = null): Boolean = + hideImages && unwatched && isEpisodeStill != false + + fun hidesOverview(unwatched: Boolean): Boolean = hideOverviews && unwatched + + companion object { + val NONE = EpisodeSpoilerPrefs() + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt index 843aae8d49..1e4f13082e 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt @@ -24,8 +24,12 @@ data class SettingPresentation( ) object SettingKeys { - const val REVISION = 16 + const val REVISION = 17 + /** Blur unwatched episode images */ + const val CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES = "catalog.hide_unwatched_episode_images" + /** Hide unwatched episode descriptions */ + const val CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS = "catalog.hide_unwatched_episode_overviews" /** Metadata language */ const val CATALOG_METADATA_LANGUAGE = "catalog.metadata_language" /** Metadata language exceptions */ @@ -163,6 +167,8 @@ object SettingKeys { /** Every key the server stores. Safe to flush. */ val REMOTE: List<String> = listOf( + CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, + CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, CATALOG_METADATA_LANGUAGE, CATALOG_METADATA_LANGUAGE_OVERRIDES, CATALOG_SHOW_ADVISORY_AGE, @@ -247,6 +253,8 @@ object SettingKeys { ) val BOOLEAN_KEYS: Set<String> = setOf( + CATALOG_HIDE_UNWATCHED_EPISODE_IMAGES, + CATALOG_HIDE_UNWATCHED_EPISODE_OVERVIEWS, CATALOG_SHOW_ADVISORY_AGE, HOME_HIDE_WATCHED_ITEMS, PLAYBACK_AUTO_PLAY_NEXT, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt index d87cf2d56e..86b8cb35ee 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt @@ -50,6 +50,8 @@ internal data class ItemDetailReadV2( @SerialName("release_date") val releaseDate: String? = null, @SerialName("first_air_date") val firstAirDate: String? = null, @SerialName("last_air_date") val lastAirDate: String? = null, + @SerialName("poster_is_episode_still") val posterIsEpisodeStill: Boolean? = null, + @SerialName("backdrop_is_episode_still") val backdropIsEpisodeStill: Boolean? = null, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, @@ -112,6 +114,8 @@ internal data class ItemDetailReadV2( releaseDate = releaseDate, firstAirDate = firstAirDate, lastAirDate = lastAirDate, + posterIsEpisodeStill = posterIsEpisodeStill, + backdropIsEpisodeStill = backdropIsEpisodeStill, posterUrl = posterUrl, posterThumbhash = posterThumbhash, backdropUrl = backdropUrl, @@ -275,6 +279,7 @@ internal data class EpisodeListItemReadV2( @SerialName("imdb_id") val imdbId: String? = null, @SerialName("tmdb_id") val tmdbId: String? = null, @SerialName("tvdb_id") val tvdbId: String? = null, + @SerialName("still_is_episode_still") val stillIsEpisodeStill: Boolean? = null, @SerialName("still_url") val stillUrl: String? = null, @SerialName("still_thumbhash") val stillThumbhash: String? = null, @SerialName("user_data") val userData: LeafItemUserDataReadV2? = null, @@ -291,6 +296,7 @@ internal data class EpisodeListItemReadV2( imdbId = imdbId, tmdbId = tmdbId, tvdbId = tvdbId, + stillIsEpisodeStill = stillIsEpisodeStill, stillUrl = stillUrl, stillThumbhash = stillThumbhash, userData = userData?.toDomain(), diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt index 7b9df42a9c..1905386e88 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/playback/ShufflePlayback.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.model.shuffle.Shuffle import org.siloserver.silo.model.shuffle.ShuffleScope import org.siloserver.silo.network.ApiResult @@ -23,6 +24,8 @@ data class ShuffleNextPick( val stillThumbhash: String?, val overview: String?, val runtimeMinutes: Int, + /** An episode the profile hasn't started, by the pick's own watch state; never a movie. */ + val isUnwatched: Boolean, ) /** Names what a shuffle draws from: "Movies", or "Breaking Bad · Season 2" for a season. */ @@ -52,6 +55,7 @@ internal fun SectionItem.toShuffleNextPick(): ShuffleNextPick { stillThumbhash = (if (episode) posterThumbhash else null) ?: backdropThumbhash, overview = overview, runtimeMinutes = runtime ?: 0, + isUnwatched = episode && EpisodeSpoilers.isUnwatched(this), ) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt index ae892a7fe3..e086365d25 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/UserItemStatePort.kt @@ -211,6 +211,9 @@ interface UserItemStatePort { * a cached card. Batched (one query). Empty map by default / unknown ids absent. */ suspend fun localContentStates(contentIds: List<String>): Map<String, LocalContentState> = emptyMap() + + /** Local writes that must take precedence over a server read started at [sinceMs]. */ + suspend fun contentIdsWithPendingOrNewerUserState(contentIds: List<String>, sinceMs: Long): Set<String> = emptySet() } /** Local optimistic content-level state; null fields = "no local opinion" (defer to server). */ diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt index 38b3b7ceca..164a69c611 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt @@ -477,8 +477,9 @@ private fun List<ResolvedSection>.mapItem( else section.copy(items = section.items.map { if (it.contentId == itemId) transform(it) else it }) } +/** Watched and unwatched both reset resume progress, as the server does. */ private fun SectionItem.withPlayed(played: Boolean): SectionItem = - copy(userState = (userState ?: MediaItemUserState()).copy(played = played)) + copy(userState = (userState ?: MediaItemUserState()).copy(played = played), positionSeconds = 0.0) private fun SectionItem.withFavorite(favorite: Boolean): SectionItem = copy(userState = (userState ?: MediaItemUserState()).copy(isFavorite = favorite)) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilers.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilers.kt new file mode 100644 index 0000000000..f7797af8e3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilers.kt @@ -0,0 +1,65 @@ +package org.siloserver.silo.viewmodel + +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.settings.EpisodeSpoilers +import org.siloserver.silo.model.watchtogether.Suggestion + +/** + * Spoiler protection for a title Watch Party shows: whether it is an episode + * this profile hasn't started, and where each artwork slot came from. The + * picker, confirmation page, lobby, and suggestions apply the same rule as + * every other surface ([EpisodeSpoilers]). + */ +data class WatchPartySpoilers( + /** An episode this profile hasn't started; false for anything else. */ + val unwatchedEpisode: Boolean, + val posterIsEpisodeStill: Boolean? = null, + val backdropIsEpisodeStill: Boolean? = null, +) { + fun hidesPoster(prefs: EpisodeSpoilerPrefs): Boolean = prefs.hidesImage(unwatchedEpisode, posterIsEpisodeStill) + + /** The backdrop slot shows the poster when there is no backdrop, so it takes that provenance. */ + fun hidesBackdrop(prefs: EpisodeSpoilerPrefs, backdropUrl: String?): Boolean = prefs.hidesImage( + unwatchedEpisode, + if (backdropUrl.isNullOrBlank()) posterIsEpisodeStill else backdropIsEpisodeStill, + ) + + fun hidesOverview(prefs: EpisodeSpoilerPrefs): Boolean = prefs.hidesOverview(unwatchedEpisode) + + companion object { + val NONE = WatchPartySpoilers(unwatchedEpisode = false) + + /** + * Nothing is known about the title beyond its type: an episode counts + * as unstarted, and its artwork as its still. + */ + fun forType(contentType: String) = WatchPartySpoilers(unwatchedEpisode = isEpisode(contentType)) + + fun of(item: SectionItem) = WatchPartySpoilers( + unwatchedEpisode = isEpisode(item.type) && EpisodeSpoilers.isUnwatched(item), + posterIsEpisodeStill = item.posterIsEpisodeStill, + backdropIsEpisodeStill = item.backdropIsEpisodeStill, + ) + + fun of(item: BrowseItem) = WatchPartySpoilers( + unwatchedEpisode = isEpisode(item.type) && + EpisodeSpoilers.isUnwatched(item.userState?.played, isInProgress = null, item.positionSeconds), + posterIsEpisodeStill = item.posterIsEpisodeStill, + backdropIsEpisodeStill = item.backdropIsEpisodeStill, + ) + + fun of(detail: ItemDetail) = WatchPartySpoilers( + unwatchedEpisode = isEpisode(detail.type) && EpisodeSpoilers.isUnwatched(detail.userData), + posterIsEpisodeStill = detail.posterIsEpisodeStill, + backdropIsEpisodeStill = detail.backdropIsEpisodeStill, + ) + + /** A suggestion carries no watch state or provenance. */ + fun of(suggestion: Suggestion) = forType(suggestion.contentType) + + private fun isEpisode(type: String) = type.trim().equals("episode", ignoreCase = true) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt index dba75b745a..02776260fb 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/WatchPartyViewModels.kt @@ -63,6 +63,8 @@ data class WatchPartyItem( val posterUrl: String? = null, val fileId: Int? = null, val libraryId: Int? = null, + /** How spoiler protection treats the title until its detail loads. */ + val spoilers: WatchPartySpoilers = WatchPartySpoilers.forType(contentType), ) /** diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilersTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilersTest.kt new file mode 100644 index 0000000000..3c3da69a3e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/settings/EpisodeSpoilersTest.kt @@ -0,0 +1,117 @@ +package org.siloserver.silo.model.settings + +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.withWatched +import org.siloserver.silo.model.catalog.LeafItemUserData +import org.siloserver.silo.model.catalog.MediaItemUserState +import org.siloserver.silo.model.section.SectionItem +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EpisodeSpoilersTest { + + @Test + fun browsePostersAndDetailHandoffsUseSelectedArtworkProvenance() { + val prefs = EpisodeSpoilerPrefs(hideImages = true) + val episode = BrowseItem("episode", "episode", "Episode", posterUrl = "still", + posterIsEpisodeStill = true, backdropUrl = "series", backdropIsEpisodeStill = false) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(episode, prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode, prefs, selectBackdrop = true)) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(backdropIsEpisodeStill = true), prefs, selectBackdrop = true)) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(posterIsEpisodeStill = null), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(posterIsEpisodeStill = false), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode, prefs, played = true)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(positionSeconds = 2.0), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode.copy(type = "series"), prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(episode, EpisodeSpoilerPrefs.NONE)) + } + + @Test + fun optimisticUnwatchedResetProtectsStartedBrowseArtwork() { + val prefs = EpisodeSpoilerPrefs(hideImages = true) + val original = BrowseItem("episode", "episode", "Episode", posterIsEpisodeStill = true, + backdropUrl = "still", backdropIsEpisodeStill = true, positionSeconds = 20.0) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(original, prefs)) + val reset = original.withWatched(false) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(original, prefs, played = reset.userState?.played, + positionSeconds = reset.positionSeconds)) + assertTrue(EpisodeSpoilers.hidesBrowseArtwork(original, prefs, played = reset.userState?.played, + selectBackdrop = true, positionSeconds = reset.positionSeconds)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(original, prefs)) + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(original.withWatched(true), prefs)) + } + + @Test + fun browseWireProgressKeepsStartedEpisodeArtworkVisible() { + val item = Json.decodeFromString<BrowseItem>("""{"content_id":"episode","type":"episode","title":"Episode","position_seconds":5,"poster_is_episode_still":true}""") + assertFalse(EpisodeSpoilers.hidesBrowseArtwork(item, EpisodeSpoilerPrefs(hideImages = true))) + } + + @Test + fun missingWatchStateCountsAsUnwatched() { + assertTrue(EpisodeSpoilers.isUnwatched(userData = null)) + assertTrue(EpisodeSpoilers.isUnwatched(LeafItemUserData())) + assertTrue(EpisodeSpoilers.isUnwatched(LeafItemUserData(positionSeconds = 0.0))) + } + + @Test + fun playedOrStartedEpisodesAreWatched() { + assertFalse(EpisodeSpoilers.isUnwatched(LeafItemUserData(played = true))) + assertFalse(EpisodeSpoilers.isUnwatched(LeafItemUserData(isInProgress = true))) + assertFalse(EpisodeSpoilers.isUnwatched(LeafItemUserData(positionSeconds = 12.0))) + // A played episode whose position was reset stays revealed. + assertFalse( + EpisodeSpoilers.isUnwatched(LeafItemUserData(played = true, positionSeconds = 0.0)), + ) + } + + @Test + fun sectionItemsUsePlayedAndPosition() { + assertTrue(EpisodeSpoilers.isUnwatched(sectionItem())) + assertTrue(EpisodeSpoilers.isUnwatched(sectionItem(userState = MediaItemUserState()))) + assertFalse(EpisodeSpoilers.isUnwatched(sectionItem(positionSeconds = 30.0))) + assertFalse( + EpisodeSpoilers.isUnwatched(sectionItem(userState = MediaItemUserState(played = true))), + ) + } + + @Test + fun supportNeedsRevision16AndBatchedEffective() { + fun caps(revision: Int, batched: Boolean = true, apiVersion: Int = 1) = + SettingsContractCapabilities( + apiVersion = apiVersion, + manifestRevision = revision, + supportsBatchedEffective = batched, + ) + assertTrue(EpisodeSpoilers.isSupported(caps(17))) + assertTrue(EpisodeSpoilers.isSupported(caps(18))) + assertFalse(EpisodeSpoilers.isSupported(caps(16))) + assertFalse(EpisodeSpoilers.isSupported(caps(17, batched = false))) + assertFalse(EpisodeSpoilers.isSupported(caps(17, apiVersion = 2))) + } + + @Test + fun imageProvenanceFollowsFallbackAndPreservesSeriesArt() { + val prefs = EpisodeSpoilerPrefs(hideImages = true) + val still = sectionItem().copy(backdropUrl = "https://example.invalid/still", backdropIsEpisodeStill = true) + assertTrue(prefs.hidesImage(EpisodeSpoilers.isUnwatched(still), EpisodeSpoilers.selectedImageIsStill(still))) + val seriesArt = still.copy(backdropIsEpisodeStill = false) + assertFalse(prefs.hidesImage(EpisodeSpoilers.isUnwatched(seriesArt), EpisodeSpoilers.selectedImageIsStill(seriesArt))) + val fallback = still.copy(backdropUrl = "", posterUrl = "https://example.invalid/poster", posterIsEpisodeStill = false) + assertFalse(prefs.hidesImage(true, EpisodeSpoilers.selectedImageIsStill(fallback))) + assertTrue(prefs.hidesImage(true, null)) + } + + private fun sectionItem( + userState: MediaItemUserState? = null, + positionSeconds: Double? = null, + ) = SectionItem( + contentId = "ep-1", + type = "episode", + title = "Pilot", + positionSeconds = positionSeconds, + userState = userState, + ) +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Test.kt index c3f0a08fbc..dd68a7b44d 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Test.kt @@ -15,13 +15,16 @@ class CatalogDetailReadV2Test { private fun detail(fileId: String) = """{"content_id":"m1","type":"movie","title":"Film","cast":[],"crew":[],"subtitles":[],"versions":[{"file_id":$fileId}],"user_data":{"last_file_id":"42"},"intro":{"start":1.5,"end":9.5}}""" @Test fun detailProjectsStringIdsAndCatalogMarkers() = runTest { - val client = client(detail("\"42\"")) + val body = detail("\"42\"").dropLast(1) + ",\"poster_is_episode_still\":true,\"backdrop_is_episode_still\":false}" + val client = client(body) try { val result = assertIs<ApiResult.Success<*>>(CatalogV2Api(client, ApiV2Gate.Unrestricted).itemDetail("m1")).data as org.siloserver.silo.model.catalog.ItemDetail assertEquals(42, result.versions.single().fileId) assertEquals(42, result.userData?.lastFileId) assertEquals(1.5, result.intro?.start) assertEquals(9.5, result.intro?.end) + assertEquals(true, result.posterIsEpisodeStill) + assertEquals(false, result.backdropIsEpisodeStill) } finally { client.close() } } @@ -80,7 +83,7 @@ class CatalogDetailReadV2Test { val client = HttpClient(MockEngine { request -> paths += request.url.encodedPath val body = when { - request.url.encodedPath.endsWith("/episodes") -> """{"items":[{"content_id":"e1","season_number":0,"episode_number":1,"files":[{"file_id":"2147483647"}],"user_data":{"last_file_id":"7"}}]}""" + request.url.encodedPath.endsWith("/episodes") -> """{"items":[{"content_id":"e1","season_number":0,"episode_number":1,"still_is_episode_still":false,"files":[{"file_id":"2147483647"}],"user_data":{"last_file_id":"7"}}]}""" request.url.encodedPath.endsWith("/people/7") -> """{"id":"7","name":"Person"}""" else -> { assertEquals("Person", request.url.parameters["q"]) @@ -95,6 +98,7 @@ class CatalogDetailReadV2Test { val episodes = assertIs<ApiResult.Success<org.siloserver.silo.model.catalog.EpisodesResponse>>(api.seasonEpisodes("series", 0)).data assertEquals(Int.MAX_VALUE, episodes.episodes.single().files.single().fileId) assertEquals(7, episodes.episodes.single().userData?.lastFileId) + assertEquals(false, episodes.episodes.single().stillIsEpisodeStill) assertEquals(7L, assertIs<ApiResult.Success<org.siloserver.silo.model.catalog.Person>>(api.person(7)).data.id) assertEquals(Long.MAX_VALUE, assertIs<ApiResult.Success<List<org.siloserver.silo.model.catalog.Person>>>(api.people("Person")).data.single().id) assertEquals(listOf("/api/v2/catalog/series/series/seasons/0/episodes", "/api/v2/catalog/people/7", "/api/v2/catalog/people"), paths) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt index 5059df4d72..2c0f8652ae 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/playback/ShufflePlaybackTest.kt @@ -70,11 +70,13 @@ class ShufflePlaybackTest { assertEquals(2, episode.episodeNumber) assertEquals("http://localhost/still.jpg", episode.stillUrl) assertEquals(42, episode.runtimeMinutes) + assertTrue(episode.isUnwatched) server.body = shuffleJson(current = episodeCard, next = movieCard) playback.refresh() val movie = assertNotNull(playback.nextPickAfter("ep-1")) assertFalse(movie.isEpisode) + assertFalse(movie.isUnwatched) assertEquals("Film", movie.title) assertNull(movie.seriesTitle) assertEquals(0, movie.seasonNumber) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilersTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilersTest.kt new file mode 100644 index 0000000000..fd44fcee80 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/WatchPartySpoilersTest.kt @@ -0,0 +1,80 @@ +package org.siloserver.silo.viewmodel + +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.model.catalog.LeafItemUserData +import org.siloserver.silo.model.catalog.MediaItemUserState +import org.siloserver.silo.model.settings.EpisodeSpoilerPrefs +import org.siloserver.silo.model.watchtogether.Suggestion +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchPartySpoilersTest { + private val enabled = EpisodeSpoilerPrefs(hideImages = true, hideOverviews = true) + private val episode = ItemDetail(contentId = "episode-1", type = "episode", title = "Pilot", backdropUrl = "still.jpg") + + @Test + fun anUnstartedEpisodeHidesItsArtworkAndOverview() { + val spoilers = WatchPartySpoilers.of(episode) + + assertTrue(spoilers.hidesPoster(enabled)) + assertTrue(spoilers.hidesBackdrop(enabled, episode.backdropUrl)) + assertTrue(spoilers.hidesOverview(enabled)) + assertFalse(spoilers.hidesPoster(EpisodeSpoilerPrefs.NONE)) + assertFalse(spoilers.hidesOverview(EpisodeSpoilerPrefs.NONE)) + } + + @Test + fun aStartedEpisodeStaysVisible() { + listOf( + LeafItemUserData(played = true), + LeafItemUserData(isInProgress = true), + LeafItemUserData(positionSeconds = 30.0), + ).forEach { state -> + val spoilers = WatchPartySpoilers.of(episode.copy(userData = state)) + assertFalse(spoilers.hidesPoster(enabled)) + assertFalse(spoilers.hidesBackdrop(enabled, episode.backdropUrl)) + assertFalse(spoilers.hidesOverview(enabled)) + } + } + + @Test + fun eachArtworkSlotUsesItsOwnProvenance() { + val spoilers = WatchPartySpoilers.of(episode.copy(posterIsEpisodeStill = false, backdropIsEpisodeStill = true)) + + assertFalse(spoilers.hidesPoster(enabled)) + assertTrue(spoilers.hidesBackdrop(enabled, "still.jpg")) + // Without a backdrop the slot shows the poster, series art here. + assertFalse(spoilers.hidesBackdrop(enabled, null)) + } + + @Test + fun aBrowseItemStartedByThisProfileStaysVisible() { + val item = BrowseItem(contentId = "episode-1", type = "episode", title = "Pilot") + + assertTrue(WatchPartySpoilers.of(item).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(item.copy(positionSeconds = 12.0)).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(item.copy(userState = MediaItemUserState(played = true))).hidesPoster(enabled)) + } + + @Test + fun onlyEpisodesAreEverProtected() { + val movie = ItemDetail(contentId = "movie-1", type = "movie", title = "Film") + + assertFalse(WatchPartySpoilers.of(movie).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(movie).hidesOverview(enabled)) + assertTrue(WatchPartySpoilers.of(suggestion("episode")).hidesPoster(enabled)) + assertFalse(WatchPartySpoilers.of(suggestion("movie")).hidesPoster(enabled)) + } + + @Test + fun anItemWithoutKnownStateIsProtectedByType() { + assertTrue(WatchPartyItem("episode-1", "episode", "Pilot").spoilers.hidesPoster(enabled)) + assertFalse(WatchPartyItem("movie-1", "movie", "Film").spoilers.hidesPoster(enabled)) + } + + private fun suggestion(type: String) = Suggestion( + id = type, roomId = "room", contentId = type, contentType = type, title = "Pick", createdAt = "2026-10-10T00:00:00Z", + ) +} diff --git a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE index 7061a91fe4..1657ba5ab4 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/SOURCE +++ b/shared/src/commonTest/resources/api/v2/fixtures/SOURCE @@ -1,7 +1,7 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/api/v2/fixtures -commit=85e1690fbec9e0dd78151487ef6b0f8e7b00fc1a -ref=t3code/tsidp-oidc-feasibility +commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 +ref=HEAD snapshot=committed api_major=2 diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json index 87488f3c05..d0454f55b4 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_account_identity_link_invalid_grant.json @@ -3,5 +3,5 @@ "title": "Invalid grant", "status": 400, "detail": "The code_verifier does not fit this link code.", - "instance": "urn:silo:request:0000000000000000000002bf" + "instance": "urn:silo:request:0000000000000000000002c7" } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json index 4e37607071..e154533212 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/complete_oauth_login_invalid_grant.json @@ -3,5 +3,5 @@ "title": "Invalid grant", "status": 400, "detail": "The code_verifier does not fit this completion code.", - "instance": "urn:silo:request:0000000000000000000002b9" + "instance": "urn:silo:request:0000000000000000000002c1" } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json index 30d22dea04..50949a7c1b 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:0000000000000000000002bb", + "instance": "urn:silo:request:0000000000000000000002c3", "errors": [ { "location": "body.password", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json b/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json index 78639be6a6..f31f1f0ebc 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/delete_account_identity_last_sign_in_method.json @@ -3,5 +3,5 @@ "title": "Last sign-in method", "status": 409, "detail": "The account has no other way to sign in; ask an administrator to set a password for it first", - "instance": "urn:silo:request:0000000000000000000002b0" + "instance": "urn:silo:request:0000000000000000000002b8" } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json index 752c4ca7bb..9279d622e3 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_external_sign_in_capabilities_ok.json @@ -1,5 +1,5 @@ { - "revision": "cf5d430e08bbf554e6b2b0b1059724253fda155248f66d6f39edea8465e248c7", + "revision": "7adbf3cc98d9654da106868d0c95ee6205d4a822876cd50b9d27015adad0f691", "state": "available", "available": true, "identities": true, @@ -9,5 +9,6 @@ "live_provider_changes": true, "provider_recheck": true, "credentials_linking": true, - "network_sign_in": true + "network_sign_in": true, + "network_link_keeps_password": true } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json index a562cca713..ca103e93ab 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "aba661db5ba93bda7e22444f124496156beee44ebf2662199d3fd596eb218fa1", + "contract_digest": "0000000000000000000000000000000000000000000000000000000000000000", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json b/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json index dcaf2258e7..d93338a5af 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/refresh_session_provider_unavailable.json @@ -3,5 +3,5 @@ "title": "Provider unavailable", "status": 503, "detail": "The sign-in provider could not confirm the account. Try again later; the session stays valid.", - "instance": "urn:silo:request:0000000000000000000002c1" + "instance": "urn:silo:request:0000000000000000000002c9" } diff --git a/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json index 667eacbd0b..a881c30a64 100644 --- a/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json +++ b/shared/src/commonTest/resources/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json @@ -3,5 +3,5 @@ "title": "Network identity required", "status": 403, "detail": "Open this server through the provider's network address to sign in this way", - "instance": "urn:silo:request:0000000000000000000002ca" + "instance": "urn:silo:request:0000000000000000000002d2" } diff --git a/shared/src/commonTest/resources/settings/v1/SOURCE b/shared/src/commonTest/resources/settings/v1/SOURCE index afac60f7cd..26b0cbbb43 100644 --- a/shared/src/commonTest/resources/settings/v1/SOURCE +++ b/shared/src/commonTest/resources/settings/v1/SOURCE @@ -1,6 +1,6 @@ repository=https://github.com/Silo-Server/silo-server path=contracts/settings/v1 -manifest_revision=16 +manifest_revision=17 fixture_version=1 Both files are byte-identical copies of the server's canonical contract; do not @@ -10,9 +10,9 @@ 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=ff4a3878684d730a42fb87a3a6631e4367008157 -manifest.json commit=ff4a3878684d730a42fb87a3a6631e4367008157 -copied from commit=ff4a3878684d730a42fb87a3a6631e4367008157 +conformance.json commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 +manifest.json commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 +copied from commit=51953b6c138a45aaca05a81b3234ef12d4bdd9c8 manifest.json is vendored whole, including the maintainer `notes` the server strips before serving /api/v1/settings/contract. Keeping it byte-identical is diff --git a/shared/src/commonTest/resources/settings/v1/conformance.json b/shared/src/commonTest/resources/settings/v1/conformance.json index 524d16e320..0729c73154 100644 --- a/shared/src/commonTest/resources/settings/v1/conformance.json +++ b/shared/src/commonTest/resources/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 16, + "manifest_revision": 17, "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": [ { diff --git a/shared/src/commonTest/resources/settings/v1/manifest.json b/shared/src/commonTest/resources/settings/v1/manifest.json index a4fd686af2..6eb3696d46 100644 --- a/shared/src/commonTest/resources/settings/v1/manifest.json +++ b/shared/src/commonTest/resources/settings/v1/manifest.json @@ -1,6 +1,6 @@ { "api_version": 1, - "revision": 16, + "revision": 17, "option_sets": { "playback_audio_languages": { "type": "language_tag", @@ -660,6 +660,36 @@ "recommended_control": "switch", "notes": "Profile scope because the reader is a household parent deciding what a child may watch, and they browse on their own profile. Default false: the advisory is extra chrome most viewers do not want, and only the profiles that ask for it should carry it. This setting only controls whether the badge is shown: turning it on or off never changes what a profile is allowed to watch. Restricting a profile by advisory age is a separate access field, the profile's max_advisory_age, which a household manager sets on the profile itself. Coverage is partial by nature: the provider that supplies the advisory is rate limited per day, so on a large library some titles carry one and others do not." }, + { + "key": "catalog.hide_unwatched_episode_images", + "introduced_in": 17, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "catalog", + "label": "Blur unwatched episode images", + "description": "Blur an episode's thumbnail until you start watching it, so the image does not give away the story.", + "recommended_control": "switch", + "notes": "Clients apply this; the server never changes catalog responses, so cached catalog data stays shared across profiles. An episode counts as unwatched while it is neither played nor in progress (user_data.played is false and it has no saved position). Covers episode stills wherever a client shows one for a specific episode: season episode lists, other-episodes rows, Next Up and Continue Watching cards, and the next-episode screen in the player. Series and season artwork is not affected. Profile scope so the choice follows the viewer across devices; default false because most viewers expect to see episode stills." + }, + { + "key": "catalog.hide_unwatched_episode_overviews", + "introduced_in": 17, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "catalog", + "label": "Hide unwatched episode descriptions", + "description": "Hide an episode's description until you start watching it.", + "recommended_control": "switch", + "notes": "Clients apply this with the same unwatched rule as catalog.hide_unwatched_episode_images: neither played nor in progress. Covers episode overviews in episode lists, on episode detail, and on the next-episode screen in the player. Series and season overviews are not affected. Separate from the image setting because some viewers want to hide only one of the two." + }, { "key": "player.hdr_enabled", "introduced_in": 1, @@ -668,6 +698,7 @@ "resolution_order": ["profile_device", "default"], "value_schema": { "type": "boolean" }, "default_value": true, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], "category": "player", "label": "HDR", "description": "Allow HDR output on this device.", @@ -739,6 +770,7 @@ "value_schema": { "type": "number", "minimum": 0.25, "maximum": 3.0, "step": 0.05 }, "default_value": 1.0, "unit": "x", + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], "category": "player", "label": "Playback speed", "description": "Default playback speed on this device.", @@ -769,6 +801,7 @@ "value_schema": { "type": "integer", "minimum": -10000, "maximum": 10000 }, "default_value": 0, "unit": "milliseconds", + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], "category": "player", "label": "Subtitle sync offset", "description": "Shift subtitles earlier or later on this device.", @@ -1085,7 +1118,7 @@ "resolution_order": ["profile", "default"], "value_schema": { "type": "boolean", "nullable": true }, "default_value": null, - "platforms": ["web"], + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], "category": "appearance", "label": "Card overlays enabled", "description": "Show overlay badges on media cards.", From 97f6773fb058f6d22653b6a7c22b04ba4bef779e Mon Sep 17 00:00:00 2001 From: Ian Mclaughlin <ianmalcolmmclaughlin@gmail.com> Date: Sat, 10 Oct 2026 23:49:12 +0100 Subject: [PATCH 77/87] feat(tv): add artwork and an On Deck row to the Up Next overlay (#541) * feat(tv): add artwork and an On Deck row to the Up Next overlay The TV Up Next overlay was a plain black screen with a text panel, while tvOS shows the next episode's artwork behind it and an On Deck row of other items in progress underneath. Android TV now does both: - The next episode's still (else the first On Deck item's art) fills the screen, blurred and dimmed. Modifier.blur does nothing before Android 12, so the blur comes from a tiny decode under its own cache key. The mini-player pane is cleared from the layer so the video stays visible. - On Deck reuses the phone's projection of home continue-watching sections. Picking an item replaces the player with one for that item, which resumes from its saved position; the current session finishes as any exit does. Hidden in a Watch Party. - The overlay is one fixed screen. The video is a separate surface placed at the pane's bounds and cannot follow a scroll, so the panel is tightened (runtime on the episode line, two-line synopsis, Keep Watching beside Back) and On Deck sits along the bottom, five cards wide. - The row uses TvSmoothBringIntoViewSpec so focus arriving from the panel does not slide it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): resume On Deck picks from the saved position and redraw the cut-out The On Deck snapshot is taken when the current item starts, so passing its position as a resume override could rewind an item watched elsewhere since. The pick now opens without an override and the player reads the saved position. The artwork cut-out read pane coordinates from state, but layout hands back the same coordinates object each time, so a moved pane never redrew it. The pane's bounds are now held in state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): route On Deck picks by item type and let the panel scroll On Deck dropped each section item's type and always opened the video player, so an audiobook in progress would open in the wrong player. The pick now carries the type through tvPlayDestinationFor, as the home rows do. The fixed Up Next layout squeezed the panel's last controls when its content outgrew the space above On Deck (large font sizes, a shuffle's extra rows). The panel now scrolls on its own; the video pane beside it stays put. TvSmoothBringIntoViewSpec keeps it from scrolling on every focus move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): keep items TV hides out of On Deck The On Deck projection read raw home sections, so a book or other item TV hides could appear on the Up Next overlay. It now applies visibleOnTv first, as TV Home does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): keep the On Deck row through a startup replan A replan swaps the session ID for the same item without reloading On Deck, so keying the load on the session discarded the row. The generation and content checks already reject stale loads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): keep safe next-episode art under spoiler protection Carry still_is_episode_still into the next-episode state so the Up Next backdrop shows artwork the server marks as not an episode still. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- .../siloserver/silo/tv/di/AndroidTvModule.kt | 1 + .../silo/tv/ui/navigation/TvAppNavigation.kt | 14 + .../tv/ui/screens/player/TvOnDeckItems.kt | 65 ++ .../tv/ui/screens/player/TvPlayerScreen.kt | 621 ++++++++++++------ .../tv/ui/screens/player/TvPlayerViewModel.kt | 53 +- 5 files changed, 554 insertions(+), 200 deletions(-) create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvOnDeckItems.kt diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index c984818c99..e1e88762c8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -587,6 +587,7 @@ val androidTvModule = module { serverReachabilityMonitor = get(), launchArgs = params.get<TvPlayerLaunchArgs>(), shuffleFeatureStore = get(), + sectionRepository = get(), ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 3e58009c27..6d11a94214 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -1590,6 +1590,20 @@ fun TvAppNavigation( castLaunchId = castLaunchId, onStaleCastLaunch = leaveStaleCastLaunch, onExit = { navController.popBackStack() }, + // Up Next On Deck pick: replace this player with the pick's. + // No resume override: the On Deck snapshot dates from when + // this item started, so the player reads the saved position. + onPlayOnDeckItem = { pickContentId, pickType -> + navController.navigateToTvPlayback( + destination = tvPlayDestinationFor( + itemType = pickType, + contentId = pickContentId, + fileId = null, + ), + contentId = pickContentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) + }, // Host Stop: back to the room's lobby in place of the player. // The membership is kept, so the lobby follows the next Start. onReturnToWatchPartyLobby = { id -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvOnDeckItems.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvOnDeckItems.kt new file mode 100644 index 0000000000..2fd735abc7 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvOnDeckItems.kt @@ -0,0 +1,65 @@ +package org.siloserver.silo.tv.ui.screens.player + +import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.settings.EpisodeSpoilers +import org.siloserver.silo.tv.ui.util.visibleOnTv + +/** One On Deck card on the Up Next overlay (tvOS `nextUpCarouselItems` parity). */ +data class TvOnDeckItem( + val contentId: String, + /** The section item's type, so a pick opens the right player (audiobooks too). */ + val type: String, + val title: String, + val subtitle: String?, + val artUrl: String?, + val artThumbhash: String?, + val progressFraction: Float?, + /** An episode the profile has not started; spoiler protection may hide [artUrl]. */ + val unwatchedEpisode: Boolean = false, + /** Whether [artUrl] is the episode's own still (null when the server doesn't say). */ + val artIsEpisodeStill: Boolean? = null, +) + +/** + * The phone's On Deck projection: home continue-watching pools minus the + * current item and its series, deduped, capped at 12, and only items with + * 16:9 art. Items TV hides (see visibleOnTv) are dropped first, as on TV Home. + */ +internal fun List<ResolvedSection>.toTvOnDeckItems(contentId: String, seriesId: String?): List<TvOnDeckItem> { + return this + .visibleOnTv() + .filter { it.sectionType in setOf("continue_watching", "in_progress", "next_up") } + .flatMap { it.items } + .filter { item -> + item.contentId != contentId && + (seriesId == null || item.seriesId != seriesId) + } + .filter { !it.backdropUrl.isNullOrBlank() } + .distinctBy { it.contentId } + .take(12) + .map { item -> + val progress = item.positionSeconds?.let { pos -> + item.durationSeconds?.takeIf { it > 0 }?.let { dur -> + (pos / dur).toFloat().coerceIn(0f, 1f) + } + } + TvOnDeckItem( + contentId = item.contentId, + type = item.type, + title = item.seriesTitle ?: item.title, + subtitle = when { + item.seasonNumber != null && item.episodeNumber != null -> + "S${item.seasonNumber}·E${item.episodeNumber}" + + (item.title.takeIf { it.isNotBlank() }?.let { " — $it" } ?: "") + item.year > 0 -> item.year.toString() + else -> null + }, + artUrl = item.backdropUrl, + artThumbhash = item.backdropThumbhash, + progressFraction = progress, + unwatchedEpisode = item.type.equals("episode", ignoreCase = true) && + EpisodeSpoilers.isUnwatched(item), + artIsEpisodeStill = item.backdropIsEpisodeStill, + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index f9d51401c8..3512aa2022 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -21,9 +21,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -33,12 +36,15 @@ 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -53,13 +59,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext @@ -135,6 +145,7 @@ import org.siloserver.silo.common.player.video.PlaybackRuntimeCorrectionMetrics import org.siloserver.silo.common.player.video.PlaybackStartupStallDetector import org.siloserver.silo.common.player.video.PostResumeVideoStallDetector import org.siloserver.silo.common.player.video.VideoPlayerTrackEntry +import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.playback.subtitleLabelIndicatesHearingImpaired import org.siloserver.silo.domain.player.IntroAutoSkipState import org.siloserver.silo.model.playback.PlaybackExecutionPlan @@ -154,10 +165,12 @@ import org.siloserver.silo.tv.cast.TvSiloCastReceiver import org.siloserver.silo.tv.data.preferences.PlaybackQuality import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvLoadingScreen +import org.siloserver.silo.tv.ui.components.TvMediaCard import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvFocusLog import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.tv.ui.theme.TvSmoothBringIntoViewSpec private const val CONTROLS_AUTO_HIDE_MS = 5_000L // Pre-revision-9 relative seeks: 10s back, 30s forward, matching tvOS @@ -259,6 +272,8 @@ fun TvPlayerScreen( // The Watch Party ended under this player (or its membership is gone); // the hub explains why and offers Rejoin. onWatchPartyEnded: () -> Unit = onExit, + // Up Next On Deck pick: replace this player with one for that item. + onPlayOnDeckItem: (contentId: String, itemType: String) -> Unit = { _, _ -> }, preferredFileId: Int? = null, preferredQuality: String? = null, // Watch Party room. When non-null, [WatchPartyPlayback] binds this player @@ -562,6 +577,7 @@ fun TvPlayerScreen( } } val stopPlaybackAndExit = { exitPlayer(true, null) } + val latestOnPlayOnDeckItem by rememberUpdatedState(onPlayOnDeckItem) val latestOnStaleCastLaunch by rememberUpdatedState(onStaleCastLaunch) // The phone's launch went stale just before this player registered. The // callback checks this player is still on top right before stopping what @@ -2487,6 +2503,13 @@ fun TvPlayerScreen( onExitPlayback = { stopPlaybackAndExit() }, onNextUpVideoBoundsChanged = { nextUpVideoBounds = it }, onIntroPromptSelect = { handleIntroPromptSelect() }, + onDeckItems = state.onDeckItems, + onPlayOnDeckItem = { item -> + // Leave like Back (the session finishes and saves its + // position), then open the pick in a fresh player. + viewModel.onOnDeckItemChosen() + exitPlayer(true) { latestOnPlayOnDeckItem(item.contentId, item.type) } + }, qualityOfferLabel = lowerQuality?.label?.takeIf { qualityOfferVisible }, ) if (watchParty != null) { @@ -3133,6 +3156,7 @@ private fun TvRoomIndicator( * surface; the pass-out gate now manifests as the overlay appearing WITHOUT a * countdown ring (the user must explicitly choose). */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun TvPlayerNextUpOverlay( nextEpisode: NextEpisodeState?, @@ -3150,6 +3174,9 @@ private fun TvPlayerNextUpOverlay( shuffle: TvShuffleUiState? = null, onPickAnother: () -> Unit = {}, onStopShuffling: () -> Unit = {}, + // Other items in progress, shown under the panel (tvOS On Deck parity). + onDeckItems: List<TvOnDeckItem> = emptyList(), + onPlayOnDeckItem: (TvOnDeckItem) -> Unit = {}, ) { val primaryFocus = remember { FocusRequester() } var upNextHasFocus by remember { mutableStateOf(false) } @@ -3162,236 +3189,428 @@ private fun TvPlayerNextUpOverlay( ) } - Box( + var overlayCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) } + var paneCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) } + // onGloballyPositioned hands back the same coordinates object on every + // layout, so its state never changes; these bounds do, and reading them + // in the hole redraws the cut-out whenever the pane moves. + var paneBoundsInRoot by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } + BoxWithConstraints( modifier = Modifier .onFocusChanged { upNextHasFocus = it.hasFocus } + .onGloballyPositioned { overlayCoordinates = it } .fillMaxSize(), ) { - Row( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - .padding(horizontal = 80.dp), - horizontalArrangement = Arrangement.spacedBy(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // The existing PlayerView is measured and placed into this pane. - // It stays mounted while the successor prepares. + // tvOS grid, five cards wide so everything fits one screen: On Deck + // cards span the content column, the preview covers the first two. + val cardSpacing = 20.dp + val onDeckCardWidth = (maxWidth - 160.dp - cardSpacing * 4) / 5 + // tvOS backgroundArtwork: the next episode's still, else the first + // On Deck item's art. Art spoiler protection hides is skipped. + val spoilers = org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs.current + val backdrop = nextEpisode + ?.takeUnless { it.isEpisode && spoilers.hidesImage(it.isUnwatched, it.stillIsEpisodeStill) } + ?.stillUrl?.takeIf { it.isNotBlank() }?.let { it to null } + ?: onDeckItems.firstOrNull { !spoilers.hidesImage(it.unwatchedEpisode, it.artIsEpisodeStill) } + ?.let { it.artUrl to it.artThumbhash } + TvNextUpBackdrop( + url = backdrop?.first, + thumbhash = backdrop?.second, + hole = { + paneBoundsInRoot + val overlay = overlayCoordinates + val pane = paneCoordinates + if (overlay != null && pane != null && overlay.isAttached && pane.isAttached) { + overlay.localBoundingBoxOf(pane) + } else { + null + } + }, + ) + // One fixed screen, never scrolled: the video is a separate surface + // placed at the pane's bounds, and it cannot follow a scroll. Only the + // panel may scroll, and only when a focused control is off screen (TV's + // default focus pivot would scroll it on every move). + CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + Column(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier - .weight(1f) - .aspectRatio(16f / 9f) - .onGloballyPositioned { onVideoBoundsChanged(it.videoViewportBounds()) } - .clip(RoundedCornerShape(8.dp)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.16f), - shape = RoundedCornerShape(8.dp), - ), - ) - - // Next-episode panel. - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(20.dp), + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, ) { - shuffle?.scopeLabel?.let { scope -> - Row( + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 80.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + // The existing PlayerView is measured and placed into this pane. + // It stays mounted while the successor prepares. + Box( + modifier = Modifier + .width(onDeckCardWidth * 2 + cardSpacing) + .aspectRatio(16f / 9f) + .onGloballyPositioned { + paneCoordinates = it + paneBoundsInRoot = it.boundsInRoot() + onVideoBoundsChanged(it.videoViewportBounds()) + } + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.16f), + shape = RoundedCornerShape(8.dp), + ), + ) + + // Next-episode panel. It scrolls on its own when its content + // outgrows the space above On Deck (large font sizes, a + // shuffle's extra rows); the video pane beside it stays put. + Column( modifier = Modifier - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.10f)) - .padding(horizontal = 14.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - androidx.tv.material3.Icon( - imageVector = Icons.Filled.Shuffle, - contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), - modifier = Modifier.size(16.dp), - ) + shuffle?.scopeLabel?.let { scope -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color.White.copy(alpha = 0.10f)) + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.tv.material3.Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(16.dp), + ) + androidx.tv.material3.Text( + text = "Shuffling $scope", + color = Color.White.copy(alpha = 0.75f), + style = androidx.tv.material3.MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + } + } + val eyebrow = when { + nextEpisode == null -> if (videoEnded) "Finished" else "More To Watch" + shuffle != null -> "Up Next at Random" + videoEnded -> "Playing Next" + else -> "Up Next" + } androidx.tv.material3.Text( - text = "Shuffling $scope", - color = Color.White.copy(alpha = 0.75f), + text = eyebrow.uppercase(), + color = Color.White.copy(alpha = 0.52f), style = androidx.tv.material3.MaterialTheme.typography.labelLarge, - maxLines = 1, ) - } - } - val eyebrow = when { - nextEpisode == null -> if (videoEnded) "Finished" else "More To Watch" - shuffle != null -> "Up Next at Random" - videoEnded -> "Playing Next" - else -> "Up Next" - } - androidx.tv.material3.Text( - text = eyebrow.uppercase(), - color = Color.White.copy(alpha = 0.52f), - style = androidx.tv.material3.MaterialTheme.typography.labelLarge, - ) - if (nextEpisode != null) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - // A shuffled movie has no series line; its own title heads the panel. - val heading = nextEpisode.seriesTitle?.takeIf { it.isNotBlank() } - ?: nextEpisode.title.takeUnless { nextEpisode.isEpisode } - heading?.let { title -> + if (nextEpisode != null) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // A shuffled movie has no series line; its own title heads the panel. + val heading = nextEpisode.seriesTitle?.takeIf { it.isNotBlank() } + ?: nextEpisode.title.takeUnless { nextEpisode.isEpisode } + heading?.let { title -> + androidx.tv.material3.Text( + text = title, + color = Color.White, + style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, + maxLines = 1, + ) + } + if (nextEpisode.isEpisode) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + androidx.tv.material3.Text( + text = "S${nextEpisode.seasonNumber}·E${nextEpisode.episodeNumber}", + color = Color.White.copy(alpha = 0.62f), + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + ) + androidx.tv.material3.Text( + text = nextEpisode.title ?: "Next Episode", + color = Color.White, + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false), + ) + // Runtime shares the episode line to keep the panel short. + if (nextEpisode.runtimeMinutes > 0) { + androidx.tv.material3.Text( + text = "· ${nextEpisode.runtimeMinutes} min", + color = Color.White.copy(alpha = 0.46f), + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + ) + } + } + if (!nextEpisode.isEpisode && nextEpisode.runtimeMinutes > 0) { + androidx.tv.material3.Text( + text = "${nextEpisode.runtimeMinutes} min", + color = Color.White.copy(alpha = 0.46f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + ) + } + val hidesOverview = org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs.current + .hidesOverview(nextEpisode.isUnwatched) + nextEpisode.overview?.takeIf { it.isNotBlank() && !hidesOverview }?.let { overview -> + androidx.tv.material3.Text( + text = overview, + color = Color.White.copy(alpha = 0.58f), + style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, + maxLines = 2, + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TvDialogActionRow( + title = "Play Now", + onClick = onPlayNow, + modifier = Modifier + .width(220.dp) + .focusRequester(primaryFocus), + ) + if (countdownSeconds != null) { + TvCountdownRing( + seconds = countdownSeconds, + totalSeconds = countdownTotalSeconds, + ) + } + } + + if (shuffle != null) { + TvDialogActionRow( + title = "Pick Another", + onClick = onPickAnother, + enabled = !shuffle.pickingAnother, + modifier = Modifier.width(260.dp), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (!videoEnded) { + TvDialogActionRow( + title = "Keep Watching", + onClick = onKeepWatching, + modifier = Modifier.width(220.dp), + ) + } + TvDialogActionRow( + title = "Back", + onClick = onBack, + modifier = Modifier.width(140.dp), + ) + } + shuffle?.message?.let { message -> + androidx.tv.material3.Text( + text = message, + color = Color(0xFFFFB4AB), + style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, + ) + } + // Interactive toggle (was a dead focusable): OK flips + // auto-play; focus inverts the pill so the D-pad stop is + // visible. + var autoPlayToggleFocused by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.tv.material3.Text( + text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", + color = if (autoPlayToggleFocused) Color.Black else Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + modifier = Modifier + .onFocusChanged { autoPlayToggleFocused = it.isFocused } + .clip(RoundedCornerShape(percent = 50)) + .background(if (autoPlayToggleFocused) Color.White else Color.Transparent) + .clickable { onToggleAutoPlay() } + .padding(horizontal = 12.dp, vertical = 4.dp), + ) + if (shuffle != null) { + var stopFocused by remember { mutableStateOf(false) } + androidx.tv.material3.Text( + text = "·", + color = Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + ) + androidx.tv.material3.Text( + text = "Stop shuffling", + color = if (stopFocused) Color.Black else Color.White.copy(alpha = 0.54f), + style = androidx.tv.material3.MaterialTheme.typography.labelMedium, + modifier = Modifier + .onFocusChanged { stopFocused = it.isFocused } + .clip(RoundedCornerShape(percent = 50)) + .background(if (stopFocused) Color.White else Color.Transparent) + .clickable { onStopShuffling() } + .padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } + } else { + // Finished / no-next-episode state. androidx.tv.material3.Text( - text = title, + text = if (videoEnded) "End of playback" else "Almost finished", color = Color.White, style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - maxLines = 1, ) - } - if (nextEpisode.isEpisode) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { androidx.tv.material3.Text( - text = "S${nextEpisode.seasonNumber}·E${nextEpisode.episodeNumber}", + text = if (shuffle != null) "Nothing else in this shuffle can play." else "No next episode is available.", color = Color.White.copy(alpha = 0.62f), - style = androidx.tv.material3.MaterialTheme.typography.titleMedium, - ) - androidx.tv.material3.Text( - text = nextEpisode.title ?: "Next Episode", - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.titleMedium, - maxLines = 2, - ) - } - if (nextEpisode.runtimeMinutes > 0) { - androidx.tv.material3.Text( - text = "${nextEpisode.runtimeMinutes} min", - color = Color.White.copy(alpha = 0.46f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - ) - } - val hidesOverview = org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs.current - .hidesOverview(nextEpisode.isUnwatched) - val overview = nextEpisode.overview?.takeIf { it.isNotBlank() && !hidesOverview } - if (overview != null) { - androidx.tv.material3.Text( - text = overview, - color = Color.White.copy(alpha = 0.58f), style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - maxLines = 3, - ) - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TvDialogActionRow( - title = "Play Now", - onClick = onPlayNow, - modifier = Modifier - .width(220.dp) - .focusRequester(primaryFocus), - ) - if (countdownSeconds != null) { - TvCountdownRing( - seconds = countdownSeconds, - totalSeconds = countdownTotalSeconds, ) + if (!videoEnded) { + TvDialogActionRow( + title = "Keep Watching", + onClick = onKeepWatching, + modifier = Modifier + .width(260.dp) + .focusRequester(primaryFocus), + ) + TvDialogActionRow( + title = "Back", + onClick = onBack, + modifier = Modifier.width(160.dp), + ) + } else { + TvDialogActionRow( + title = "Back", + onClick = onBack, + modifier = Modifier + .width(160.dp) + .focusRequester(primaryFocus), + ) + } } } + } + } + if (onDeckItems.isNotEmpty()) { + TvNextUpOnDeckRow( + items = onDeckItems, + cardWidth = onDeckCardWidth, + cardSpacing = cardSpacing, + onPlay = onPlayOnDeckItem, + ) + } + } + } + } +} - if (shuffle != null) { - TvDialogActionRow( - title = "Pick Another", - onClick = onPickAnother, - enabled = !shuffle.pickingAnother, - modifier = Modifier.width(260.dp), - ) - } - if (!videoEnded) { - TvDialogActionRow( - title = "Keep Watching", - onClick = onKeepWatching, - modifier = Modifier.width(260.dp), - ) - } - TvDialogActionRow( - title = "Back", - onClick = onBack, - modifier = Modifier.width(160.dp), +/** + * Full-screen artwork behind the Up Next overlay (tvOS `backgroundArtwork`): + * dimmed so it tints rather than shows. `Modifier.blur` does nothing before + * Android 12 (the SHIELD runs 11), so the softness comes from decoding the + * image tiny and scaling it up. The [hole] — the mini-player pane — is + * cleared so the video drawn beneath this overlay stays visible. + */ +@Composable +private fun TvNextUpBackdrop(url: String?, thumbhash: String?, hole: () -> androidx.compose.ui.geometry.Rect?) { + if (url.isNullOrBlank() && thumbhash.isNullOrBlank()) return + val holeRadius = with(androidx.compose.ui.platform.LocalDensity.current) { 8.dp.toPx() } + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + hole()?.let { rect -> + drawRoundRect( + color = Color.Black, + topLeft = rect.topLeft, + size = rect.size, + cornerRadius = androidx.compose.ui.geometry.CornerRadius(holeRadius), + blendMode = androidx.compose.ui.graphics.BlendMode.Clear, ) - shuffle?.message?.let { message -> - androidx.tv.material3.Text( - text = message, - color = Color(0xFFFFB4AB), - style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - ) - } - // Interactive toggle (was a dead focusable): OK flips - // auto-play; focus inverts the pill so the D-pad stop is - // visible. - var autoPlayToggleFocused by remember { mutableStateOf(false) } - Row(verticalAlignment = Alignment.CenterVertically) { - androidx.tv.material3.Text( - text = "Auto-play is ${if (autoPlayEnabled) "On" else "Off"}", - color = if (autoPlayToggleFocused) Color.Black else Color.White.copy(alpha = 0.54f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - modifier = Modifier - .onFocusChanged { autoPlayToggleFocused = it.isFocused } - .clip(RoundedCornerShape(percent = 50)) - .background(if (autoPlayToggleFocused) Color.White else Color.Transparent) - .clickable { onToggleAutoPlay() } - .padding(horizontal = 12.dp, vertical = 4.dp), + } + }, + ) { + ThumbhashImage( + url = url, + thumbhash = thumbhash, + contentDescription = null, + decodeSizePx = 48, + // Its own cache entry: under the URL alone, a full-size copy + // cached by another screen comes back sharp instead of tiny. + cacheKey = url?.let { "next-up-backdrop:$it" }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = 1.12f + scaleY = 1.12f + }, + ) + // tvOS dims to ~4% (0.18 under a 0.78 scrim), which barely reads on a + // TV in a dim room; keep ~40% and let the scrim carry legibility. + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f))) + // Deeper at the bottom so the On Deck row reads over the art. + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0.55f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.85f), + ), + ), + ) + } +} + +/** The Up Next overlay's On Deck row: other items in progress (tvOS parity). */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +private fun TvNextUpOnDeckRow( + items: List<TvOnDeckItem>, + cardWidth: androidx.compose.ui.unit.Dp, + cardSpacing: androidx.compose.ui.unit.Dp, + onPlay: (TvOnDeckItem) -> Unit, +) { + val spoilers = org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + androidx.tv.material3.Text( + text = "On Deck", + color = Color.White.copy(alpha = 0.86f), + style = androidx.tv.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 80.dp), + ) + // TV's default focus pivot slides the row so the focused card sits a + // third of the way across, shifting it left as focus arrives from the + // panel; scroll only when a card nears an edge. + CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + androidx.compose.foundation.lazy.LazyRow( + contentPadding = PaddingValues(horizontal = 80.dp), + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + ) { + items(items.size, key = { items[it].contentId }) { index -> + val item = items[index] + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + TvMediaCard( + title = item.title, + hideArtwork = spoilers.hidesImage(item.unwatchedEpisode, item.artIsEpisodeStill), + posterUrl = item.artUrl, + posterThumbhash = item.artThumbhash, + onClick = { onPlay(item) }, + progress = item.progressFraction, + artworkAspectRatio = 16f / 9f, + width = cardWidth, ) - if (shuffle != null) { - var stopFocused by remember { mutableStateOf(false) } + item.subtitle?.let { subtitle -> androidx.tv.material3.Text( - text = "·", - color = Color.White.copy(alpha = 0.54f), + text = subtitle, + color = Color.White.copy(alpha = 0.52f), style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - ) - androidx.tv.material3.Text( - text = "Stop shuffling", - color = if (stopFocused) Color.Black else Color.White.copy(alpha = 0.54f), - style = androidx.tv.material3.MaterialTheme.typography.labelMedium, - modifier = Modifier - .onFocusChanged { stopFocused = it.isFocused } - .clip(RoundedCornerShape(percent = 50)) - .background(if (stopFocused) Color.White else Color.Transparent) - .clickable { onStopShuffling() } - .padding(horizontal = 12.dp, vertical = 4.dp), + maxLines = 1, + modifier = Modifier.width(cardWidth), ) } } - } else { - // Finished / no-next-episode state. - androidx.tv.material3.Text( - text = if (videoEnded) "End of playback" else "Almost finished", - color = Color.White, - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - ) - androidx.tv.material3.Text( - text = if (shuffle != null) "Nothing else in this shuffle can play." else "No next episode is available.", - color = Color.White.copy(alpha = 0.62f), - style = androidx.tv.material3.MaterialTheme.typography.bodyMedium, - ) - if (!videoEnded) { - TvDialogActionRow( - title = "Keep Watching", - onClick = onKeepWatching, - modifier = Modifier - .width(260.dp) - .focusRequester(primaryFocus), - ) - TvDialogActionRow( - title = "Back", - onClick = onBack, - modifier = Modifier.width(160.dp), - ) - } else { - TvDialogActionRow( - title = "Back", - onClick = onBack, - modifier = Modifier - .width(160.dp) - .focusRequester(primaryFocus), - ) - } } } } @@ -3752,6 +3971,8 @@ private fun TvPlayerOverlays( onExitPlayback: () -> Unit, onNextUpVideoBoundsChanged: (Rect) -> Unit, onIntroPromptSelect: () -> Unit, + onDeckItems: List<TvOnDeckItem> = emptyList(), + onPlayOnDeckItem: (TvOnDeckItem) -> Unit = {}, /** Watch Party: the lower rung offered after repeated stalls, or null. */ qualityOfferLabel: String? = null, ) { @@ -3942,6 +4163,8 @@ private fun TvPlayerOverlays( shuffle = shuffle, onPickAnother = onPickAnother, onStopShuffling = onStopShuffling, + onDeckItems = onDeckItems, + onPlayOnDeckItem = onPlayOnDeckItem, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 3ef3a04217..2ba75a8138 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -621,6 +621,8 @@ data class NextEpisodeState( val episodeNumber: Int, val title: String?, val stillUrl: String?, + /** Whether [stillUrl] is the episode's own still (null when the server doesn't say). */ + val stillIsEpisodeStill: Boolean? = null, val overview: String? = null, val seriesTitle: String? = null, val runtimeMinutes: Int = 0, @@ -753,6 +755,9 @@ class TvPlayerViewModel( // Shuffle: the server picks what plays next. Optional so tests that // construct the VM directly stay source-compatible. private val shuffleFeatureStore: org.siloserver.silo.model.feature.ShuffleFeatureStore? = null, + // Home sections feeding the Up Next overlay's On Deck row. Optional so + // tests that construct the VM directly stay source-compatible. + private val sectionRepository: org.siloserver.silo.repository.SectionRepository? = null, ) : ViewModel() { companion object { @@ -1144,6 +1149,8 @@ class TvPlayerViewModel( val seasonNumber: Int? = null, val episodeNumber: Int? = null, val nextEpisode: NextEpisodeState? = null, + // Up Next's On Deck row: other items in progress (tvOS parity). + val onDeckItems: List<TvOnDeckItem> = emptyList(), val stillWatchingPrompt: Boolean = false, // Up-Next end-of-playback surface (mirrors tvOS PlayerNextUpScreen). When // `showNextUp` is true the screen renders the Up-Next overlay — a 16:9 @@ -2452,7 +2459,10 @@ class TvPlayerViewModel( ) } startIntroAutoSkipObserver() - if (!nextUpTransitionGate.isActive) resolveNextEpisode() + if (!nextUpTransitionGate.isActive) { + resolveNextEpisode() + loadOnDeckItems() + } } is VideoPlayerUiState.Error -> { episodeSelectionHandoffSlot.retainForRetry( @@ -3168,6 +3178,7 @@ class TvPlayerViewModel( ) } resolveNextEpisode() + loadOnDeckItems() } } @@ -4420,6 +4431,45 @@ class TvPlayerViewModel( * excluded, per the resolver's playback-order contract) and finds the * immediate next via [nextEpisodeAfter]. */ + private var onDeckGeneration = 0L + + /** + * Populates the Up Next overlay's On Deck row (tvOS + * `loadNextUpOnDeckItems` and phone parity): home continue-watching pools, + * minus the current item and its series. A Watch Party never shows it: + * the room decides what plays. + */ + private fun loadOnDeckItems() { + val run = ++onDeckGeneration + val repository = sectionRepository ?: return + val state = _uiState.value + val forContentId = state.contentId + val currentSeriesId = state.seriesId + _uiState.update { it.copy(onDeckItems = emptyList()) } + if (roomId != null) return + // Not keyed on the session: a startup replan swaps it for the same + // item without reloading, and the row doesn't depend on it. + fun stillCurrent() = run == onDeckGeneration && _uiState.value.contentId == forContentId + viewModelScope.launch { + val owner = repository.captureHomeAuthority() ?: return@launch + repository.loadScopedHomeSections(owner, ::stillCurrent) { sections -> + val pool = sections.toTvOnDeckItems(forContentId, currentSeriesId) + _uiState.update { if (!stillCurrent()) it else it.copy(onDeckItems = pool) } + } + } + } + + /** + * An On Deck pick leaves this player for another item: an explicit choice, + * so stop the countdown before navigation replaces the screen and its + * disposal finishes the session as any exit does. + */ + fun onOnDeckItemChosen() { + nextUpCountdownJob?.cancel() + nextUpCountdownJob = null + _uiState.update { it.copy(nextUpCountdownSeconds = null) } + } + private fun resolveNextEpisode() { // The room owns what plays next; nothing here may offer it. if (roomId != null) return @@ -4458,6 +4508,7 @@ class TvPlayerViewModel( episodeNumber = next.episodeNumber, title = next.title, stillUrl = next.stillUrl, + stillIsEpisodeStill = next.stillIsEpisodeStill, overview = next.overview, seriesTitle = state.seriesTitle, runtimeMinutes = next.runtime, From 747efff761a9781bad385b6d00bb821f77bd5fcc Mon Sep 17 00:00:00 2001 From: Suspense <rhainland@gmail.com> Date: Sun, 11 Oct 2026 02:40:58 +0200 Subject: [PATCH 78/87] feat(metadata): mark AI translations and translate episode lists and TV Home on view (#536) * feat(metadata): mark AI-translated descriptions and translate episodes and TV marquee on view Decode machine_translated_fields on item detail, season and episode rows and section/catalog cards, plus pending_translation_language on episode rows and cards. Show "Translated by AI" under machine-translated overviews on phone and TV detail pages, a compact marker on episode rows, and an icon in the TV focus marquee. The series page starts the shown season's translation job in auto mode when an episode row reports a missing description, and season pages refresh their episode list while the job runs. The TV focus marquee translates the item the viewer rests on in auto mode. Changing the profile's metadata language re-reads Home, For You and library lists. * fix(tv): translate on view only for Featured cards in the focus marquee The web client translates only the visible Featured slide. Cards resting in ordinary rows now keep their text and AI marker but no longer start translation jobs. * fix(tv): keep a marquee translation after leaving Home The marquee kept the translated description only in its own memory, so returning from a detail page showed the original text again. Re-read the Home rows when a translation lands. * fix(metadata): retry a season's translation after a switch, and mark only AI overviews Switching seasons while another season's translation was polling latched the new season as already translated, so its episodes stayed untranslated for the session on phone and TV. The new season's job now starts once the running one ends. The "Translated by AI" mark sits with the overview, but it showed for any machine-translated field, so an AI tagline alone marked a provider or manual overview as AI. It now requires the overview to be machine-translated. * fix: preserve translation refresh and episode state * fix: preserve translation retry and episode ownership * fix: invalidate translated detail caches on language changes * fix: bind language cache invalidation to its profile --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- .../common/data/db/dao/CatalogCacheDao.kt | 6 + .../repository/RoomCatalogCacheRepository.kt | 11 + .../ui/components/MachineTranslatedLabel.kt | 59 +++++ .../RoomCatalogCacheRepositoryTest.kt | 57 +++++ .../silo/android/di/AndroidModule.kt | 5 +- .../detail/DescriptionTranslationSection.kt | 20 ++ .../android/ui/screens/detail/EpisodeList.kt | 14 +- .../ui/screens/detail/ItemDetailScreen.kt | 22 ++ .../ui/screens/detail/ItemDetailViewModel.kt | 142 ++++++++++-- .../ui/screens/detail/SeriesDetailContent.kt | 16 +- .../android/ui/screens/home/HomeScreen.kt | 7 +- .../ui/screens/libraries/LibrariesScreen.kt | 32 +++ .../screens/detail/MobileDetailActionsTest.kt | 122 ++++++++++- .../siloserver/silo/tv/di/AndroidTvModule.kt | 5 +- .../silo/tv/ui/components/TvFocusMarquee.kt | 56 ++++- .../tv/ui/components/TvMarqueeTranslation.kt | 203 ++++++++++++++++++ .../tv/ui/components/TvSkylineSectionFeed.kt | 7 + .../detail/TvDescriptionTranslationSection.kt | 23 ++ .../ui/screens/detail/TvDetailEpisodeRail.kt | 13 +- .../silo/tv/ui/screens/detail/TvDetailHero.kt | 13 ++ .../ui/screens/detail/TvItemDetailScreen.kt | 31 +++ .../screens/detail/TvItemDetailViewModel.kt | 126 +++++++++-- .../silo/tv/ui/screens/home/TvHomeScreen.kt | 3 + .../library/TvLibraryDetailViewModel.kt | 40 ++++ .../TvRecommendationsScreen.kt | 1 + .../ui/components/TvMarqueeTranslationTest.kt | 161 ++++++++++++++ .../detail/TvNextUpSelectionHandoffTest.kt | 80 ++++++- .../siloserver/silo/di/RepositoryModule.kt | 11 +- .../settings/CatalogLanguageRevision.kt | 36 ++++ .../settings/ProfileSettingsController.kt | 40 +++- .../DescriptionTranslationController.kt | 48 ++++- .../silo/model/catalog/CatalogModels.kt | 19 +- .../silo/model/catalog/MachineTranslation.kt | 43 ++++ .../silo/model/section/SectionModels.kt | 6 +- .../apiv2/CatalogDetailReadV2Models.kt | 8 +- .../silo/repository/CatalogRepository.kt | 120 ++++++++--- .../silo/repository/port/CatalogCachePort.kt | 6 + .../silo/viewmodel/HomeViewModel.kt | 17 ++ .../viewmodel/RecommendationsViewModel.kt | 10 +- .../settings/ProfileSettingsControllerTest.kt | 104 ++++++++- .../DescriptionTranslationControllerTest.kt | 155 ++++++++++++- .../catalog/EpisodeTranslationMergeTest.kt | 50 +++++ .../apiv2/MachineTranslationDecodingTest.kt | 107 +++++++++ .../CatalogRepositoryDetailCacheTest.kt | 157 +++++++++++++- .../silo/viewmodel/HomeScreenV2Test.kt | 30 ++- 45 files changed, 2133 insertions(+), 109 deletions(-) create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/MachineTranslatedLabel.kt create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslation.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslationTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/CatalogLanguageRevision.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/MachineTranslation.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/EpisodeTranslationMergeTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/MachineTranslationDecodingTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/CatalogCacheDao.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/CatalogCacheDao.kt index 0ec9ac87e1..df4a874be1 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/CatalogCacheDao.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/dao/CatalogCacheDao.kt @@ -9,6 +9,12 @@ import org.siloserver.silo.common.data.db.entity.CatalogCacheEntity @Dao interface CatalogCacheDao { + @Query(""" + DELETE FROM catalog_cache WHERE serverId = :serverId AND profileId = :profileId + AND (cacheKey LIKE 'item-detail:%' OR cacheKey LIKE 'seasons:%' OR cacheKey LIKE 'episodes:%') + """) + suspend fun deleteDetailMetadata(serverId: String, profileId: String) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(row: CatalogCacheEntity) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepository.kt index 962a022a7b..d5b3eccdb4 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepository.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.common.data.repository +import org.siloserver.silo.repository.port.CatalogCacheScope + import org.siloserver.silo.common.data.db.SiloDatabase import org.siloserver.silo.common.data.db.entity.CatalogCacheEntity import org.siloserver.silo.model.catalog.CatalogResponse @@ -31,6 +33,15 @@ class RoomCatalogCacheRepository( private val dao = db.catalogCacheDao() private val json = Json { ignoreUnknownKeys = true } + override suspend fun invalidateDetailMetadata(scope: CatalogCacheScope?) { + val owner = scope ?: run { + val snapshot = snapshotProvider() ?: return + val profile = snapshot.profileId ?: return + CatalogCacheScope(snapshot.serverId, profile) + } + dao.deleteDetailMetadata(owner.serverId, owner.profileId) + } + override suspend fun cacheLibraries(libraries: List<UserLibrary>) = cacheLibraries(libraries, currentWriteLease()) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/MachineTranslatedLabel.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/MachineTranslatedLabel.kt new file mode 100644 index 0000000000..715db81655 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/ui/components/MachineTranslatedLabel.kt @@ -0,0 +1,59 @@ +package org.siloserver.silo.common.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** The words every machine-translation marker uses, shown or spoken. */ +const val MachineTranslatedDescription = "Translated by AI" + +/** + * Marks text the server reports as machine-translated (the + * `machine_translated_fields` of a detail, season, episode or card). The + * compact form is an icon for dense rows such as episode cards; it keeps the + * words for accessibility services. Never focusable, so it adds no D-pad stop. + */ +@Composable +fun MachineTranslatedLabel( + color: Color, + modifier: Modifier = Modifier, + compact: Boolean = false, + fontSize: TextUnit = 12.sp, + iconSize: Dp = 12.dp, +) { + Row( + modifier = modifier.clearAndSetSemantics { contentDescription = MachineTranslatedDescription }, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + imageVector = Icons.Outlined.Translate, + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier.size(iconSize), + ) + if (!compact) { + BasicText( + text = MachineTranslatedDescription, + style = TextStyle(color = color, fontSize = fontSize), + maxLines = 1, + ) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepositoryTest.kt index b94da999eb..561d80bd28 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/repository/RoomCatalogCacheRepositoryTest.kt @@ -144,4 +144,61 @@ class RoomCatalogCacheRepositoryTest { assertEquals(0L, oldProfileGeneration) assertNull(guardedRepo.getCachedLibraries()) } + @Test + fun languageInvalidationPurgesOnlyCurrentScopesDetailMetadata() = runTest { + val owner = scope!! + val detail = org.siloserver.silo.model.catalog.ItemDetail(contentId = "c1", type = "series", title = "Old language") + val seasons = org.siloserver.silo.model.catalog.SeasonsResponse() + val episodes = org.siloserver.silo.model.catalog.EpisodesResponse() + suspend fun populate() { + repo.cacheItemDetail("c1", detail) + repo.cacheSeasons("c1", seasons) + repo.cacheEpisodes("c1", 1, episodes) + repo.cacheLibraries(listOf(UserLibrary(id = 1, name = "Shows", type = "series"))) + } + populate() + scope = owner.copy(profileId = "p2") + populate() + scope = owner + repo.invalidateDetailMetadata() + assertNull(repo.getCachedItemDetail("c1")) + assertNull(repo.getCachedSeasons("c1")) + assertNull(repo.getCachedEpisodes("c1", 1)) + assertEquals("Shows", repo.getCachedLibraries()?.single()?.name) + scope = owner.copy(profileId = "p2") + assertEquals(detail, repo.getCachedItemDetail("c1")) + assertEquals(seasons, repo.getCachedSeasons("c1")) + assertEquals(episodes, repo.getCachedEpisodes("c1", 1)) + } + + @Test + fun languagePurgeAfterProfileSwitchDeletesTheWriterAndRetainsTheActiveProfilesMetadata() = runTest { + val owner = scope!! + val detail = org.siloserver.silo.model.catalog.ItemDetail(contentId = "c1", type = "series", title = "Cached") + val seasons = org.siloserver.silo.model.catalog.SeasonsResponse() + val episodes = org.siloserver.silo.model.catalog.EpisodesResponse() + suspend fun populate() { + repo.cacheItemDetail("c1", detail) + repo.cacheSeasons("c1", seasons) + repo.cacheEpisodes("c1", 1, episodes) + } + populate() + val language = org.siloserver.silo.domain.settings.CatalogLanguageRevision() + language.changed(org.siloserver.silo.repository.port.CatalogCacheScope(owner.serverId, owner.profileId!!)) + scope = owner.copy(profileId = "p2") + populate() + val client = io.ktor.client.HttpClient(io.ktor.client.engine.mock.MockEngine { error("No live request expected") }) + val catalog = org.siloserver.silo.repository.CatalogRepository( + org.siloserver.silo.network.api.CatalogApi(client), repo, catalogLanguage = language, + ) + catalog.invalidateLocalizedMetadata() + assertEquals(detail, repo.getCachedItemDetail("c1")) + assertEquals(seasons, repo.getCachedSeasons("c1")) + assertEquals(episodes, repo.getCachedEpisodes("c1", 1)) + scope = owner + assertNull(catalog.getCachedItemDetail("c1")) + assertNull(catalog.getCachedSeasons("c1")) + assertNull(catalog.getCachedEpisodes("c1", 1)) + } + } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index d5ff4f7bef..b321d3886f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -449,7 +449,7 @@ val androidModule = module { shuffleFeatureStore = get(), ) } - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get(), catalogLanguage = get()) } viewModel { MainHeaderViewModel(get()) } viewModel { LibrariesViewModel( @@ -457,9 +457,10 @@ val androidModule = module { getOrNull<org.siloserver.silo.repository.port.UserItemStatePort>() ?: org.siloserver.silo.repository.port.NoOpUserItemStatePort, get(), get<org.siloserver.silo.common.settings.BrowsePrefsStore>(), + catalogLanguage = get(), ) } - viewModel { RecommendationsViewModel(get()) } + viewModel { RecommendationsViewModel(get(), catalogLanguage = get()) } viewModel { SearchViewModel(get()) } single { org.siloserver.silo.common.settings.BrowsePrefsStore( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DescriptionTranslationSection.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DescriptionTranslationSection.kt index c372936f84..a483f8f122 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DescriptionTranslationSection.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DescriptionTranslationSection.kt @@ -10,7 +10,9 @@ import androidx.compose.material3.TextButton 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 +import org.siloserver.silo.common.ui.components.MachineTranslatedLabel import org.siloserver.silo.metadata.DescriptionTranslationPhase /** @@ -49,3 +51,21 @@ fun DescriptionTranslationSection( } } } + +/** + * "Translated by AI" under an overview the server reports as machine-translated. + * [compact] is the icon-only form for dense rows such as episode cards. + */ +@Composable +fun DetailMachineTranslatedLabel( + modifier: Modifier = Modifier, + compact: Boolean = false, + iconSize: Dp = 12.dp, +) { + MachineTranslatedLabel( + color = DetailTertiaryText, + compact = compact, + iconSize = iconSize, + modifier = modifier, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt index e8b8fedbeb..00efc7b6af 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt @@ -58,6 +58,7 @@ import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.EpisodeListItem +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview import org.siloserver.silo.model.settings.EpisodeSpoilers import kotlin.math.abs @@ -178,6 +179,8 @@ private fun EpisodeRailCard( val stillShape = RoundedCornerShape(8.dp) val spoilers = LocalEpisodeSpoilerPrefs.current val unwatched = EpisodeSpoilers.isUnwatched(episode.userData) + val visibleOverview = episode.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } val progress = episodeProgressFraction( positionSeconds = episode.userData?.positionSeconds, durationSeconds = episode.userData?.durationSeconds, @@ -284,6 +287,11 @@ private fun EpisodeRailCard( letterSpacing = 1.sp, color = Color.White.copy(alpha = 0.55f), ) + if (showsEpisodeDetails && visibleOverview != null && + hasMachineTranslatedOverview(episode.machineTranslatedFields) + ) { + DetailMachineTranslatedLabel(compact = true, iconSize = 11.dp) + } if (isCurrent) { Text( text = "NOW VIEWING", @@ -322,11 +330,9 @@ private fun EpisodeRailCard( } // Spoiler protection omits the text; the fixed-height column // keeps the card the same size. - val overview = episode.overview - ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } - if (overview != null) { + if (visibleOverview != null) { Text( - text = overview, + text = visibleOverview, style = MaterialTheme.typography.bodySmall, color = DetailSecondaryText, maxLines = 3, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index 13f060f884..c58a85f0ed 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -93,6 +93,8 @@ import org.siloserver.silo.network.AccessChangeSignals import org.siloserver.silo.metadata.DescriptionTranslationPhase import org.siloserver.silo.model.feature.MetadataAiFeatureStore import org.siloserver.silo.model.metadata.MetadataAiOnView +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview +import org.siloserver.silo.model.catalog.pendingEpisodeTranslationLanguage internal data class SeriesDetailRedirect( val seriesContentId: String, @@ -410,6 +412,22 @@ fun ItemDetailScreen( viewModel.translateDescription(auto = true) } } + // The series page lists the selected season's episodes inline; + // the season's job fills any episode description still missing. + val pendingEpisodeLanguage = state.episodes + .filter { it.seasonNumber == state.selectedSeasonNumber } + .pendingEpisodeTranslationLanguage() + LaunchedEffect( + detail.contentId, + state.selectedSeasonNumber, + state.seasons.isNotEmpty(), + pendingEpisodeLanguage, + metadataAiStatus.onView, + ) { + if (metadataAiStatus.onView == MetadataAiOnView.Auto && pendingEpisodeLanguage != null) { + viewModel.translateShownSeasonEpisodes() + } + } val translationSlot: (@Composable () -> Unit)? = if (translationEligible && (metadataAiStatus.onView == MetadataAiOnView.Button || translationPhase != DescriptionTranslationPhase.Idle) @@ -420,6 +438,10 @@ fun ItemDetailScreen( onTranslate = { viewModel.translateDescription() }, ) } + } else if (hasMachineTranslatedOverview(detail.machineTranslatedFields) && + !detail.overview.isNullOrBlank() + ) { + { DetailMachineTranslatedLabel() } } else { null } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt index f71a2bbb3d..d579882786 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.siloserver.silo.common.downloads.DownloadEnqueuer +import org.siloserver.silo.model.catalog.withEpisodeDescription +import org.siloserver.silo.model.catalog.withTranslatedDescription import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.FileVersion import org.siloserver.silo.model.catalog.ItemDetail @@ -24,6 +26,7 @@ import org.siloserver.silo.network.isAccessRefusal import org.siloserver.silo.model.catalog.isBookLikeItemType import org.siloserver.silo.metadata.DescriptionTranslationController import org.siloserver.silo.metadata.DescriptionTranslationPhase +import org.siloserver.silo.model.catalog.pendingEpisodeTranslationLanguage import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.repository.MetadataAiRepository import org.siloserver.silo.repository.DownloadsRepository @@ -49,6 +52,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.ensureActive /** * UI state for the item detail screen. @@ -212,6 +216,16 @@ class ItemDetailViewModel( ) val translationPhase: StateFlow<DescriptionTranslationPhase> = descriptionTranslation.phase + // A season's translation job also fills its episodes' descriptions. Phone + // has no season page — the series page lists the selected season's + // episodes inline — so this page starts that job when an episode row + // reports a missing description. Its own controller keeps the series + // overview's single-flight latch independent. + private val episodeTranslation = DescriptionTranslationController( + repository = metadataAiRepository, + delayMs = { kotlinx.coroutines.delay(it) }, + ) + init { viewModelScope.launch { var previous = personalDataRepository.memberships.generation.value @@ -712,10 +726,11 @@ class ItemDetailViewModel( for (season in accumulator.remainingSeasons(seasons)) { if (!routeActive) return@launch + val descriptionRevision = episodeDescriptionRevisions[season.seasonNumber] ?: 0L when (val r = catalogRepository.getEpisodes(seriesId, season.seasonNumber, libraryId = libraryId)) { is ApiResult.Success -> { val episodes = withLocalProgress(r.data.episodes) - cacheEpisodes(season.seasonNumber, episodes) + cacheEpisodes(season.seasonNumber, episodes, descriptionRevision) accumulator.recordSeason(season.seasonNumber, episodes) _uiState.update { it.copy( @@ -858,11 +873,15 @@ class ItemDetailViewModel( catalogRepository.getItemDetail(contentId, libraryId = libraryId) } when (result) { - is ApiResult.Success -> _uiState.update { state -> - if (state.selectedEpisodeContentId != contentId) state else state.copy( - selectedEpisodeDetail = withLocalProgress(result.data), - isLoadingSelectedEpisodeDetail = false, - ) + is ApiResult.Success -> { + val refreshed = withLocalProgress(result.data) + _uiState.update { state -> + if (state.selectedEpisodeContentId != contentId) state else state.copy( + selectedEpisodeDetail = state.episodes.firstOrNull { it.contentId == contentId } + ?.let(refreshed::withEpisodeDescription) ?: refreshed, + isLoadingSelectedEpisodeDetail = false, + ) + } } else -> _uiState.update { state -> if (state.selectedEpisodeContentId != contentId) state else state.copy( @@ -911,6 +930,7 @@ class ItemDetailViewModel( preferPrefetched: Boolean = false, fresh: Boolean = false, ) { + episodeRebuildGenerations[seasonNumber] = (episodeRebuildGenerations[seasonNumber] ?: 0L) + 1 episodeLoadJob?.cancel() val cachedEpisodes = _uiState.value.episodesBySeason[seasonNumber] if (!forceRefresh && cachedEpisodes != null) { @@ -933,6 +953,7 @@ class ItemDetailViewModel( } return } + val descriptionRevision = episodeDescriptionRevisions[seasonNumber] ?: 0L episodeLoadJob = viewModelScope.launch { _uiState.update { it.copy( @@ -953,12 +974,17 @@ class ItemDetailViewModel( is ApiResult.Success -> { val episodes = withLocalProgress(result.data.episodes) loadedSeasonNumber = seasonNumber + episodeRebuildGenerations[seasonNumber] = (episodeRebuildGenerations[seasonNumber] ?: 0L) + 1 _uiState.update { - val cache = it.episodesBySeason + (seasonNumber to episodes) + val current = it.episodesBySeason[seasonNumber].orEmpty().associateBy { row -> row.contentId } + val rows = if (descriptionRevision != (episodeDescriptionRevisions[seasonNumber] ?: 0L)) { + episodes.map { row -> current[row.contentId]?.let(row::withTranslatedDescription) ?: row } + } else episodes + val cache = it.episodesBySeason + (seasonNumber to rows) it.copy( isLoadingEpisodes = false, episodesBySeason = cache, - episodes = if (it.selectedSeasonNumber == seasonNumber) episodes else it.episodes, + episodes = if (it.selectedSeasonNumber == seasonNumber) rows else it.episodes, ) } ensureSeriesEpisodeSelection(episodes) @@ -989,15 +1015,39 @@ class ItemDetailViewModel( } } + private val episodeDescriptionRevisions = mutableMapOf<Int, Long>() + private val episodeRebuildGenerations = mutableMapOf<Int, Long>() + private fun cacheEpisodes( seasonNumber: Int, episodes: List<EpisodeListItem>, + descriptionRevision: Long, ) { + episodeRebuildGenerations[seasonNumber] = (episodeRebuildGenerations[seasonNumber] ?: 0L) + 1 _uiState.update { - val cache = it.episodesBySeason + (seasonNumber to episodes) + val current = it.episodesBySeason[seasonNumber].orEmpty().associateBy { row -> row.contentId } + val rows = if (descriptionRevision != (episodeDescriptionRevisions[seasonNumber] ?: 0L)) { + episodes.map { row -> current[row.contentId]?.let(row::withTranslatedDescription) ?: row } + } else episodes + val cache = it.episodesBySeason + (seasonNumber to rows) it.copy( episodesBySeason = cache, - episodes = if (it.selectedSeasonNumber == seasonNumber) episodes else it.episodes, + episodes = if (it.selectedSeasonNumber == seasonNumber) rows else it.episodes, + ) + } + } + + private fun cacheTranslatedEpisodes(seasonNumber: Int, fresh: List<EpisodeListItem>) { + episodeDescriptionRevisions[seasonNumber] = (episodeDescriptionRevisions[seasonNumber] ?: 0L) + 1 + _uiState.update { state -> + val current = state.episodesBySeason[seasonNumber].orEmpty().associateBy { it.contentId } + val merged = fresh.map { row -> current[row.contentId]?.withTranslatedDescription(row) ?: row } + val selected = merged.firstOrNull { it.contentId == state.selectedEpisodeContentId } + state.copy( + episodesBySeason = state.episodesBySeason + (seasonNumber to merged), + episodes = if (state.selectedSeasonNumber == seasonNumber) merged else state.episodes, + selectedEpisodeDetail = selected?.let { state.selectedEpisodeDetail?.withEpisodeDescription(it) } + ?: state.selectedEpisodeDetail, ) } } @@ -1247,6 +1297,59 @@ class ItemDetailViewModel( } } + /** + * `auto` on-view mode for the inline episode list: start the shown season's + * translation once per (season, language) when its episode rows report a + * missing description, then re-read the episodes until none does. + */ + fun translateShownSeasonEpisodes() { + val state = _uiState.value + val detail = state.detail ?: return + val seriesId = if (detail.type == "series") detail.contentId else detail.seriesId ?: return + val seasonNumber = state.selectedSeasonNumber + val season = state.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val target = state.episodes + .filter { it.seasonNumber == seasonNumber } + .pendingEpisodeTranslationLanguage() ?: return + // A season switched to while another season's job polls is retried + // when that job ends (below), rather than latched as already fired. + if (!episodeTranslation.claimAutoFire(season.contentId, target)) return + episodeTranslation.resetFailure() + viewModelScope.launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + episodeTranslation.translate( + contentId = season.contentId, + targetLanguage = target, + refetchPendingLanguage = { owner -> + val rebuildGeneration = episodeRebuildGenerations[seasonNumber] ?: 0L + when ( + val result = catalogRepository.getEpisodes( + seriesId, + seasonNumber, + libraryId = libraryId, + fresh = true, + ) + ) { + is ApiResult.Success -> if (metadataAiRepository.isCurrent(owner)) { + val episodes = withLocalProgress(result.data.episodes) + kotlinx.coroutines.currentCoroutineContext().ensureActive() + if (metadataAiRepository.isCurrent(owner) && + rebuildGeneration == (episodeRebuildGenerations[seasonNumber] ?: 0L) + ) { + cacheTranslatedEpisodes(seasonNumber, episodes) + episodes.pendingEpisodeTranslationLanguage() + } else target + } else { + target + } + else -> target // transient refetch failure: keep polling + } + }, + onTranslated = {}, + ) + if (episodeTranslation.takeDeferredAuto()) translateShownSeasonEpisodes() + } + } + /** * Toggles the watchlist state for this item. */ @@ -1416,7 +1519,7 @@ class ItemDetailViewModel( failedEpisodeWatchedGenerations[it.contentId] == latest) } .associateBy { it.contentId } - fun List<EpisodeListItem>.restored() = map { previousById[it.contentId] ?: it } + fun List<EpisodeListItem>.restored() = map { previousById[it.contentId]?.withTranslatedDescription(it) ?: it } _uiState.update { state -> val episodesBySeason = if (previousEpisodes != null) { state.episodesBySeason.mapValues { (number, episodes) -> @@ -1448,15 +1551,16 @@ class ItemDetailViewModel( * stale so selecting them later loads fresh checkmarks. */ private fun refreshAfterSeasonWatchedChange(seriesId: String, changedSeasonNumber: Int?) { - _uiState.update { state -> - state.copy( - episodesBySeason = state.episodesBySeason.filterKeys { number -> - // The loaded page is the fallback if the selected season's reload fails. - number == state.selectedSeasonNumber || number == loadedSeasonNumber || - (changedSeasonNumber != null && number != changedSeasonNumber) - }, - ) + val state = _uiState.value + val retained = state.episodesBySeason.filterKeys { number -> + // The loaded page is the fallback if the selected season's reload fails. + number == state.selectedSeasonNumber || number == loadedSeasonNumber || + (changedSeasonNumber != null && number != changedSeasonNumber) + } + (state.episodesBySeason.keys - retained.keys).forEach { number -> + episodeRebuildGenerations[number] = (episodeRebuildGenerations[number] ?: 0L) + 1 } + _uiState.update { it.copy(episodesBySeason = retained) } // Re-reads the series hero, the season list, and the visible episodes. quietRefresh(afterWatchedChange = true) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt index dbde09a0e6..e8e49d5750 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt @@ -33,6 +33,7 @@ import org.siloserver.silo.android.ui.theme.SiloBackground import org.siloserver.silo.android.ui.util.rememberDominantColor import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.model.catalog.EpisodeListItem +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview import org.siloserver.silo.model.catalog.ItemDetail import org.siloserver.silo.model.catalog.ItemExtra import org.siloserver.silo.model.catalog.Season @@ -129,6 +130,19 @@ fun SeriesDetailContent( ?.takeIf { it.isNotBlank() } ?: selectedEpisode?.overview?.takeIf { it.isNotBlank() } } + // Mark the episode overview by the same source the text above came from. + val selectedEpisodeOverviewMachineTranslated = when { + !loadedSelectedEpisodeDetail?.overview.isNullOrBlank() -> + hasMachineTranslatedOverview(loadedSelectedEpisodeDetail?.machineTranslatedFields) + selectedEpisodeOverview != null -> hasMachineTranslatedOverview(selectedEpisode?.machineTranslatedFields) + else -> false + } + val episodeTranslationLabel: (@Composable () -> Unit)? = + if (!hidesSelectedEpisodeOverview && selectedEpisodeOverviewMachineTranslated) { + { DetailMachineTranslatedLabel() } + } else { + null + } // iOS keeps the series cast credit stable while the selected episode's // overview and playback options change. Reusing the series credit avoids // replacing it with a skeleton (and repainting different names) on every @@ -293,7 +307,7 @@ fun SeriesDetailContent( directorText = fixedSeriesCredit, isCreditLoading = false, reserveCreditSpace = !isExpandedDetailLayout && usesEpisodeEditorial, - translation = if (isExpandedDetailLayout || !usesEpisodeEditorial) translation else null, + translation = if (isExpandedDetailLayout || !usesEpisodeEditorial) translation else episodeTranslationLabel, belowOverview = if (isExpandedDetailLayout) null else playbackSelector, expandedBelowOverview = { episodeSection(true) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt index 7fb7ad6e33..d916c964ac 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt @@ -1,5 +1,6 @@ package org.siloserver.silo.android.ui.screens.home +import org.siloserver.silo.domain.settings.CatalogLanguageRevision import org.siloserver.silo.android.ui.components.SiloDropdownMenuItem import org.siloserver.silo.android.ui.components.SiloDropdownMenu import androidx.compose.foundation.background @@ -155,6 +156,8 @@ fun HomeScreen( } val context = LocalContext.current val catalogRepository: CatalogRepository = koinInject() + val catalogLanguage: CatalogLanguageRevision = koinInject() + val languageRevision by catalogLanguage.revision.collectAsState() val heroHandoff = LocalHeroSourceHandoff.current val continueWatchingWarmTargets = remember(regularSections) { regularSections @@ -182,10 +185,10 @@ fun HomeScreen( .take(4) .toList() } - var warmedSeriesArtwork by remember(activeServerId, profileScopeId) { + var warmedSeriesArtwork by remember(activeServerId, profileScopeId, languageRevision) { mutableStateOf<Map<String, WarmedSeriesArtwork>>(emptyMap()) } - LaunchedEffect(activeServerId, profileScopeId, continueWatchingWarmTargets) { + LaunchedEffect(activeServerId, profileScopeId, languageRevision, continueWatchingWarmTargets) { // Warm each Continue Watching destination in row order. Within one // title, its parent detail, exact episode and selector data run in // parallel. CatalogRepository keeps these calls alive/single-flight if diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt index a048479043..9178f92546 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/libraries/LibrariesScreen.kt @@ -142,6 +142,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.Job @@ -220,6 +221,7 @@ class LibrariesViewModel( org.siloserver.silo.repository.port.NoOpUserItemStatePort, private val playerSettingsStore: org.siloserver.silo.common.settings.PlayerSettingsStore? = null, private val browsePrefs: BrowsePrefsStore? = null, + catalogLanguage: org.siloserver.silo.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { /** Access changes this ViewModel has applied, kept while its screen is away. */ val accessChanges = org.siloserver.silo.network.AccessChangeCursor() @@ -264,6 +266,11 @@ class LibrariesViewModel( } } ?.launchIn(viewModelScope) + // Titles and descriptions are localized per metadata language. + catalogLanguage?.revision + ?.drop(1) + ?.onEach { refreshAfterLanguageChange() } + ?.launchIn(viewModelScope) refresh() } @@ -398,6 +405,31 @@ class LibrariesViewModel( } } + private fun refreshAfterLanguageChange() { + recommendedJob?.cancel() + recommendedLoadedLibraryId = null + browseLoadedLibraryId = null + collectionsLoadedLibraryId = null + ++recommendedRequestGeneration + ++catalogRequestGeneration + ++catalogQueryGeneration + ++collectionsRequestGeneration + catalogContinuation = null + _uiState.update { + it.copy( + sections = emptyList(), + catalogItems = emptyList(), + collections = emptyList(), + availableFilters = null, + isLoadingSections = false, + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + isLoadingCollections = false, + ) + } + refresh() + } + /** * The server reported an access change: the library list, sections, * titles, facets, and collections may all differ under the new policy. diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt index f865415da5..3c87d443a8 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/MobileDetailActionsTest.kt @@ -18,6 +18,9 @@ import org.siloserver.silo.repository.port.PersonalWrite import org.siloserver.silo.repository.port.PersonalWriteHandle import org.siloserver.silo.repository.port.UserItemStatePort import org.siloserver.silo.network.api.CatalogApi +import org.siloserver.silo.network.api.MetadataAiApi +import org.siloserver.silo.model.metadata.MetadataAiStatus +import org.siloserver.silo.model.metadata.MetadataTranslationJob import org.siloserver.silo.network.api.DownloadsApi import org.siloserver.silo.network.api.EbookReaderApi import org.siloserver.silo.network.api.PersonalDataApi @@ -60,6 +63,122 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class MobileDetailActionsTest { + @Test + fun delayedTranslationDoesNotRestoreANewerReloadsRemovedEpisodes() = runItemDetailTest { + assertTranslationPreservesReload(reloadAdmittedFirst = false) + } + + @Test + fun translationStartedDuringReloadPreservesThePublishedReload() = runItemDetailTest { + assertTranslationPreservesReload(reloadAdmittedFirst = true) + } + + @Test + fun delayedTranslationDoesNotRepopulateASeasonInvalidatedAfterAWatchedWrite() = runItemDetailTest { + val translationRead = CompletableDeferred<Unit>() + var seasonOneReads = 0 + val client = dummyHttpClient(StandardTestDispatcher(testScheduler)) { request -> + when { + request.url.encodedPath.endsWith("/seasons/1/episodes") -> { + if (++seasonOneReads == 1) { + translationRead.await() + respond( + """{"items":[{"content_id":"season-1-episode-1","season_number":1,"episode_number":1,"user_data":{"played":false}}]}""", + HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else respond("", HttpStatusCode.ServiceUnavailable) + } + request.url.encodedPath.endsWith("/seasons/2/episodes") -> respond( + """{"items":[{"content_id":"season-2-episode-1","season_number":2,"episode_number":1}]}""", + HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond("{}") + } + } + val viewModel = itemDetailViewModel( + RecordingPersonalDataRepository(mutableListOf(), engineDispatcher = StandardTestDispatcher(testScheduler)), + catalogRepository = CatalogRepository(CatalogApi(client)), + metadataApi = acceptingTranslationApi(), + ) + viewModel.seedSeriesDetail(listOf(EpisodeListItem( + "season-1-episode-1", 1, 1, pendingTranslationLanguage = "de", + ))) + viewModel.translateShownSeasonEpisodes() + advanceUntilIdle() + assertEquals(1, seasonOneReads) + viewModel.selectSeason(2) + advanceUntilIdle() + viewModel.setSeasonWatched(viewModel.uiState.value.seasons.first(), true) + advanceUntilIdle() + assertFalse(1 in viewModel.uiState.value.episodesBySeason) + + translationRead.complete(Unit) + advanceUntilIdle() + assertFalse(1 in viewModel.uiState.value.episodesBySeason) + assertEquals(2, viewModel.uiState.value.selectedSeasonNumber) + } + + private fun acceptingTranslationApi() = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success(MetadataAiStatus()) + override suspend fun translateDescription( + contentId: String, + targetLanguage: String, + scope: AuthScopeSnapshot?, + ) = ApiResult.Success(MetadataTranslationJob("job", "season", contentId, targetLanguage, "pending")) + } + + private suspend fun kotlinx.coroutines.test.TestScope.assertTranslationPreservesReload(reloadAdmittedFirst: Boolean) { + val translationRead = CompletableDeferred<Unit>() + val reloadRead = CompletableDeferred<Unit>() + val translationReadNumber = if (reloadAdmittedFirst) 2 else 1 + var episodeReads = 0 + val client = dummyHttpClient(StandardTestDispatcher(testScheduler)) { request -> + when { + request.url.encodedPath.endsWith("/seasons/1/episodes") -> { + val admitted = ++episodeReads + val response = if (admitted == translationReadNumber) { + translationRead.await() + """{"items":[{"content_id":"season-1-episode-1","season_number":1,"episode_number":1,"overview":"Older"},{"content_id":"removed-episode","season_number":1,"episode_number":2}]}""" + } else { + reloadRead.await() + """{"items":[{"content_id":"season-1-episode-1","season_number":1,"episode_number":1,"overview":"Newer"}]}""" + } + respond(response, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + else -> respond("{}") + } + } + val viewModel = itemDetailViewModel( + RecordingPersonalDataRepository(mutableListOf(), engineDispatcher = StandardTestDispatcher(testScheduler)), + catalogRepository = CatalogRepository(CatalogApi(client)), + metadataApi = acceptingTranslationApi(), + ) + viewModel.seedSeriesDetail(listOf(EpisodeListItem( + "season-1-episode-1", 1, 1, pendingTranslationLanguage = "de", + ))) + if (reloadAdmittedFirst) { + viewModel.refreshOnReturn() + advanceUntilIdle() + assertEquals(1, episodeReads) + } + viewModel.translateShownSeasonEpisodes() + advanceUntilIdle() + assertEquals(translationReadNumber, episodeReads) + + if (!reloadAdmittedFirst) { + viewModel.refreshOnReturn() + advanceUntilIdle() + } + reloadRead.complete(Unit) + advanceUntilIdle() + assertEquals("Newer", viewModel.uiState.value.episodes.single().overview) + translationRead.complete(Unit) + advanceUntilIdle() + assertEquals(listOf("season-1-episode-1"), viewModel.uiState.value.episodes.map { it.contentId }) + assertEquals("Newer", viewModel.uiState.value.episodes.single().overview) + assertEquals(viewModel.uiState.value.episodes, viewModel.uiState.value.episodesBySeason[1]) + } + @Test fun watchedTogglePersistsOptimisticStateOnSuccess() = runItemDetailTest { val repository = RecordingPersonalDataRepository( @@ -733,6 +852,7 @@ class MobileDetailActionsTest { catalogRepository: CatalogRepository? = null, contentId: String? = null, downloads: List<DownloadRecord> = emptyList(), + metadataApi: MetadataAiApi? = null, ): ItemDetailViewModel { val dispatcher = StandardTestDispatcher(testScheduler) return ItemDetailViewModel( @@ -743,7 +863,7 @@ class MobileDetailActionsTest { ebookReaderRepository = dummyEbookReaderRepository(dispatcher), recommendationRepository = RecommendationRepository(RecommendationApi(dummyHttpClient(dispatcher))), metadataAiRepository = org.siloserver.silo.repository.MetadataAiRepository( - org.siloserver.silo.network.api.DefaultMetadataAiApi(dummyHttpClient(dispatcher), gate = org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted), + metadataApi ?: org.siloserver.silo.network.api.DefaultMetadataAiApi(dummyHttpClient(dispatcher), gate = org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted), ), savedStateHandle = SavedStateHandle(contentId?.let { mapOf("contentId" to it) } ?: emptyMap()), ).also { viewModels += it } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index e1e88762c8..6c6d932cf9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -449,9 +449,9 @@ val androidTvModule = module { } // Content ViewModels - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get(), catalogLanguage = get()) } viewModel { org.siloserver.silo.tv.ui.screens.home.TvUpcomingViewModel(get()) } - viewModel { RecommendationsViewModel(get()) } + viewModel { RecommendationsViewModel(get(), catalogLanguage = get()) } // The Requests page reads the approval queue itself, so the hub doesn't // count it; it loads its view models when it composes. viewModel { params -> @@ -497,6 +497,7 @@ val androidTvModule = module { libraryType = params.get(), mediaScope = params.values.getOrNull(3) as? String, browsePrefs = get(), + catalogLanguage = get(), ) } viewModel { params -> diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt index 0df84bfdf9..1f399b9cef 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvFocusMarquee.kt @@ -1,7 +1,11 @@ package org.siloserver.silo.tv.ui.components import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -37,7 +41,9 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.Text import org.siloserver.silo.common.settings.titleLogoUrl +import org.siloserver.silo.common.ui.components.MachineTranslatedLabel import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview import org.siloserver.silo.tv.ui.theme.SiloOnSurface import org.siloserver.silo.tv.ui.theme.SiloSecondaryText import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs @@ -57,6 +63,9 @@ import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs fun TvFocusMarquee( content: TvMarqueeContent?, detailLine: String? = content?.detailLine, + /** The rested item's description translation; like [detailLine], it + * updates the block in place instead of replaying the crossfade. */ + translation: TvMarqueeTranslation? = null, modifier: Modifier = Modifier, startPadding: androidx.compose.ui.unit.Dp = 44.dp, topPadding: androidx.compose.ui.unit.Dp = 0.dp, @@ -113,13 +122,19 @@ fun TvFocusMarquee( value.underPrefs(spoilerPrefs) }, detailLine = detailLine.takeIf { value.id == content?.id }, + translation = translation.takeIf { it?.contentId == value.contentId }, footer = footer, ) } } } } else if (content != null) { - TvMarqueeBlock(content = content, detailLine = detailLine, footer = footer) + TvMarqueeBlock( + content = content, + detailLine = detailLine, + translation = translation.takeIf { it?.contentId == content.contentId }, + footer = footer, + ) } } } @@ -128,6 +143,7 @@ fun TvFocusMarquee( private fun TvMarqueeBlock( content: TvMarqueeContent, detailLine: String?, + translation: TvMarqueeTranslation? = null, footer: (@Composable (TvMarqueeContent) -> Unit)? = null, ) { // tvOS parity (TVFocusMarquee): when the text-fallback title wraps to two @@ -203,8 +219,17 @@ private fun TvMarqueeBlock( ) } + val hidesSynopsis = content.isEpisode && LocalEpisodeSpoilerPrefs.current.hidesOverview( + org.siloserver.silo.model.settings.EpisodeSpoilers.isUnwatched(content.source), + ) + val synopsisText = (translation?.synopsis ?: content.synopsis) + ?.takeIf { it.isNotBlank() && !hidesSynopsis } + val translating = translation?.translating == true + val machineTranslated = !translating && synopsisText != null && + (translation?.machineTranslated ?: hasMachineTranslatedOverview(content.source.machineTranslatedFields)) + // Badge + meta line. - if (content.badges.isNotEmpty() || content.metaParts.isNotEmpty()) { + if (content.badges.isNotEmpty() || content.metaParts.isNotEmpty() || machineTranslated) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -223,13 +248,34 @@ private fun TvMarqueeBlock( spacing = MarqueeMetaGap, ) } + // The block has no spare row, so the machine-translation + // marker is the compact icon at the end of the meta line. + if (machineTranslated) { + MachineTranslatedLabel( + color = SiloSecondaryText, + compact = true, + iconSize = 14.dp, + ) + } } } // Two synopsis lines (one when a text title wraps): the raised // typography floors made the old three-line block tall enough to // climb under the top menu bar, and the bar zone wins. - content.synopsis?.takeIf { it.isNotBlank() }?.let { synopsis -> + synopsisText?.let { synopsis -> + // A running translation pulses the original text until the + // translated description replaces it. + val pulse = if (translating) { + rememberInfiniteTransition(label = "marqueeTranslating").animateFloat( + initialValue = 0.35f, + targetValue = 0.7f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "marqueeTranslatingAlpha", + ).value + } else { + 1f + } Text( text = synopsis, color = SiloSecondaryText, @@ -237,7 +283,9 @@ private fun TvMarqueeBlock( lineHeight = MarqueeSynopsisSize * 1.35f, maxLines = if (titleLineCount > 1) 1 else 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = MarqueeSynopsisMaxWidth), + modifier = Modifier + .widthIn(max = MarqueeSynopsisMaxWidth) + .alpha(pulse), ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslation.kt new file mode 100644 index 0000000000..c1797765b0 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslation.kt @@ -0,0 +1,203 @@ +package org.siloserver.silo.tv.ui.components + +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.siloserver.silo.metadata.DescriptionTranslationController +import org.siloserver.silo.metadata.DescriptionTranslationPhase +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview +import org.siloserver.silo.model.feature.MetadataAiFeatureStore +import org.siloserver.silo.model.metadata.MetadataAiOnView +import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.repository.MetadataAiRepository + +/** + * How the focus marquee shows the rested item's description translation: + * the landed text (if any), whether it is machine-translated, and whether a + * translation of this item is running. Applied only to the item it names, so + * one card's progress never shows on another. + */ +data class TvMarqueeTranslation( + val contentId: String, + val synopsis: String?, + val machineTranslated: Boolean, + val translating: Boolean, +) + +/** + * On-view description translation for the focus marquee, the Android TV + * counterpart of the web Featured hero: in `auto` mode a Featured-section item + * the viewer rests on translates once per (item, language), then its detail is re-read + * until the server reports the description available. The marquee is never + * focusable, so `button` mode leaves the action to the item's detail page. + */ +@Stable +internal class TvMarqueeTranslationState( + private val repository: MetadataAiRepository, + private val scope: CoroutineScope, +) { + private val controller = DescriptionTranslationController( + repository = repository, + delayMs = { delay(it) }, + ) + val phase = controller.phase + val runningContentId = controller.runningContentId + + private data class Landed(val overview: String?, val machineTranslated: Boolean, val pending: String?) + + /** Descriptions re-read while this feed was on screen, by content id. */ + private val landed = mutableStateMapOf<String, Landed>() + private var translationJob: Job? = null + private var focusedContentId: String? = null + private var failedPreflightContentId: String? = null + val working = MutableStateFlow(false) + + fun onFocusChanged(contentId: String?) { + if (focusedContentId != contentId) failedPreflightContentId = null + focusedContentId = contentId + } + + fun close() { + translationJob?.cancel() + } + + fun pendingLanguage(item: SectionItem): String? = + if (item.contentId in landed) landed[item.contentId]?.pending + else item.pendingTranslationLanguage?.takeIf { it.isNotBlank() } + + fun translateOnView(item: SectionItem, onLanded: () -> Unit) { + if (translationJob?.isActive == true) return + val contentId = item.contentId + val hintedTarget = pendingLanguage(item) ?: return + if (failedPreflightContentId == contentId || !controller.shouldAutoFire(contentId, hintedTarget)) return + working.value = true + translationJob = scope.launch { + try { + // Retained cards can still name the previous metadata language. + // Read the current profile's description before queuing work. + val owner = repository.captureAuthority() + val latest = repository.refreshDetail(contentId, owner) + currentCoroutineContext().ensureActive() + if (latest !is ApiResult.Success || !repository.isCurrent(owner)) { + // Retry on refocus without repeatedly reading a failing endpoint + // while focus rests here. No translation claim has been consumed. + failedPreflightContentId = contentId + return@launch + } + fun publish(detail: org.siloserver.silo.model.catalog.ItemDetail) { + landed[contentId] = Landed( + overview = detail.overview?.takeIf { it.isNotBlank() }, + machineTranslated = hasMachineTranslatedOverview(detail.machineTranslatedFields), + pending = detail.pendingTranslationLanguage?.takeIf { it.isNotBlank() }, + ) + } + publish(latest.data) + val target = latest.data.pendingTranslationLanguage?.takeIf { it.isNotBlank() } + if (target == null) { + onLanded() + return@launch + } + if (!controller.claimAutoFire(contentId, target)) return@launch + controller.resetFailure() + controller.translate( + contentId = contentId, + targetLanguage = target, + refetchPendingLanguage = { currentOwner -> + when (val result = repository.refreshDetail(contentId, currentOwner)) { + is ApiResult.Success -> if (repository.isCurrent(currentOwner)) { + currentCoroutineContext().ensureActive() + publish(result.data) + landed[contentId]?.pending + } else target + else -> target + } + }, + onTranslated = { onLanded() }, + ) + } finally { + working.value = false + } + } + } + + fun presentation(item: SectionItem, phase: DescriptionTranslationPhase, runningId: String?): TvMarqueeTranslation { + val update = landed[item.contentId] + return TvMarqueeTranslation( + contentId = item.contentId, + synopsis = update?.overview ?: item.overview?.takeIf { it.isNotBlank() }, + machineTranslated = update?.machineTranslated ?: hasMachineTranslatedOverview(item.machineTranslatedFields), + translating = phase == DescriptionTranslationPhase.Translating && runningId == item.contentId, + ) + } +} + +/** + * Whether the marquee's item came from a Featured section (the section's + * `featured` flag). Only those translate on view, matching the web client, + * which translates the visible Featured slide only; cards in ordinary rows + * keep their text and marker but never start a job when focus rests on them. + * [TvMarqueeContent.id] is `"<section id>#<content id>"`. + */ +internal fun isFeaturedMarqueeContent(content: TvMarqueeContent?, sections: List<ResolvedSection>): Boolean = + content != null && sections.any { section -> + section.featured && content.id == "${section.id}#${content.contentId}" + } + +/** How long focus rests on a card before `auto` mode counts it as viewed. */ +private const val TvMarqueeTranslateDwellMs = 1_000L + +/** + * Drives [TvMarqueeTranslationState] for the item the marquee shows and + * returns what the marquee should render for it. + */ +@Composable +internal fun rememberTvMarqueeTranslation( + item: SectionItem?, + /** Start on-view translation for this item; false only displays it. */ + autoTranslate: Boolean, + /** Called once a translation lands, to re-read the rows that hold the card. */ + onTranslated: () -> Unit = {}, +): TvMarqueeTranslation? { + val latestOnTranslated by rememberUpdatedState(onTranslated) + val repository: MetadataAiRepository = koinInject() + val metadataAiStore: MetadataAiFeatureStore = koinInject() + val status by metadataAiStore.status.collectAsState() + val language: org.siloserver.silo.domain.settings.CatalogLanguageRevision = koinInject() + val languageRevision by language.revision.collectAsState() + val scope = rememberCoroutineScope() + val state = remember(repository, scope, languageRevision) { TvMarqueeTranslationState(repository, scope) } + DisposableEffect(state) { onDispose { state.close() } } + val phase by state.phase.collectAsState() + val runningId by state.runningContentId.collectAsState() + val pending = item?.let(state::pendingLanguage) + val working by state.working.collectAsState() + val busy = working || phase == DescriptionTranslationPhase.Translating + // Re-run when a running translation ends, so an item skipped meanwhile + // gets its turn while the viewer is still resting on it. + LaunchedEffect(state, item?.contentId, pending, status.onView, busy, autoTranslate) { + state.onFocusChanged(item?.contentId) + if (!autoTranslate || item == null || pending == null || status.onView != MetadataAiOnView.Auto) { + return@LaunchedEffect + } + delay(TvMarqueeTranslateDwellMs) + state.translateOnView(item) { latestOnTranslated() } + } + return item?.let { state.presentation(it, phase, runningId) } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt index e1db94b5f6..0a40557ef7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvSkylineSectionFeed.kt @@ -167,6 +167,8 @@ fun TvSkylineSectionFeed( /** A non-null action replaces the card context menu for that long press. */ longClickActionForSection: (ResolvedSection, SectionItem) -> (() -> Unit)? = { _, _ -> null }, onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + /** Re-reads [sections] after the marquee translated a card's description. */ + onMarqueeTranslated: () -> Unit = {}, ) { val rows = remember(sections) { sections.filter { it.items.isNotEmpty() } } val diagnosticsSurface = when { @@ -892,6 +894,11 @@ fun TvSkylineSectionFeed( TvFocusMarquee( content = marquee.content, detailLine = marquee.enrichment?.detailLine, + translation = rememberTvMarqueeTranslation( + item = marquee.content?.source, + autoTranslate = isFeaturedMarqueeContent(marquee.content, rows), + onTranslated = onMarqueeTranslated, + ), startPadding = TvSkyline.safeAreaX, // Keep the marquee out of the top-menu-bar zone: the block is // bottom-anchored and grows upward, and the raised typography diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDescriptionTranslationSection.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDescriptionTranslationSection.kt index 5ee63d6050..73152bfcf1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDescriptionTranslationSection.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDescriptionTranslationSection.kt @@ -11,7 +11,10 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.compose.material3.CircularProgressIndicator +import org.siloserver.silo.common.ui.components.MachineTranslatedLabel import org.siloserver.silo.metadata.DescriptionTranslationPhase +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp /** * TV description-translation affordance (Apple tvOS parity): a focusable @@ -61,3 +64,23 @@ internal fun TvDescriptionTranslationSection( } } } + +/** + * "Translated by AI" under a synopsis the server reports as machine-translated. + * Passive text, never focusable; [compact] is the icon-only form for episode + * cards, which still speaks the words to accessibility services. + */ +@Composable +internal fun TvMachineTranslatedLabel( + modifier: Modifier = Modifier, + compact: Boolean = false, + iconSize: Dp = 14.dp, +) { + MachineTranslatedLabel( + color = Color.White.copy(alpha = 0.6f), + compact = compact, + fontSize = 14.sp, + iconSize = iconSize, + modifier = modifier, + ) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt index 31d5e0fb43..8d6c1537a9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt @@ -70,6 +70,7 @@ import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.EpisodeListItem +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.tv.ui.components.TvMediaCardActions import org.siloserver.silo.tv.ui.components.TvMediaCardContextMenu @@ -363,6 +364,8 @@ private fun TvDetailEpisodeCard( val cardWidth = if (usesSeriesGeometry) tvEpisodeCardWidth() else 230.dp.cardScaled() val spoilers = LocalEpisodeSpoilerPrefs.current val unwatched = EpisodeSpoilers.isUnwatched(episode.userData) + val visibleOverview = episode.overview + ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } val stillHeight = if (usesSeriesGeometry) cardWidth * (9f / 16f) else 130.dp.cardScaled() val eyebrowFontSize = if (usesSeriesGeometry) 11.sp else 14.sp val eyebrowLineHeight = if (usesSeriesGeometry) 14.sp else 17.sp @@ -528,6 +531,12 @@ private fun TvDetailEpisodeCard( } else if (isCurrent) { NowViewingTag() } + if (!hidesEpisodeTitle && caption.showsMetadata && + visibleOverview != null && + hasMachineTranslatedOverview(episode.machineTranslatedFields) + ) { + TvMachineTranslatedLabel(compact = true, iconSize = 12.dp) + } } if (!hidesEpisodeTitle) { @@ -565,9 +574,7 @@ private fun TvDetailEpisodeCard( // identical vertical metrics. Spoiler protection blanks it // the same way. Text( - text = episode.overview - ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } - .orEmpty(), + text = visibleOverview.orEmpty(), fontSize = 16.sp, fontWeight = FontWeight.Normal, color = SiloSecondaryText, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt index 20df8e6dfe..71ffd00859 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt @@ -82,6 +82,8 @@ internal fun TvDetailHero( // Optional description-translation affordance (Apple tvOS parity), // rendered as its own focus stop directly under the synopsis. translation: (@Composable () -> Unit)? = null, + /** The shown synopsis was machine-translated: mark it "Translated by AI". */ + machineTranslated: Boolean = false, /** Grows the hero, not the artwork, for a taller summary strip (request * detail's status and stage track) so the actions stay inside its clip. */ extraHeight: Dp = 0.dp, @@ -164,6 +166,7 @@ internal fun TvDetailHero( collapsedSynopsisLines = 3, compactSeries = compactSeries, translation = translation.takeUnless { compactSeries }, + machineTranslated = machineTranslated && !compactSeries, playbackSummary = playbackSummary, ) @@ -179,6 +182,14 @@ internal fun TvDetailHero( Box(Modifier.weight(1f)) { actions() } translation() } + } else if (compactSeries && machineTranslated) { + // Series reserves fixed editorial slots, so the marker sits + // at the end of the action row where the translate button + // would; it is not focusable and adds no D-pad stop. + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { actions() } + TvMachineTranslatedLabel() + } } else { actions() } @@ -203,6 +214,7 @@ private fun EditorialColumn( collapsedSynopsisLines: Int, compactSeries: Boolean, translation: (@Composable () -> Unit)? = null, + machineTranslated: Boolean = false, playbackSummary: (@Composable () -> Unit)? = null, ) { val isCombinedSeriesEpisode = compactSeries && !seriesTitle.isNullOrBlank() @@ -273,6 +285,7 @@ private fun EditorialColumn( ) } } + if (machineTranslated && !overview.isNullOrBlank()) TvMachineTranslatedLabel() translation?.invoke() } // Bottom-lock the credit and playback readout independently of synopsis length. diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index a1cb32e258..3b8647ae26 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -110,6 +110,8 @@ import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.common.ui.movieDirectorCredit import org.siloserver.silo.common.ui.openYoutubeTrailer import org.siloserver.silo.metadata.DescriptionTranslationPhase +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview +import org.siloserver.silo.model.catalog.pendingEpisodeTranslationLanguage import org.siloserver.silo.model.audiobook.AudiobookNarration import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.FileVersion @@ -777,6 +779,34 @@ private fun TvDetailContent( viewModel.translateDescription(auto = true) } } + // A series page's rail lists the selected season's episodes; the season's + // job fills any episode description still missing. + val pendingEpisodeLanguage = state.episodes + .filter { it.seasonNumber == state.selectedSeason } + .pendingEpisodeTranslationLanguage() + LaunchedEffect( + detail.contentId, + state.selectedSeason, + state.seasons.isNotEmpty(), + pendingEpisodeLanguage, + metadataAiStatus.onView, + ) { + if (metadataAiStatus.onView == MetadataAiOnView.Auto && pendingEpisodeLanguage != null) { + viewModel.translateShownSeasonEpisodes() + } + } + // Label the shown description when the server reports it machine-translated: + // the focused episode's on a series page, else the page item's own. + val heroMachineTranslated = if (activeSeriesEpisode != null) { + if (!activeSeriesPlaybackDetail?.overview.isNullOrBlank()) { + hasMachineTranslatedOverview(activeSeriesPlaybackDetail?.machineTranslatedFields) + } else { + hasMachineTranslatedOverview(activeSeriesEpisode.machineTranslatedFields) + } + } else { + hasMachineTranslatedOverview(detail.machineTranslatedFields) && + translationPhase != DescriptionTranslationPhase.Translating + } && !heroOverview.isNullOrBlank() val translationSlot: (@Composable () -> Unit)? = if (detail.pendingTranslationLanguage != null && (metadataAiStatus.onView == MetadataAiOnView.Button || @@ -880,6 +910,7 @@ private fun TvDetailContent( translation = translationSlot.takeIf { activeSeriesEpisode == null && !hidesHeroOverview }, + machineTranslated = heroMachineTranslated && !hidesHeroOverview, compactSeries = isSeriesDetail, playbackSummary = { TvDetailPlaybackSelectionSummary( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt index 594728ec25..bf444a92f1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -20,6 +20,8 @@ import org.siloserver.silo.common.settings.dolbyVisionPolicySnapshot import org.siloserver.silo.domain.settings.ProfileSettingsController import org.siloserver.silo.model.catalog.BrowseItem import org.siloserver.silo.model.catalog.CastMember +import org.siloserver.silo.model.catalog.withTranslatedDescription +import org.siloserver.silo.model.catalog.withEpisodeDescription import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.FileVersion import org.siloserver.silo.model.catalog.ItemDetail @@ -29,6 +31,7 @@ import org.siloserver.silo.model.catalog.SeasonUserData import org.siloserver.silo.model.catalog.isAudiobookItemType import org.siloserver.silo.model.catalog.initialSeasonDisplayPlan import org.siloserver.silo.model.catalog.sortedForDisplay +import org.siloserver.silo.model.catalog.pendingEpisodeTranslationLanguage import org.siloserver.silo.model.playback.combinedSubtitleSelectionIndexes import org.siloserver.silo.model.playback.ClientCodecCapabilities import org.siloserver.silo.model.playback.buildPlaybackSubtitleChoices @@ -53,6 +56,7 @@ import org.siloserver.silo.tv.ui.util.isTvHiddenMediaType import org.siloserver.silo.tv.ui.util.visibleOnTv import org.siloserver.silo.viewmodel.applyLocalPlaybackProgress import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -405,6 +409,16 @@ class TvItemDetailViewModel( val translationPhase: StateFlow<org.siloserver.silo.metadata.DescriptionTranslationPhase> = descriptionTranslation.phase + // A season's translation job also fills its episodes' descriptions. The + // series page lists the selected season's episodes in its rail, so it + // starts that job when an episode row reports a missing description. Its + // own controller keeps the series overview's single-flight latch apart. + private val episodeTranslation = + org.siloserver.silo.metadata.DescriptionTranslationController( + repository = metadataAiRepository, + delayMs = { kotlinx.coroutines.delay(it) }, + ) + /** * Loads the cascaded subtitle preferences that annotate the selector row's @@ -1022,12 +1036,12 @@ class TvItemDetailViewModel( (latest <= episodeMutationWatermark || failedEpisodeWatchMutation[it.contentId] == latest) } .associateBy { it.contentId } - episodeWindow.mapEpisodes(seasonNumber) { previousById[it.contentId] ?: it } + episodeWindow.mapEpisodes(seasonNumber) { previousById[it.contentId]?.withTranslatedDescription(it) ?: it } _uiState.update { state -> state.copy( seasons = state.seasons.map { if (it.seasonNumber == seasonNumber) previousSeason else it }, episodes = if (state.selectedSeason == seasonNumber) { - state.episodes.map { previousById[it.contentId] ?: it } + state.episodes.map { previousById[it.contentId]?.withTranslatedDescription(it) ?: it } } else { state.episodes }, @@ -1525,6 +1539,7 @@ class TvItemDetailViewModel( // the rail stay consistent (T15). private var loadedSeason: Int? = null private var episodeListGeneration: Long = 0 + private val episodeDescriptionRevisions = mutableMapOf<Int, Long>() private var nextEpisodeWatchMutationGeneration: Long = 0 private val episodeWatchMutationGenerations = mutableMapOf<String, Long>() private val seasonWatchMutationGenerations = mutableMapOf<Int, Long>() @@ -1582,6 +1597,7 @@ class TvItemDetailViewModel( // the authoritative owner of the network result publications below. episodeLoadRequestGeneration += 1 val requestGeneration = episodeLoadRequestGeneration + val descriptionRevision = episodeDescriptionRevisions[seasonNumber] ?: 0L episodeLoadJob?.cancel() episodeLoadJob = viewModelScope.launch { fun ownsRequest(): Boolean = @@ -1607,9 +1623,16 @@ class TvItemDetailViewModel( if (!ownsRequest()) return@launch loadedSeason = seasonNumber episodeListGeneration += 1 - _uiState.update { it.copy(episodesLoading = false, episodes = episodes) } - refreshNextUp(episodes) - recordCarouselSeason(seasonNumber, episodes) + _uiState.update { state -> + val current = state.episodes.associateBy { it.contentId } + val rows = if (descriptionRevision != (episodeDescriptionRevisions[seasonNumber] ?: 0L)) { + episodes.map { row -> current[row.contentId]?.let(row::withTranslatedDescription) ?: row } + } else episodes + state.copy(episodesLoading = false, episodes = rows) + } + val publishedEpisodes = _uiState.value.episodes + refreshNextUp(publishedEpisodes) + recordCarouselSeason(seasonNumber, publishedEpisodes) val revalidationComplete = refreshEpisodeFavoriteStates(episodes, revalidate = revalidateFavorites) // Caught up only now, and only if every id we were asked to @@ -1758,7 +1781,7 @@ class TvItemDetailViewModel( val live = _uiState.value if (live.selectedSeason == mutationSeason) { val restored = live.episodes.map { episode -> - if (episode.contentId == episodeContentId) previousEpisode else episode + if (episode.contentId == episodeContentId) previousEpisode.withTranslatedDescription(episode) else episode } if (_uiState.compareAndSet(live, live.copy(episodes = restored))) { publishCarousel() @@ -1844,8 +1867,15 @@ class TvItemDetailViewModel( val previousId = _uiState.value.nextUpEpisode?.contentId if (nextUp?.contentId == previousId && _uiState.value.nextUpEpisode != null) { // Same target — just refresh the snapshot (userData may have changed) - // without re-loading playback detail. - _uiState.update { it.copy(nextUpEpisode = nextUp, nextUpTargetReady = true) } + // without re-loading playback detail. A fresher row also carries + // the episode's translated description into the hero. + _uiState.update { + it.copy( + nextUpEpisode = nextUp, + nextUpTargetReady = true, + nextUpPlaybackDetail = nextUp?.let { row -> it.nextUpPlaybackDetail?.withEpisodeDescription(row) } ?: it.nextUpPlaybackDetail, + ) + } return } @@ -1988,13 +2018,13 @@ class TvItemDetailViewModel( // read was suspended. Finish loading the target but // leave that newer explicit choice untouched. it.copy( - nextUpPlaybackDetail = playbackDetail, + nextUpPlaybackDetail = it.nextUpEpisode?.let(playbackDetail::withEpisodeDescription) ?: playbackDetail, isLoadingNextUpPlaybackDetail = false, didLoadNextUpPlaybackDetail = true, ) } else { it.copy( - nextUpPlaybackDetail = playbackDetail, + nextUpPlaybackDetail = it.nextUpEpisode?.let(playbackDetail::withEpisodeDescription) ?: playbackDetail, isLoadingNextUpPlaybackDetail = false, didLoadNextUpPlaybackDetail = true, selectedNextUpFileId = selection.fileId, @@ -2142,14 +2172,86 @@ class TvItemDetailViewModel( val refreshed = withLocalProgress(result.data) if (metadataAiRepository.isCurrent(owner) && _uiState.value.detail?.contentId == detail.contentId) { _uiState.update { it.copy(detail = refreshed) } - refreshed.pendingTranslationLanguage + val seriesId = detail.seriesId + val seasonNumber = detail.seasonNumber + if (detail.type.equals("season", ignoreCase = true) && + seriesId != null && seasonNumber != null + ) { + refreshEpisodesForTranslation(seriesId, seasonNumber, owner, target) + ?: refreshed.pendingTranslationLanguage + } else refreshed.pendingTranslationLanguage } else target } else -> target // transient refetch failure: keep polling } }, - onTranslated = { }, + onTranslated = {}, + ) + } + } + + private suspend fun refreshEpisodesForTranslation( + seriesId: String, + seasonNumber: Int, + owner: org.siloserver.silo.network.AuthScopeSnapshot?, + target: String, + ): String? { + val requestGeneration = episodeLoadRequestGeneration + val listGeneration = episodeListGeneration + val result = catalogRepository.getEpisodes(seriesId, seasonNumber, libraryId = libraryId, fresh = true) + if (result !is ApiResult.Success || !metadataAiRepository.isCurrent(owner)) return target + kotlinx.coroutines.currentCoroutineContext().ensureActive() + if (_uiState.value.selectedSeason != seasonNumber) return result.data.episodes.pendingEpisodeTranslationLanguage() + val fresh = withLocalProgress(result.data.episodes) + kotlinx.coroutines.currentCoroutineContext().ensureActive() + if (!metadataAiRepository.isCurrent(owner) || + requestGeneration != episodeLoadRequestGeneration || + listGeneration != episodeListGeneration || + _uiState.value.selectedSeason != seasonNumber + ) return target + episodeDescriptionRevisions[seasonNumber] = (episodeDescriptionRevisions[seasonNumber] ?: 0L) + 1 + _uiState.update { state -> + val current = state.episodes.associateBy { it.contentId } + val merged = fresh.sortedBy { it.episodeNumber } + .map { row -> current[row.contentId]?.withTranslatedDescription(row) ?: row } + state.copy(episodes = merged) + } + val episodes = _uiState.value.episodes + refreshNextUp(episodes) + recordCarouselSeason(seasonNumber, episodes) + return result.data.episodes.pendingEpisodeTranslationLanguage() + } + + /** + * `auto` on-view mode for a series page's episode rail: start the shown + * season's translation once per (season, language) when its episode rows + * report a missing description, re-reading the rail until none does. The + * season detail's pending language covers its episodes, so it is the + * completion signal. + */ + fun translateShownSeasonEpisodes() { + val state = _uiState.value + val detail = state.detail ?: return + if (!detail.type.equals("series", ignoreCase = true)) return + val seasonNumber = state.selectedSeason ?: return + val season = state.seasons.firstOrNull { it.seasonNumber == seasonNumber } ?: return + val target = state.episodes + .filter { it.seasonNumber == seasonNumber } + .pendingEpisodeTranslationLanguage() ?: return + // A season switched to while another season's job polls is retried + // when that job ends (below), rather than latched as already fired. + if (!episodeTranslation.claimAutoFire(season.contentId, target)) return + episodeTranslation.resetFailure() + viewModelScope.launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + episodeTranslation.translate( + contentId = season.contentId, + targetLanguage = target, + refetchPendingLanguage = { owner -> + refreshEpisodesForTranslation(detail.contentId, seasonNumber, owner, target) + }, + onTranslated = {}, ) + if (episodeTranslation.takeDeferredAuto()) translateShownSeasonEpisodes() } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt index 47b4ad6a8f..420151bdc2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt @@ -145,6 +145,7 @@ fun TvHomeScreen( firstRowContainerFocusRequester = firstRowContainerFocusRequester, onContentUpFallbackChanged = onContentUpFallbackChanged, onSetWatched = viewModel::setWatched, + onMarqueeTranslated = viewModel::refreshFromRealtime, onToggleFavorite = viewModel::toggleFavorite, onToggleWatchlist = viewModel::toggleWatchlist, onDismissContinueWatching = viewModel::dismissContinueWatching, @@ -239,8 +240,10 @@ private fun TvHomeContent( onDismissContinueWatching: (String, String) -> Unit = { _, _ -> }, onDismissNextUp: (String, String) -> Unit = { _, _ -> }, sectionsFullyResolved: Boolean = true, + onMarqueeTranslated: () -> Unit = {}, ) { TvSkylineSectionFeed( + onMarqueeTranslated = onMarqueeTranslated, surfaceKey = "home", // tvOS Home drops the foreground marquee + row band by 56pt while // keeping the ambient art and top navigation fixed (28dp at TV scale). diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt index 0adfc857f4..b21b45f516 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Semaphore @@ -165,6 +166,7 @@ class TvLibraryDetailViewModel( private val libraryType: String, private val mediaScope: String? = null, private val browsePrefs: BrowsePrefsStore? = null, + catalogLanguage: org.siloserver.silo.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { /** Access changes this ViewModel has applied, kept while its screen is away. */ val accessChanges = org.siloserver.silo.network.AccessChangeCursor() @@ -243,6 +245,14 @@ class TvLibraryDetailViewModel( // `/catalog/filters` call is slow and is wasted work for the (common) // case where the user never leaves Recommended. loadRecommended() + // Titles and descriptions are localized per metadata language. + catalogLanguage?.let { language -> + viewModelScope.launch { + language.revision.drop(1).collect { + refreshAfterLanguageChange() + } + } + } } fun onTabSelected(tab: TvLibraryTab) { @@ -416,6 +426,36 @@ class TvLibraryDetailViewModel( loadAudiobookGroups(groupBy = groupBy, reset = true) } + private fun refreshAfterLanguageChange() { + recommendedJob?.cancel() + filtersJob?.cancel() + collectionsJob?.cancel() + ++recommendedGeneration + ++browseGeneration + ++audiobookGroupsGeneration + browseContinuation = null + audiobookContinuation = null + _uiState.update { + it.copy( + sections = emptyList(), + browseItems = emptyList(), + collections = emptyList(), + collectionSections = emptyList(), + genres = emptyList(), + facetOptions = null, + recommendedLoading = false, + browseLoading = false, + browseLoadingMore = false, + audiobookGroups = emptyList(), + audiobookGroupsLoading = false, + audiobookGroupsLoadingMore = false, + filtersLoading = false, + collectionsLoading = false, + ) + } + refreshAfterAccessChange() + } + /** * The server reported an access change: titles, sections, facets, and * collections in this library may differ under the new policy. Reload the diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/recommendations/TvRecommendationsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/recommendations/TvRecommendationsScreen.kt index 62e0259f14..4407d7e1e4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/recommendations/TvRecommendationsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/recommendations/TvRecommendationsScreen.kt @@ -257,6 +257,7 @@ fun TvRecommendationsScreen( } else -> TvSkylineSectionFeed( surfaceKey = "for_you", + onMarqueeTranslated = viewModel::refresh, sections = visibleSections, onItemClick = onRecommendationItemClick, // Both tokens are monotonic, so their sum is too — which is all diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslationTest.kt new file mode 100644 index 0000000000..8d20628682 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/components/TvMarqueeTranslationTest.kt @@ -0,0 +1,161 @@ +package org.siloserver.silo.tv.ui.components + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.siloserver.silo.network.api.MetadataAiApi +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.model.metadata.MetadataAiStatus +import org.siloserver.silo.model.metadata.MetadataTranslationJob +import org.siloserver.silo.model.catalog.ItemDetail +import org.siloserver.silo.repository.MetadataAiRepository +import kotlin.test.assertEquals + +import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvMarqueeTranslationTest { + private val pendingCard = SectionItem( + contentId = "movie-1", + type = "movie", + title = "Film", + overview = "Original", + pendingTranslationLanguage = "nl", + ) + private val featured = ResolvedSection( + id = "featured", + sectionType = "featured", + title = "Featured", + featured = true, + items = listOf(pendingCard), + ) + private val ordinary = ResolvedSection( + id = "recently_added", + sectionType = "recently_added", + title = "Recently Added", + items = listOf(pendingCard), + ) + private val sections = listOf(featured, ordinary) + + @Test + fun aCardFocusedInAFeaturedSectionTranslatesOnView() { + val content = TvMarqueeContent.from(pendingCard, featured.title, featured.id) + + assertTrue(isFeaturedMarqueeContent(content, sections)) + } + + @Test + fun theSameCardFocusedInAnOrdinaryRowDoesNot() { + val content = TvMarqueeContent.from(pendingCard, ordinary.title, ordinary.id) + + assertFalse(isFeaturedMarqueeContent(content, sections)) + } + + @Test + fun noContentOrAnUnknownRowDoesNot() { + assertFalse(isFeaturedMarqueeContent(null, sections)) + val content = TvMarqueeContent.from(pendingCard, "Gone", "gone") + assertFalse(isFeaturedMarqueeContent(content, sections)) + } + @Test + fun retainedCardQueuesOnlyTheCurrentProfilesPendingLanguage() = runTest { + val requested = mutableListOf<String>() + var reads = 0 + var refreshed = false + val api = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success( + MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?) : ApiResult<MetadataTranslationJob> { + requested += targetLanguage + return ApiResult.Success( + MetadataTranslationJob("job", "item", contentId, targetLanguage, "pending")) + } + override suspend fun refreshDetail(contentId: String, scope: AuthScopeSnapshot?): ApiResult<ItemDetail> { + reads++ + return ApiResult.Success(ItemDetail( + contentId, "movie", title = "Film", overview = if (reads == 1) "Original" else "Localized", + pendingTranslationLanguage = if (reads == 1) "de" else null, + machineTranslatedFields = if (reads == 1) emptyList() else listOf("overview"))) + } + } + val state = TvMarqueeTranslationState(MetadataAiRepository(api), this) + state.translateOnView(pendingCard) { refreshed = true } + advanceUntilIdle() + assertEquals(listOf("de"), requested) + assertTrue(refreshed) + val presented = state.presentation(pendingCard, state.phase.value, state.runningContentId.value) + assertEquals("Localized", presented.synopsis) + assertTrue(presented.machineTranslated) + assertFalse(state.working.value) + } + + @Test + fun disposingDuringLanguagePreflightCancelsItsQueuedWork() = runTest { + val response = CompletableDeferred<ItemDetail>() + var posts = 0 + val api = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success( + MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?): ApiResult<MetadataTranslationJob> { + posts++ + return ApiResult.Error(503, "unavailable", "Unavailable") + } + override suspend fun refreshDetail(contentId: String, scope: AuthScopeSnapshot?) = + ApiResult.Success(response.await()) + } + val state = TvMarqueeTranslationState(MetadataAiRepository(api), this) + state.translateOnView(pendingCard) {} + runCurrent() + assertTrue(state.working.value) + state.close() + response.complete(ItemDetail("movie-1", "movie", title = "Film", pendingTranslationLanguage = "de")) + runCurrent() + assertEquals(0, posts) + assertFalse(state.working.value) + } + + @Test + fun failedPreflightRetriesOnRefocusWithoutConsumingTheTranslationClaim() = runTest { + var reads = 0 + var posts = 0 + val api = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success(MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?): ApiResult<MetadataTranslationJob> { + posts++ + return ApiResult.Success(MetadataTranslationJob("job", "item", contentId, targetLanguage, "pending")) + } + override suspend fun refreshDetail(contentId: String, scope: AuthScopeSnapshot?): ApiResult<ItemDetail> { + if (++reads == 1) return ApiResult.NetworkError(IllegalStateException("Temporary failure")) + return ApiResult.Success(ItemDetail("movie-1", "movie", title = "Film", + overview = if (reads == 2) "Original" else "Localized", + pendingTranslationLanguage = if (reads == 2) "nl" else null)) + } + } + val state = TvMarqueeTranslationState(MetadataAiRepository(api), this) + state.onFocusChanged(pendingCard.contentId) + state.translateOnView(pendingCard) {} + advanceUntilIdle() + state.translateOnView(pendingCard) {} + advanceUntilIdle() + assertEquals(1, reads, "resting focus must not loop on a failing preflight") + assertEquals(0, posts) + + state.onFocusChanged("another-card") + state.onFocusChanged(pendingCard.contentId) + state.translateOnView(pendingCard) {} + advanceUntilIdle() + assertEquals(1, posts) + assertEquals("Localized", state.presentation(pendingCard, state.phase.value, state.runningContentId.value).synopsis) + } + +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt index 9888fc7402..e16dba0e69 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt @@ -41,6 +41,10 @@ import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.api.CatalogApi import org.siloserver.silo.network.api.DefaultMetadataAiApi +import org.siloserver.silo.network.api.MetadataAiApi +import org.siloserver.silo.model.metadata.MetadataAiStatus +import org.siloserver.silo.model.metadata.MetadataTranslationJob +import org.siloserver.silo.repository.port.LocalPlaybackProgress import org.siloserver.silo.network.api.PersonalDataApi import org.siloserver.silo.network.api.ProfileApi import org.siloserver.silo.network.api.SettingsApi @@ -75,6 +79,60 @@ import kotlin.test.assertTrue */ @OptIn(ExperimentalCoroutinesApi::class) class TvNextUpSelectionHandoffTest { + @Test + fun delayedTranslationDoesNotRestoreANewerReloadsRemovedEpisodes() = runDetailTest { + val scenario = Scenario(suffix = "-translation-reload") + scenario.translationPending = true + val fixture = createFixture(scenario, acceptingTranslationApi()) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + awaitCondition { fixture.viewModel.uiState.value.carouselEpisodes.size == 3 } + val initialReads = scenario.completedSeasonOneReads.get() + val oldRead = CompletableDeferred<Unit>() + scenario.seasonOneResponses += DetailResponse(oldRead, scenario.episodesJson() + .replace("\"pending_translation_language\":\"de\"", "\"pending_translation_language\":null")) + fixture.viewModel.translateShownSeasonEpisodes() + awaitCondition { scenario.seasonOneResponses.isEmpty() } + + scenario.seasonOnePageOverride = """{"items":[{"content_id":"${scenario.episodeOneId}","season_number":1,"episode_number":1,"overview":"Newer"}]}""" + fixture.viewModel.refreshOnReturn() + awaitCondition { fixture.viewModel.uiState.value.episodes.singleOrNull()?.overview == "Newer" } + oldRead.complete(Unit) + // A discarded reply keeps polling. The old implementation instead + // publishes the removed row and treats this completed reply as final. + awaitCondition { + fixture.viewModel.uiState.value.episodes.any { it.contentId == scenario.episodeTwoId } || + scenario.completedSeasonOneReads.get() >= initialReads + 3 + } + + assertEquals(listOf(scenario.episodeOneId), fixture.viewModel.uiState.value.episodes.map { it.contentId }) + assertEquals("Newer", fixture.viewModel.uiState.value.episodes.single().overview) + assertFalse(fixture.viewModel.uiState.value.carouselEpisodes.any { it.contentId == scenario.episodeTwoId }) + } + + @Test + fun translationAppliesLocalProgressToNewEpisodeRows() = runDetailTest { + val scenario = Scenario(suffix = "-translation-local-progress") + scenario.seasonOnePageOverride = """{"items":[{"content_id":"${scenario.episodeOneId}","season_number":1,"episode_number":1,"pending_translation_language":"de"}]}""" + val fixture = createFixture(scenario, acceptingTranslationApi()) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + awaitCondition { fixture.viewModel.uiState.value.carouselEpisodes.size == 2 } + fixture.userState.progress[scenario.episodeTwoId] = LocalPlaybackProgress(201, 30.0, 120.0) + scenario.seasonOnePageOverride = """{"items":[{"content_id":"${scenario.episodeOneId}","season_number":1,"episode_number":1},{"content_id":"${scenario.episodeTwoId}","season_number":1,"episode_number":2,"user_data":{"played":false,"is_in_progress":false}}]}""" + fixture.viewModel.translateShownSeasonEpisodes() + awaitCondition { fixture.viewModel.uiState.value.episodes.size == 2 } + + val inserted = fixture.viewModel.uiState.value.episodes.first { it.contentId == scenario.episodeTwoId } + assertTrue(inserted.userData?.isInProgress == true) + assertEquals(30.0, inserted.userData?.positionSeconds) + } + + private fun acceptingTranslationApi() = object : MetadataAiApi { + override suspend fun status() = ApiResult.Success(MetadataAiStatus()) + override suspend fun translateDescription(contentId: String, targetLanguage: String, + scope: AuthScopeSnapshot?) = ApiResult.Success( + MetadataTranslationJob("job", "season", contentId, targetLanguage, "pending")) + } + @Test fun routedEpisodeReplacesAnEarlierSeasonJump() = runDetailTest { val scenario = Scenario(suffix = "-routed-season-jump") @@ -784,7 +842,7 @@ class TvNextUpSelectionHandoffTest { } } - private fun createFixture(scenario: Scenario): Fixture { + private fun createFixture(scenario: Scenario, metadataApi: MetadataAiApi? = null): Fixture { val identityTransitions = DefaultIdentityTransitionBarrier() val tokenManager = FakeTokenManager(identityTransitions) val client = scenario.client(tokenManager) @@ -816,7 +874,7 @@ class TvNextUpSelectionHandoffTest { }, profileRepository = profileRepository, profileSettings = ProfileSettingsController(SettingsRepository(UnavailableSettingsApi())), - metadataAiRepository = MetadataAiRepository(DefaultMetadataAiApi(client, gate = ApiV2Gate.Unrestricted)), + metadataAiRepository = MetadataAiRepository(metadataApi ?: DefaultMetadataAiApi(client, gate = ApiV2Gate.Unrestricted)), contentId = scenario.seriesId, userItemState = userState, tokenManager = tokenManager, @@ -877,6 +935,10 @@ class TvNextUpSelectionHandoffTest { var seasonsGate: CompletableDeferred<Unit>? = null var seasonOneEpisodesGate: CompletableDeferred<Unit>? = null var seasonTwoEpisodesGate: CompletableDeferred<Unit>? = null + var translationPending = false + var seasonOnePageOverride: String? = null + val seasonOneResponses = ConcurrentLinkedDeque<DetailResponse>() + val completedSeasonOneReads = AtomicInteger() val seasonsRequests = AtomicInteger() val episodeTwoRequests = AtomicInteger() val pendingEpisodeOneResponses = AtomicInteger() @@ -912,8 +974,11 @@ class TvNextUpSelectionHandoffTest { ) } "/api/v2/catalog/series/$seriesId/seasons/1/episodes" -> { - seasonOneEpisodesGate?.await() - json(episodesJson()) + val queued = seasonOneResponses.pollFirst() + queued?.gate?.await() + if (queued == null) seasonOneEpisodesGate?.await() + completedSeasonOneReads.incrementAndGet() + json(queued?.json ?: seasonOnePageOverride ?: episodesJson()) } "/api/v2/catalog/series/$seriesId/seasons/2/episodes" -> { seasonTwoEpisodesGate?.await() @@ -979,9 +1044,9 @@ class TvNextUpSelectionHandoffTest { install(ContentNegotiation) { json(SiloJson) } } - private fun episodesJson(): String = + fun episodesJson(): String = """{"items":[ - {"content_id":"$episodeOneId","season_number":1,"episode_number":1,"title":"One","user_data":{"played":$episodeOneWatched}}, + {"content_id":"$episodeOneId","season_number":1,"episode_number":1,"title":"One","pending_translation_language":${if (translationPending) "\"de\"" else "null"},"user_data":{"played":$episodeOneWatched}}, {"content_id":"$episodeTwoId","season_number":1,"episode_number":2,"title":"Two","user_data":{"played":$episodeTwoWatched}} ]}""".trimIndent() } @@ -993,6 +1058,9 @@ class TvNextUpSelectionHandoffTest { val writes = CopyOnWriteArrayList<Write>() val readGates = ConcurrentHashMap<Pair<String, Int>, CompletableDeferred<Unit>>() val startedReads: MutableSet<Pair<String, Int>> = ConcurrentHashMap.newKeySet() + val progress = ConcurrentHashMap<String, LocalPlaybackProgress>() + override suspend fun localPlaybackProgress(contentId: String) = progress[contentId] + override suspend fun localPlaybackProgressForContent(contentIds: List<String>) = progress.filterKeys { it in contentIds } private var nextPersonalWrite = 0L override suspend fun beginPersonalWrite(command: PersonalWrite) = diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt index 76fce116bc..4296bf240c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/di/RepositoryModule.kt @@ -66,6 +66,8 @@ val repositoryModule = module { single { CatalogRepository( catalogApi = get(), + catalogLanguage = get(), + identityTransitions = get(), catalogCache = getOrNull<org.siloserver.silo.repository.port.CatalogCachePort>() ?: org.siloserver.silo.repository.port.NoOpCatalogCachePort, ) @@ -131,7 +133,14 @@ val repositoryModule = module { 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.siloserver.silo.domain.settings.ProfileSettingsController(get()) } + single { org.siloserver.silo.domain.settings.CatalogLanguageRevision() } + single { + org.siloserver.silo.domain.settings.ProfileSettingsController( + get(), get(), + captureAuthority = { get<TokenManager>().snapshotCurrentScope() }, + invalidateCatalogMetadata = { get<CatalogRepository>().invalidateLocalizedMetadata() }, + ) + } single { LibraryPlaybackPrefsRepository(get()) } single { DownloadsRepository(get(), getOrNull<org.siloserver.silo.repository.port.DownloadDeletionPort>() ?: org.siloserver.silo.repository.port.NoOpDownloadDeletionPort, get(), get(), get()) } single { EbookReaderRepository(get(), get()) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/CatalogLanguageRevision.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/CatalogLanguageRevision.kt new file mode 100644 index 0000000000..6353e3c615 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/CatalogLanguageRevision.kt @@ -0,0 +1,36 @@ +package org.siloserver.silo.domain.settings + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import org.siloserver.silo.repository.port.CatalogCacheScope + +/** + * Bumps when the active profile's metadata language changes on this device. + * + * The server localizes titles and descriptions per profile, so every catalog + * list on screen holds text in the old language until it is read again. Home, + * For You and library surfaces collect this and re-read quietly; a profile + * switch needs no signal because it rebuilds those screens. + */ +class CatalogLanguageRevision { + internal data class Changes( + val revision: Long = 0L, + val scopes: Map<CatalogCacheScope?, Long> = emptyMap(), + ) + + private val _changes = MutableStateFlow(Changes()) + internal val changes: Changes get() = _changes.value + private val _revision = MutableStateFlow(0L) + val revision: StateFlow<Long> = _revision.asStateFlow() + + fun changed(scope: CatalogCacheScope? = null) { + val next = _changes.updateAndGet { current -> + val revision = current.revision + 1 + Changes(revision, current.scopes + (scope to revision)) + } + _revision.update { maxOf(it, next.revision) } + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsController.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsController.kt index b91730788c..655b562d10 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsController.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsController.kt @@ -1,9 +1,13 @@ package org.siloserver.silo.domain.settings +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import org.siloserver.silo.model.settings.EffectiveSettingValue import org.siloserver.silo.model.settings.SettingKeys import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.repository.SettingsRepository +import org.siloserver.silo.repository.port.CatalogCacheScope import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive @@ -28,6 +32,9 @@ import kotlinx.serialization.json.jsonPrimitive */ class ProfileSettingsController( private val repository: SettingsRepository, + private val catalogLanguage: CatalogLanguageRevision = CatalogLanguageRevision(), + private val captureAuthority: (suspend () -> AuthScopeSnapshot?)? = null, + private val invalidateCatalogMetadata: suspend () -> Unit = {}, ) { /** Whether the connected server answered the canonical settings probe. */ @@ -110,8 +117,8 @@ class ProfileSettingsController( * 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)) { + private suspend fun reresolve(authority: AuthScopeSnapshot? = null): Snapshot? = + when (val result = repository.getEffectiveValues(PROFILE_KEYS, authority = authority)) { is ApiResult.Success -> snapshotOf(result.data) is ApiResult.Error, is ApiResult.NetworkError -> null } @@ -128,27 +135,40 @@ class ProfileSettingsController( 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)) + suspend fun setMetadataLanguage(language: String): WriteResult { + val authority = captureAuthority?.invoke() + if (captureAuthority != null && authority?.profileId == null) return WriteResult(false, null) + val result = writeLanguage(SettingKeys.CATALOG_METADATA_LANGUAGE, language, authority) + // The write committed even if the following settings read is canceled. + if (result is ApiResult.Success) { + catalogLanguage.changed(authority?.let { CatalogCacheScope(it.serverId, requireNotNull(it.profileId)) }) + // Complete durable invalidation even if the post-write reload is + // canceled, so a later app launch cannot revive the old language. + withContext(NonCancellable) { + invalidateCatalogMetadata() + } + } + return resolved(result, authority) + } - private suspend fun resolved(write: ApiResult<Unit>): WriteResult = + private suspend fun resolved(write: ApiResult<Unit>, authority: AuthScopeSnapshot? = null): WriteResult = if (write is ApiResult.Success) { - WriteResult(succeeded = true, snapshot = reresolve()) + WriteResult(succeeded = true, snapshot = reresolve(authority)) } else { WriteResult(succeeded = false, snapshot = null) } - private suspend fun writeLanguage(key: String, language: String): ApiResult<Unit> { + private suspend fun writeLanguage(key: String, language: String, authority: AuthScopeSnapshot? = null): ApiResult<Unit> { 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)) + return if (tag.isEmpty()) repository.clearProfileValue(key, authority) else write(key, JsonPrimitive(tag), authority) } - private suspend fun write(key: String, value: JsonElement): ApiResult<Unit> = - when (val result = repository.setProfileValue(key, value)) { + private suspend fun write(key: String, value: JsonElement, authority: AuthScopeSnapshot? = null): ApiResult<Unit> = + when (val result = repository.setProfileValue(key, value, authority)) { is ApiResult.Success -> ApiResult.Success(Unit) is ApiResult.Error -> result is ApiResult.NetworkError -> result diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/metadata/DescriptionTranslationController.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/metadata/DescriptionTranslationController.kt index f1d52b7c65..9f0c0d10b3 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/metadata/DescriptionTranslationController.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/metadata/DescriptionTranslationController.kt @@ -28,6 +28,15 @@ class DescriptionTranslationController( private val _phase = MutableStateFlow(DescriptionTranslationPhase.Idle) val phase: StateFlow<DescriptionTranslationPhase> = _phase.asStateFlow() + private val _runningContentId = MutableStateFlow<String?>(null) + + /** + * The item whose translation [phase] describes, so a surface that switches + * items (the TV focus marquee) never shows one item's progress on another. + * Kept after a failure so the failed item can offer a retry. + */ + val runningContentId: StateFlow<String?> = _runningContentId.asStateFlow() + private val autoFired = mutableSetOf<String>() /** @@ -35,14 +44,19 @@ class DescriptionTranslationController( * current `pending_translation_language`; null means the translation * landed (the refetch has already delivered the new overview). * @param onTranslated invoked once the pending language clears. + * @param onPoll invoked on every poll tick before the refetch, for data + * the job also changes but the detail does not carry (a season job + * translates its episodes, so the episode list refreshes here). */ suspend fun translate( contentId: String, targetLanguage: String, refetchPendingLanguage: suspend (org.siloserver.silo.network.AuthScopeSnapshot?) -> String?, onTranslated: suspend () -> Unit, + onPoll: suspend (org.siloserver.silo.network.AuthScopeSnapshot?) -> Unit = {}, ) { if (_phase.value == DescriptionTranslationPhase.Translating) return + _runningContentId.value = contentId _phase.value = DescriptionTranslationPhase.Translating val owner = repository.captureAuthority() @@ -71,6 +85,11 @@ class DescriptionTranslationController( _phase.value = DescriptionTranslationPhase.Failed return } + onPoll(owner) + if (!repository.isCurrent(owner)) { + _phase.value = DescriptionTranslationPhase.Failed + return + } val pending = refetchPendingLanguage(owner) if (!repository.isCurrent(owner)) { _phase.value = DescriptionTranslationPhase.Failed @@ -95,6 +114,29 @@ class DescriptionTranslationController( } } + private var deferredAuto = false + + /** + * `auto` on-view mode: claim (content, language) for a translation that + * will start now. Returns false without claiming when it already fired, or + * while another translation runs — that one would make [translate] a no-op + * and the claim would block every later try. In the busy case + * [takeDeferredAuto] reports it, so the caller tries again once the + * running translation ends. + */ + fun claimAutoFire(contentId: String, targetLanguage: String): Boolean { + if (_phase.value == DescriptionTranslationPhase.Translating) { + deferredAuto = true + return false + } + if (!shouldAutoFire(contentId, targetLanguage)) return false + markAutoFired(contentId, targetLanguage) + return true + } + + /** True once, after an auto claim was refused because a translation was running. */ + fun takeDeferredAuto(): Boolean = deferredAuto.also { deferredAuto = false } + /** `auto` on-view mode: fire at most once per (content, language) per session. */ fun shouldAutoFire(contentId: String, targetLanguage: String): Boolean = "$contentId|$targetLanguage" !in autoFired @@ -110,7 +152,9 @@ class DescriptionTranslationController( } private companion object { - // Apple DescriptionTranslationCoordinator schedule (~31s cap). - val POLL_BACKOFF_SECONDS = listOf(1L, 2L, 3L, 5L, 5L, 5L, 5L, 5L) + // Apple DescriptionTranslationCoordinator's opening backoff, held at + // 5s to the web client's 45s budget: a season job also translates its + // episodes before the season's pending language clears. + val POLL_BACKOFF_SECONDS = listOf(1L, 2L, 3L, 4L, 5L, 5L, 5L, 5L, 5L, 5L, 5L) } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt index 3ab2473e84..988f4a5634 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/CatalogModels.kt @@ -83,6 +83,10 @@ data class BrowseItem( @SerialName("position_seconds") val positionSeconds: Double? = null, @SerialName("duration_seconds") val durationSeconds: Double? = null, @SerialName("progress_updated_at") val progressUpdatedAt: String? = null, + /** This profile's metadata language while the card's description is not available in it yet. */ + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview", "tagline") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable @@ -178,6 +182,8 @@ data class ItemDetail( // Non-null while a viewer-facing description translation job is queued or // running for this item; clears when the translated overview lands. @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview", "tagline") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), val tagline: String? = null, val runtime: Int = 0, @SerialName("content_rating") val contentRating: String? = null, @@ -454,7 +460,9 @@ data class Season( @SerialName("episode_count") val episodeCount: Int = 0, @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, - @SerialName("user_data") val userData: SeasonUserData? = null + @SerialName("user_data") val userData: SeasonUserData? = null, + /** Localized fields ("overview") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable @@ -529,7 +537,14 @@ data class EpisodeListItem( @SerialName("still_url") val stillUrl: String? = null, @SerialName("still_thumbhash") val stillThumbhash: String? = null, @SerialName("user_data") val userData: LeafItemUserData? = null, - val files: List<EpisodeFile> = emptyList() + val files: List<EpisodeFile> = emptyList(), + /** + * This profile's metadata language while the episode's description is not + * available in it yet; translating the episode's season fills it. + */ + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/MachineTranslation.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/MachineTranslation.kt new file mode 100644 index 0000000000..d3533a6ed8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/MachineTranslation.kt @@ -0,0 +1,43 @@ +package org.siloserver.silo.model.catalog + +/** + * Whether the overview a surface shows was machine-translated (the server's + * `machine_translated_fields` on a detail, season, episode or card). Use this + * wherever the "Translated by AI" mark sits with the overview alone: the + * server can report `["tagline"]` for a provider overview with an AI tagline. + */ +fun hasMachineTranslatedOverview(fields: List<String>?): Boolean = + fields.orEmpty().contains(MACHINE_TRANSLATED_OVERVIEW) + +const val MACHINE_TRANSLATED_OVERVIEW = "overview" + +/** + * The language a season's episode rows are still missing, if any. A season's + * translation job covers its episodes, so one pending row is enough to start it. + */ +fun List<EpisodeListItem>.pendingEpisodeTranslationLanguage(): String? = + firstNotNullOfOrNull { episode -> episode.pendingTranslationLanguage?.takeIf { it.isNotBlank() } } + +/** Refresh description metadata without replacing playback or personal state. */ +fun ItemDetail.withEpisodeDescription(row: EpisodeListItem): ItemDetail { + if (row.contentId != contentId) return this + // A detail read can finish after translation while the rail still holds + // a pending snapshot. Keep that completed detail until the rail catches up. + if (pendingTranslationLanguage.isNullOrBlank() && !row.pendingTranslationLanguage.isNullOrBlank()) return this + if (row.overview.isNullOrBlank()) return copy(pendingTranslationLanguage = row.pendingTranslationLanguage) + return copy( + overview = row.overview, + machineTranslatedFields = row.machineTranslatedFields, + pendingTranslationLanguage = row.pendingTranslationLanguage, + ) +} + +/** A translation poll owns description fields; the current row owns user state. */ +fun EpisodeListItem.withTranslatedDescription(row: EpisodeListItem): EpisodeListItem { + if (row.contentId != contentId) return this + return copy( + overview = row.overview, + machineTranslatedFields = row.machineTranslatedFields, + pendingTranslationLanguage = row.pendingTranslationLanguage, + ) +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt index b007693b16..c449856739 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/section/SectionModels.kt @@ -40,7 +40,11 @@ data class SectionItem( @SerialName("backdrop_url") val backdropUrl: String? = null, @SerialName("backdrop_thumbhash") val backdropThumbhash: String? = null, @SerialName("logo_url") val logoUrl: String? = null, - @SerialName("user_state") val userState: MediaItemUserState? = null + @SerialName("user_state") val userState: MediaItemUserState? = null, + /** This profile's metadata language while the card's description is not available in it yet. */ + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + /** Localized fields ("overview", "tagline") whose text was machine-translated by AI. */ + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt index 86b8cb35ee..6d6b112043 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/CatalogDetailReadV2Models.kt @@ -27,6 +27,7 @@ internal data class ItemDetailReadV2( val year: Int = 0, val overview: String? = null, @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), val tagline: String? = null, val runtime: Int = 0, @SerialName("content_rating") val contentRating: String? = null, @@ -93,6 +94,7 @@ internal data class ItemDetailReadV2( year = year, overview = overview, pendingTranslationLanguage = pendingTranslationLanguage, + machineTranslatedFields = machineTranslatedFields, tagline = tagline, runtime = runtime, contentRating = contentRating, @@ -283,7 +285,9 @@ internal data class EpisodeListItemReadV2( @SerialName("still_url") val stillUrl: String? = null, @SerialName("still_thumbhash") val stillThumbhash: String? = null, @SerialName("user_data") val userData: LeafItemUserDataReadV2? = null, - val files: List<EpisodeFileReadV2> = emptyList() + val files: List<EpisodeFileReadV2> = emptyList(), + @SerialName("pending_translation_language") val pendingTranslationLanguage: String? = null, + @SerialName("machine_translated_fields") val machineTranslatedFields: List<String> = emptyList(), ) { fun toDomain() = EpisodeListItem( contentId = contentId, @@ -301,6 +305,8 @@ internal data class EpisodeListItemReadV2( stillThumbhash = stillThumbhash, userData = userData?.toDomain(), files = files.map { it.toDomain() }, + pendingTranslationLanguage = pendingTranslationLanguage, + machineTranslatedFields = machineTranslatedFields, ) } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt index 7c80fc3258..b3de50a91f 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/CatalogRepository.kt @@ -1,5 +1,7 @@ package org.siloserver.silo.repository +import org.siloserver.silo.domain.settings.CatalogLanguageRevision +import org.siloserver.silo.repository.port.CatalogCacheScope import org.siloserver.silo.model.catalog.CatalogFiltersResponse import org.siloserver.silo.model.catalog.CatalogQueryGroup import org.siloserver.silo.model.catalog.CatalogResponse @@ -35,6 +37,7 @@ class CatalogRepository( private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), requestDispatcher: CoroutineDispatcher = Dispatchers.Default, + private val catalogLanguage: CatalogLanguageRevision? = null, ) { /** * Home may warm a detail immediately before its destination requests the @@ -44,12 +47,18 @@ class CatalogRepository( */ private val detailRequestScope = CoroutineScope(SupervisorJob() + requestDispatcher) private val detailRequestMutex = Mutex() + private data class DetailRequestKey( + val identityGeneration: Long, + val languageRevision: Long, + val contentId: String, + ) private val itemDetailInFlight = - mutableMapOf<Pair<Long, String>, Deferred<ApiResult<ItemDetail>>>() + mutableMapOf<DetailRequestKey, Deferred<ApiResult<ItemDetail>>>() private val seasonsInFlight = - mutableMapOf<Pair<Long, String>, Deferred<ApiResult<SeasonsResponse>>>() + mutableMapOf<DetailRequestKey, Deferred<ApiResult<SeasonsResponse>>>() private data class EpisodesRequestKey( val identityGeneration: Long, + val languageRevision: Long, val seriesId: String, val seasonNumber: Int, ) @@ -185,10 +194,11 @@ class CatalogRepository( ): ApiResult<ItemDetail> { if (libraryId != null) return catalogApi.getItemDetail(contentId, libraryId) val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision val warmRequest = if (!joinWarmup) null else detailRequestMutex.withLock { - itemDetailInFlight[requestIdentityGeneration to contentId] + itemDetailInFlight[DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, contentId)] } - return warmRequest?.await() ?: fetchItemDetail(contentId, requestIdentityGeneration) + return warmRequest?.await() ?: fetchItemDetail(contentId, requestIdentityGeneration, requestLanguageRevision) } /** @@ -197,17 +207,18 @@ class CatalogRepository( */ suspend fun warmItemDetail(contentId: String): ApiResult<ItemDetail> { val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision return coalescedDetailRequest( requests = itemDetailInFlight, - key = requestIdentityGeneration to contentId, + key = DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, contentId), ) { - fetchItemDetail(contentId, requestIdentityGeneration) + fetchItemDetail(contentId, requestIdentityGeneration, requestLanguageRevision) } } /** Returns the last cached item detail without touching the network. */ suspend fun getCachedItemDetail(contentId: String, libraryId: Int? = null): ItemDetail? = - if (libraryId != null) null else catalogCache.getCachedItemDetail(contentId) + if (libraryId != null) null else readLocalizedCache { catalogCache.getCachedItemDetail(contentId) } /** * Cache-first detail for speculative UI enrichment. Unlike a detail screen, @@ -215,7 +226,7 @@ class CatalogRepository( */ suspend fun getItemDetailForPrefetch(contentId: String, libraryId: Int? = null): ApiResult<ItemDetail> { if (libraryId != null) return catalogApi.getItemDetail(contentId, libraryId) - catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } return getItemDetail(contentId) } @@ -237,32 +248,34 @@ class CatalogRepository( suspend fun getSeasons(seriesId: String, libraryId: Int? = null, fresh: Boolean = false): ApiResult<SeasonsResponse> { if (libraryId != null) return catalogApi.getSeasons(seriesId, libraryId) val requestIdentityGeneration = identityTransitions.generation.value - if (fresh) return fetchSeasons(seriesId, requestIdentityGeneration, serveCacheOnFailure = false) + val requestLanguageRevision = currentLanguageRevision + if (fresh) return fetchSeasons(seriesId, requestIdentityGeneration, requestLanguageRevision, serveCacheOnFailure = false) val warmRequest = detailRequestMutex.withLock { - seasonsInFlight[requestIdentityGeneration to seriesId] + seasonsInFlight[DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId)] } - return warmRequest?.await() ?: fetchSeasons(seriesId, requestIdentityGeneration) + return warmRequest?.await() ?: fetchSeasons(seriesId, requestIdentityGeneration, requestLanguageRevision) } /** Starts a process-owned live season-list warm-up for a pending detail route. */ suspend fun warmSeasons(seriesId: String): ApiResult<SeasonsResponse> { val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision return coalescedDetailRequest( requests = seasonsInFlight, - key = requestIdentityGeneration to seriesId, + key = DetailRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId), ) { - fetchSeasons(seriesId, requestIdentityGeneration) + fetchSeasons(seriesId, requestIdentityGeneration, requestLanguageRevision) } } /** Returns the last cached season list without touching the network. */ suspend fun getCachedSeasons(seriesId: String, libraryId: Int? = null): SeasonsResponse? = - if (libraryId != null) null else catalogCache.getCachedSeasons(seriesId) + if (libraryId != null) null else readLocalizedCache { catalogCache.getCachedSeasons(seriesId) } /** Cache-first season list for speculative detail navigation. */ suspend fun getSeasonsForPrefetch(seriesId: String, libraryId: Int? = null): ApiResult<SeasonsResponse> { if (libraryId != null) return catalogApi.getSeasons(seriesId, libraryId) - catalogCache.getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } + getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } return getSeasons(seriesId) } @@ -278,11 +291,12 @@ class CatalogRepository( ): ApiResult<EpisodesResponse> { if (libraryId != null) return catalogApi.getEpisodes(seriesId, seasonNumber, libraryId) val requestIdentityGeneration = identityTransitions.generation.value - if (fresh) return fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, serveCacheOnFailure = false) - val requestKey = EpisodesRequestKey(requestIdentityGeneration, seriesId, seasonNumber) + val requestLanguageRevision = currentLanguageRevision + if (fresh) return fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, requestLanguageRevision, serveCacheOnFailure = false) + val requestKey = EpisodesRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId, seasonNumber) val warmRequest = detailRequestMutex.withLock { episodesInFlight[requestKey] } return warmRequest?.await() - ?: fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration) + ?: fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, requestLanguageRevision) } /** Starts a process-owned live episode-list warm-up for a pending detail route. */ @@ -291,17 +305,18 @@ class CatalogRepository( seasonNumber: Int, ): ApiResult<EpisodesResponse> { val requestIdentityGeneration = identityTransitions.generation.value + val requestLanguageRevision = currentLanguageRevision return coalescedDetailRequest( requests = episodesInFlight, - key = EpisodesRequestKey(requestIdentityGeneration, seriesId, seasonNumber), + key = EpisodesRequestKey(requestIdentityGeneration, requestLanguageRevision, seriesId, seasonNumber), ) { - fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration) + fetchEpisodes(seriesId, seasonNumber, requestIdentityGeneration, requestLanguageRevision) } } /** Returns one cached season's episodes without touching the network. */ suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int, libraryId: Int? = null): EpisodesResponse? = - if (libraryId != null) null else catalogCache.getCachedEpisodes(seriesId, seasonNumber) + if (libraryId != null) null else readLocalizedCache { catalogCache.getCachedEpisodes(seriesId, seasonNumber) } /** Cache-first episode list for speculative detail navigation. */ suspend fun getEpisodesForPrefetch( @@ -310,7 +325,7 @@ class CatalogRepository( libraryId: Int? = null, ): ApiResult<EpisodesResponse> { if (libraryId != null) return catalogApi.getEpisodes(seriesId, seasonNumber, libraryId) - catalogCache.getCachedEpisodes(seriesId, seasonNumber)?.let { + getCachedEpisodes(seriesId, seasonNumber)?.let { return ApiResult.Success(it) } return getEpisodes(seriesId, seasonNumber) @@ -360,16 +375,17 @@ class CatalogRepository( private suspend fun fetchItemDetail( contentId: String, requestIdentityGeneration: Long, + requestLanguageRevision: Long, ): ApiResult<ItemDetail> { val result = catalogApi.getItemDetail(contentId) if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + writeLocalizedCache(requestIdentityGeneration, requestLanguageRevision) { cacheWriteLease -> catalogCache.cacheItemDetail(contentId, result.data, cacheWriteLease) } return result } if (result.canServeCache()) { - catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } } return result } @@ -377,17 +393,18 @@ class CatalogRepository( private suspend fun fetchSeasons( seriesId: String, requestIdentityGeneration: Long, + requestLanguageRevision: Long, serveCacheOnFailure: Boolean = true, ): ApiResult<SeasonsResponse> { val result = catalogApi.getSeasons(seriesId) if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + writeLocalizedCache(requestIdentityGeneration, requestLanguageRevision) { cacheWriteLease -> catalogCache.cacheSeasons(seriesId, result.data, cacheWriteLease) } return result } if (serveCacheOnFailure && result.canServeCache()) { - catalogCache.getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } + getCachedSeasons(seriesId)?.let { return ApiResult.Success(it) } } return result } @@ -396,11 +413,12 @@ class CatalogRepository( seriesId: String, seasonNumber: Int, requestIdentityGeneration: Long, + requestLanguageRevision: Long, serveCacheOnFailure: Boolean = true, ): ApiResult<EpisodesResponse> { val result = catalogApi.getEpisodes(seriesId, seasonNumber) if (result is ApiResult.Success) { - writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + writeLocalizedCache(requestIdentityGeneration, requestLanguageRevision) { cacheWriteLease -> catalogCache.cacheEpisodes( seriesId, seasonNumber, @@ -411,13 +429,59 @@ class CatalogRepository( return result } if (serveCacheOnFailure && result.canServeCache()) { - catalogCache.getCachedEpisodes(seriesId, seasonNumber)?.let { + getCachedEpisodes(seriesId, seasonNumber)?.let { return ApiResult.Success(it) } } return result } + private val localizedCacheMutex = Mutex() + private val invalidatedLanguageRevisions = mutableMapOf<CatalogCacheScope?, Long>() + private val currentLanguageRevision: Long + get() = catalogLanguage?.changes?.revision ?: 0L + + /** Purge durable descriptions before a new language can reuse them. */ + suspend fun invalidateLocalizedMetadata() { + localizedCacheMutex.withLock { synchronizeLocalizedCache() } + } + + // Called with localizedCacheMutex held, including across Room writes. An + // old write completes before the purge, or fails its language check after it. + private suspend fun synchronizeLocalizedCache(): Long { + val changes = catalogLanguage?.changes ?: return 0L + // Purge the writer's scope even after a profile/server switch. Track + // each owner independently, including changes before lazy construction. + for ((scope, revision) in changes.scopes) { + if (revision > (invalidatedLanguageRevisions[scope] ?: 0L)) { + catalogCache.invalidateDetailMetadata(scope) + invalidatedLanguageRevisions[scope] = revision + } + } + return changes.revision + } + + private suspend fun <T> readLocalizedCache(read: suspend () -> T?): T? = + localizedCacheMutex.withLock { + val revision = synchronizeLocalizedCache() + read().takeIf { revision == currentLanguageRevision } + } + + private suspend fun writeLocalizedCache( + requestIdentityGeneration: Long, + requestLanguageRevision: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, + ) { + localizedCacheMutex.withLock { + val revision = synchronizeLocalizedCache() + if (requestLanguageRevision == revision && + requestIdentityGeneration == identityTransitions.generation.value + ) { + write(CatalogCacheWriteLease(requestIdentityGeneration)) + } + } + } + private suspend fun writeIfIdentityUnchanged( requestGeneration: Long, write: suspend (CatalogCacheWriteLease) -> Unit, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/CatalogCachePort.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/CatalogCachePort.kt index b8e910bd53..295c383c24 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/CatalogCachePort.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/port/CatalogCachePort.kt @@ -14,6 +14,9 @@ import org.siloserver.silo.network.ApiResult */ data class CatalogCacheWriteLease(val identityGeneration: Long) +/** The durable cache owner, without credentials or a device session. */ +data class CatalogCacheScope(val serverId: String, val profileId: String) + /** * Offline read cache for catalog browse (Track B). Backs repository-level * cache-with-fallback in [org.siloserver.silo.repository.PersonalDataRepository] @@ -27,6 +30,9 @@ data class CatalogCacheWriteLease(val identityGeneration: Long) * network-only. Scope `(serverId, profileId)` is resolved inside the impl. */ interface CatalogCachePort { + /** Drop localized details, seasons, and episodes for the active profile. */ + suspend fun invalidateDetailMetadata(scope: CatalogCacheScope? = null) {} + /** V2 library-section cache entries require the exact initiating authority. */ suspend fun cacheLibrarySectionsV2(libraryId: Int, sections: List<ResolvedSection>, owner: org.siloserver.silo.network.AuthScopeSnapshot) {} suspend fun getCachedLibrarySectionsV2(libraryId: Int, owner: org.siloserver.silo.network.AuthScopeSnapshot): List<ResolvedSection>? = null diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt index 164a69c611..1fc1899fc0 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive @@ -66,6 +68,7 @@ class HomeViewModel( private val homeRealtime: org.siloserver.silo.repository.HomeRealtimeCoordinator? = null, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val diagnostics: HomeDiagnosticsObserver = HomeDiagnosticsObserver.None, + private val catalogLanguage: org.siloserver.silo.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { private var displayedOwner: AuthScopeSnapshot? = null @@ -94,6 +97,20 @@ class HomeViewModel( init { loadSections() + catalogLanguage?.let { language -> + viewModelScope.launch { + // A metadata-language change re-localizes every card: re-read + // quietly. Unlike refreshFromRealtime this never skips, since a + // fetch already in flight still asked for the old language. + language.revision.drop(1).collectLatest { + val generation = fetchSections(HomeLoadTrigger.REALTIME) + if (generation == fetchGeneration) { + _uiState.update { it.copy(isRefreshing = false) } + runPendingRealtimeRefresh() + } + } + } + } homeRealtime?.let { coordinator -> viewModelScope.launch { coordinator.refreshSignals.collect { refreshFromRealtime() } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RecommendationsViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RecommendationsViewModel.kt index 8be823c5bf..c0d97aa662 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RecommendationsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/RecommendationsViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch data class RecommendationsUiState( @@ -27,6 +28,7 @@ data class RecommendationsUiState( class RecommendationsViewModel( private val recommendationRepository: RecommendationRepository, + catalogLanguage: org.siloserver.silo.domain.settings.CatalogLanguageRevision? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(RecommendationsUiState()) @@ -35,7 +37,13 @@ class RecommendationsViewModel( private var loadGeneration = 0L private var loadJob: kotlinx.coroutines.Job? = null - init { loadRecommendations() } + init { + loadRecommendations() + catalogLanguage?.let { language -> + // Re-read the localized cards after a metadata-language change. + viewModelScope.launch { language.revision.drop(1).collect { refresh() } } + } + } fun loadRecommendations() = startLoad(refreshing = false) fun refresh() = startLoad(refreshing = true) diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsControllerTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsControllerTest.kt index 1f488ac7bd..0ec652cf6f 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsControllerTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/domain/settings/ProfileSettingsControllerTest.kt @@ -8,9 +8,12 @@ import org.siloserver.silo.model.settings.SettingKeys import org.siloserver.silo.model.settings.SettingsContractCapabilities import org.siloserver.silo.model.settings.StoredSettingValue import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.repository.port.CatalogCacheScope import org.siloserver.silo.network.api.SettingsApi import org.siloserver.silo.repository.SettingsRepository import io.ktor.client.HttpClient +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -37,9 +40,13 @@ class ProfileSettingsControllerTest { ApiResult.Success(StoredSettingValue(key = it, scope = "profile")) }, val deleteResult: ApiResult<Unit> = ApiResult.Success(Unit), + val effectiveHook: suspend () -> Unit = {}, + val deleteHook: suspend () -> Unit = {}, ) : SettingsApi(org.siloserver.silo.network.apiv2.SettingsV2Api(HttpClient(), org.siloserver.silo.network.TokenManagerImpl(), org.siloserver.silo.network.apiv2.ApiV2Gate.Unrestricted)) { val calls = mutableListOf<Call>() + val writeAuthorities = mutableListOf<AuthScopeSnapshot?>() + val readAuthorities = mutableListOf<AuthScopeSnapshot?>() override suspend fun getContractCapabilities(): ApiResult<SettingsContractCapabilities> = capabilities @@ -48,7 +55,11 @@ class ProfileSettingsControllerTest { libraryIds: List<Int>, seriesIds: List<String>, authority: org.siloserver.silo.network.AuthScopeSnapshot?, - ): ApiResult<EffectiveSettingValuesResponse> = effective + ): ApiResult<EffectiveSettingValuesResponse> { + readAuthorities += authority + effectiveHook() + return effective + } override suspend fun putValue( key: String, @@ -57,6 +68,7 @@ class ProfileSettingsControllerTest { profileId: String?, authority: org.siloserver.silo.network.AuthScopeSnapshot?, ): ApiResult<StoredSettingValue> { + writeAuthorities += authority calls += Call.Put(key, scope.scope, value) return putResult(key) } @@ -67,7 +79,9 @@ class ProfileSettingsControllerTest { profileId: String?, authority: org.siloserver.silo.network.AuthScopeSnapshot?, ): ApiResult<Unit> { + writeAuthorities += authority calls += Call.Delete(key, scope.scope) + deleteHook() return deleteResult } } @@ -127,6 +141,94 @@ class ProfileSettingsControllerTest { ) } + @Test + fun `only a metadata language write that lands bumps the catalog language revision`() = runTest { + val revision = CatalogLanguageRevision() + val controller = ProfileSettingsController(SettingsRepository(FakeSettingsApi()), revision) + + controller.setSubtitleLanguage("nl") + assertEquals(0L, revision.revision.value) + controller.setMetadataLanguage("nl") + assertEquals(1L, revision.revision.value) + + val failing = ProfileSettingsController( + SettingsRepository(FakeSettingsApi(putResult = { ApiResult.Error(500, "server_error", "Failed") })), + revision, + ) + failing.setMetadataLanguage("de") + assertEquals(1L, revision.revision.value) + } + + @Test + fun `committed language write invalidates cards even when settings reload is canceled`() = runTest { + val revision = CatalogLanguageRevision() + val api = FakeSettingsApi(effectiveHook = { throw kotlinx.coroutines.CancellationException("Settings closed") }) + var invalidated = false + val controller = ProfileSettingsController(SettingsRepository(api), revision) { invalidated = true } + kotlin.test.assertFailsWith<kotlinx.coroutines.CancellationException> { + controller.setMetadataLanguage("de") + } + assertEquals(1L, revision.revision.value) + assertEquals(1, api.calls.size) + assertTrue(invalidated) + } + + @Test + fun `cancellation after committed language write still finishes durable invalidation`() = runTest { + val entered = kotlinx.coroutines.CompletableDeferred<Unit>() + val release = kotlinx.coroutines.CompletableDeferred<Unit>() + var invalidated = false + val controller = ProfileSettingsController(SettingsRepository(FakeSettingsApi())) { + entered.complete(Unit) + release.await() + invalidated = true + } + val job = launch { + controller.setMetadataLanguage("de") + } + entered.await() + job.cancel() + release.complete(Unit) + job.join() + assertTrue(invalidated) + } + + @Test + fun `metadata write clear signal and reread retain the initiating owner after a switch`() = runTest { + val a = AuthScopeSnapshot("server", "a", "https://example.test", null) + val b = a.copy(profileId = "b") + for (tag in listOf("de", "")) { + var active = a + val language = CatalogLanguageRevision() + val api = FakeSettingsApi( + putResult = { + active = b + ApiResult.Success(StoredSettingValue(key = it, scope = "profile")) + }, + deleteHook = { active = b }, + ) + val controller = ProfileSettingsController( + SettingsRepository(api), language, captureAuthority = { active }, + ) + assertTrue(controller.setMetadataLanguage(tag).succeeded) + assertEquals(b, active) + assertEquals<List<AuthScopeSnapshot?>>(listOf(a), api.writeAuthorities) + assertEquals<List<AuthScopeSnapshot?>>(listOf(a), api.readAuthorities) + assertEquals<Map<CatalogCacheScope?, Long>>(mapOf(CatalogCacheScope("server", "a") to 1L), language.changes.scopes) + } + } + + @Test + fun `metadata change without a captured profile sends no request or signal`() = runTest { + val api = FakeSettingsApi() + val language = CatalogLanguageRevision() + val controller = ProfileSettingsController(SettingsRepository(api), language, captureAuthority = { null }) + assertFalse(controller.setMetadataLanguage("de").succeeded) + assertTrue(api.calls.isEmpty()) + assertTrue(api.readAuthorities.isEmpty()) + assertEquals(0L, language.revision.value) + } + @Test fun `clearing an absent value succeeds`() = runTest { // 404 means nothing was stored there, which is the state the caller diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/metadata/DescriptionTranslationControllerTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/metadata/DescriptionTranslationControllerTest.kt index 9f195b0c2d..5b3b4a46c9 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/metadata/DescriptionTranslationControllerTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/metadata/DescriptionTranslationControllerTest.kt @@ -77,8 +77,117 @@ class DescriptionTranslationControllerTest { ) assertEquals(DescriptionTranslationPhase.Failed, controller.phase.value) - // Apple's bounded backoff schedule has 8 slots. - assertEquals(8, polls) + // Bounded backoff: 11 slots, 45 seconds in all (the web client's budget). + assertEquals(11, polls) + } + + @Test + fun pollBudgetSpansFortyFiveSeconds() = runTest { + val api = FakeApi(translateResult = ApiResult.Success(org.siloserver.silo.model.metadata.MetadataTranslationJob("1", "season", "season-1", "nl", "pending"))) + var waitedMs = 0L + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { waitedMs += it }, + ) + + controller.translate( + contentId = "season-1", + targetLanguage = "nl", + refetchPendingLanguage = { "nl" }, + onTranslated = { }, + ) + + assertEquals(45_000L, waitedMs) + } + + @Test + fun onPollRunsBeforeEveryRefetchSoDependentListsRefresh() = runTest { + val api = FakeApi(translateResult = ApiResult.Success(org.siloserver.silo.model.metadata.MetadataTranslationJob("1", "season", "season-1", "nl", "pending"))) + val events = mutableListOf<String>() + var polls = 0 + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { }, + ) + + controller.translate( + contentId = "season-1", + targetLanguage = "nl", + refetchPendingLanguage = { + polls += 1 + events += "refetch" + if (polls < 2) "nl" else null + }, + onTranslated = { events += "translated" }, + onPoll = { events += "poll" }, + ) + + assertEquals(listOf("poll", "refetch", "poll", "refetch", "translated"), events) + } + + @Test + fun runningContentIdNamesTheItemBeingTranslated() = runTest { + val api = FakeApi(translateResult = ApiResult.Success(org.siloserver.silo.model.metadata.MetadataTranslationJob("1", "item", "movie-1", "nl", "pending"))) + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { }, + ) + var seenWhilePolling: String? = null + var phaseWhilePolling: DescriptionTranslationPhase? = null + + assertEquals(null, controller.runningContentId.value) + controller.translate( + contentId = "movie-1", + targetLanguage = "nl", + refetchPendingLanguage = { + seenWhilePolling = controller.runningContentId.value + phaseWhilePolling = controller.phase.value + null + }, + onTranslated = { }, + ) + + assertEquals("movie-1", seenWhilePolling) + assertEquals(DescriptionTranslationPhase.Translating, phaseWhilePolling) + assertEquals(DescriptionTranslationPhase.Idle, controller.phase.value) + } + + @Test + fun buttonTriggerIsSingleFlightWhileATranslationRuns() = runTest { + var translateCalls = 0 + val api = object : MetadataAiApi { + override suspend fun status(): ApiResult<MetadataAiStatus> = + ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun translateDescription( + contentId: String, + targetLanguage: String, + scope: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult<org.siloserver.silo.model.metadata.MetadataTranslationJob> { + translateCalls += 1 + return ApiResult.Success(org.siloserver.silo.model.metadata.MetadataTranslationJob("1", "item", contentId, targetLanguage, "pending")) + } + } + val controller = DescriptionTranslationController( + repository = MetadataAiRepository(api), + delayMs = { }, + ) + + controller.translate( + contentId = "movie-1", + targetLanguage = "nl", + refetchPendingLanguage = { + // A second press while the first job polls must not queue another. + controller.translate("movie-1", "nl", refetchPendingLanguage = { null }, onTranslated = { }) + null + }, + onTranslated = { }, + ) + + assertEquals(1, translateCalls) + // Once idle again, the button can fire a fresh request. + controller.translate("movie-1", "nl", refetchPendingLanguage = { null }, onTranslated = { }) + assertEquals(2, translateCalls) } @Test @@ -100,6 +209,48 @@ class DescriptionTranslationControllerTest { assertEquals(DescriptionTranslationPhase.Failed, controller.phase.value) } + @Test + fun aSeasonSwitchedToWhileAnotherTranslatesIsDeferredNotLatched() = runTest { + val started = mutableListOf<String>() + val api = object : MetadataAiApi { + override suspend fun status(): ApiResult<MetadataAiStatus> = + ApiResult.NetworkError(IllegalStateException("not used")) + + override suspend fun translateDescription( + contentId: String, + targetLanguage: String, + scope: org.siloserver.silo.network.AuthScopeSnapshot?, + ): ApiResult<org.siloserver.silo.model.metadata.MetadataTranslationJob> { + started += contentId + return ApiResult.Success(org.siloserver.silo.model.metadata.MetadataTranslationJob("1", "season", contentId, targetLanguage, "pending")) + } + } + val controller = DescriptionTranslationController(MetadataAiRepository(api), delayMs = { }) + var claimedWhileBusy: Boolean? = null + + // Season 1's job runs; the viewer switches to season 2 mid-poll. + assertTrue(controller.claimAutoFire("season-1", "de")) + controller.translate( + contentId = "season-1", + targetLanguage = "de", + refetchPendingLanguage = { + claimedWhileBusy = controller.claimAutoFire("season-2", "de") + null + }, + onTranslated = { }, + ) + + assertEquals(false, claimedWhileBusy) + // The refused claim is reported once, and season 2 was not latched. + assertTrue(controller.takeDeferredAuto()) + assertEquals(false, controller.takeDeferredAuto()) + assertTrue(controller.claimAutoFire("season-2", "de")) + controller.translate("season-2", "de", refetchPendingLanguage = { null }, onTranslated = { }) + assertEquals(listOf("season-1", "season-2"), started) + // Season 1 stays latched for the session. + assertEquals(false, controller.claimAutoFire("season-1", "de")) + } + @Test fun autoFireLatchesPerContentAndLanguage() { val controller = DescriptionTranslationController( diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/EpisodeTranslationMergeTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/EpisodeTranslationMergeTest.kt new file mode 100644 index 0000000000..032795a08b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/catalog/EpisodeTranslationMergeTest.kt @@ -0,0 +1,50 @@ +package org.siloserver.silo.model.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EpisodeTranslationMergeTest { + @Test fun translationResponsePreservesWatchedProgressAndFiles() { + val current = EpisodeListItem("episode", 1, 1, overview = "Original", + userData = LeafItemUserData(played = true, positionSeconds = 42.0), + files = listOf(EpisodeFile(17))) + val stale = current.copy(overview = "Localized", userData = LeafItemUserData(played = false), + files = emptyList(), machineTranslatedFields = listOf("overview")) + val merged = current.withTranslatedDescription(stale) + assertEquals(current.userData, merged.userData) + assertEquals(current.files, merged.files) + assertEquals("Localized", merged.overview) + assertEquals(listOf("overview"), merged.machineTranslatedFields) + } + + @Test fun sameTextStillUpdatesProvenanceAndClearsPendingLanguage() { + val detail = ItemDetail("episode", "episode", title = "Episode", overview = "Same", + pendingTranslationLanguage = "de", userData = LeafItemUserData(played = true)) + val row = EpisodeListItem("episode", 1, 1, overview = "Same", + machineTranslatedFields = listOf("overview")) + val merged = detail.withEpisodeDescription(row) + assertEquals(listOf("overview"), merged.machineTranslatedFields) + assertNull(merged.pendingTranslationLanguage) + assertEquals(detail.userData, merged.userData) + } + + @Test fun lateDetailUsesCurrentTranslationWithoutReplacingPlaybackMetadata() { + val late = ItemDetail("episode", "episode", title = "Episode", overview = "Original", + pendingTranslationLanguage = "de", userData = LeafItemUserData(positionSeconds = 23.0)) + val row = EpisodeListItem("episode", 1, 1, overview = "Localized", + machineTranslatedFields = listOf("overview")) + val merged = late.withEpisodeDescription(row) + assertEquals("Localized", merged.overview) + assertEquals(late.userData, merged.userData) + assertNull(merged.pendingTranslationLanguage) + assertEquals(late, late.withEpisodeDescription(row.copy(contentId = "other"))) + } + @Test fun pendingRowCannotReplaceCompletedDetail() { + val detail = ItemDetail("episode", "episode", title = "Episode", overview = "Localized", + machineTranslatedFields = listOf("overview")) + val stale = EpisodeListItem("episode", 1, 1, overview = "Original", pendingTranslationLanguage = "de") + assertEquals(detail, detail.withEpisodeDescription(stale)) + } + +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/MachineTranslationDecodingTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/MachineTranslationDecodingTest.kt new file mode 100644 index 0000000000..c8771d0690 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/MachineTranslationDecodingTest.kt @@ -0,0 +1,107 @@ +package org.siloserver.silo.network.apiv2 + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.siloserver.silo.model.catalog.BrowseItem +import org.siloserver.silo.model.catalog.Season +import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview +import org.siloserver.silo.model.catalog.pendingEpisodeTranslationLanguage +import org.siloserver.silo.network.SiloJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MachineTranslationDecodingTest { + private fun obj(json: String): JsonObject = SiloJson.parseToJsonElement(json).jsonObject + + @Test + fun detailCarriesMachineTranslatedFieldsAndPendingLanguage() { + val detail = SiloJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","overview":"Tekst","tagline":"Regel", + "pending_translation_language":"nl","machine_translated_fields":["overview","tagline"], + "cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + + assertEquals(listOf("overview", "tagline"), detail.machineTranslatedFields) + assertEquals("nl", detail.pendingTranslationLanguage) + assertTrue(hasMachineTranslatedOverview(detail.machineTranslatedFields)) + } + + @Test + fun absentOrNullFieldsDecodeAsNotTranslated() { + val absent = SiloJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + val nulled = SiloJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","machine_translated_fields":null, + "cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + + assertEquals(emptyList(), absent.machineTranslatedFields) + assertEquals(emptyList(), nulled.machineTranslatedFields) + assertFalse(hasMachineTranslatedOverview(absent.machineTranslatedFields)) + } + + @Test + fun episodeRowsCarryBothFields() { + val rows = listOf( + """{"content_id":"e1","season_number":1,"episode_number":1,"overview":"Vertaald","machine_translated_fields":["overview"]}""", + """{"content_id":"e2","season_number":1,"episode_number":2,"overview":"Original","pending_translation_language":"nl"}""", + ).map { SiloJson.decodeFromString<EpisodeListItemReadV2>(it).toDomain() } + + assertEquals(listOf("overview"), rows[0].machineTranslatedFields) + assertNull(rows[0].pendingTranslationLanguage) + assertEquals("nl", rows[1].pendingTranslationLanguage) + assertEquals(emptyList(), rows[1].machineTranslatedFields) + assertEquals("nl", rows.pendingEpisodeTranslationLanguage()) + assertNull(rows.take(1).pendingEpisodeTranslationLanguage()) + } + + @Test + fun seasonRowsCarryMachineTranslatedFields() { + val season = SiloJson.decodeFromString<Season>( + """{"content_id":"s1","season_number":1,"overview":"Vertaald","machine_translated_fields":["overview"]}""", + ) + + assertEquals(listOf("overview"), season.machineTranslatedFields) + } + + @Test + fun sectionCardsCarryBothFields() { + val section = decodeSectionV2( + obj( + """{"id":"featured","section_type":"featured","title":"Featured","items":[ + {"content_id":"m1","type":"movie","title":"Film","overview":"Vertaald","machine_translated_fields":["overview"]}, + {"content_id":"m2","type":"movie","title":"Other","overview":"Original","pending_translation_language":"nl"} + ]}""", + ), + ) + + assertEquals(listOf("overview"), section.items[0].machineTranslatedFields) + assertNull(section.items[0].pendingTranslationLanguage) + assertEquals("nl", section.items[1].pendingTranslationLanguage) + } + + @Test + fun catalogCardsCarryBothFields() { + val card = SiloJson.decodeFromString<BrowseItem>( + """{"content_id":"m1","type":"movie","title":"Film","pending_translation_language":"nl","machine_translated_fields":["tagline"]}""", + ) + + assertEquals("nl", card.pendingTranslationLanguage) + assertEquals(listOf("tagline"), card.machineTranslatedFields) + } + + @Test + fun anAiTaglineAloneDoesNotMarkAProviderOverview() { + val detail = SiloJson.decodeFromString<ItemDetailReadV2>( + """{"content_id":"m1","type":"movie","title":"Film","overview":"Anbieter: Text","tagline":"[German] Line", + "machine_translated_fields":["tagline"],"cast":[],"crew":[],"versions":[],"subtitles":[]}""", + ).toDomain() + + // Overview-only surfaces (detail heroes, episode rows, marquee, cards). + assertFalse(hasMachineTranslatedOverview(detail.machineTranslatedFields)) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt index 3a604c4f30..91b4ad4290 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/CatalogRepositoryDetailCacheTest.kt @@ -11,8 +11,10 @@ import org.siloserver.silo.network.SiloJson import org.siloserver.silo.network.api.CatalogApi import org.siloserver.silo.repository.port.CatalogCachePort import org.siloserver.silo.repository.port.CatalogCacheWriteLease +import org.siloserver.silo.repository.port.CatalogCacheScope import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockEngineConfig import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.HttpHeaders @@ -36,9 +38,9 @@ import kotlin.test.assertTrue class CatalogRepositoryDetailCacheTest { private class FakeCache( - val preset: ItemDetail? = null, - val seasonsPreset: SeasonsResponse? = null, - val episodesPreset: EpisodesResponse? = null, + var preset: ItemDetail? = null, + var seasonsPreset: SeasonsResponse? = null, + var episodesPreset: EpisodesResponse? = null, val identityTransitions: IdentityTransitionBarrier? = null, val beforeItemCache: suspend () -> Unit = {}, ) : CatalogCachePort { @@ -54,8 +56,22 @@ class CatalogRepositoryDetailCacheTest { lease.identityGeneration == identityTransitions.generation.value ) { cachedId = contentId + preset = detail } } + val invalidatedScopes = mutableListOf<CatalogCacheScope?>() + override suspend fun invalidateDetailMetadata(scope: CatalogCacheScope?) { + invalidatedScopes += scope + preset = null + seasonsPreset = null + episodesPreset = null + } + override suspend fun cacheSeasons(seriesId: String, response: SeasonsResponse) { + seasonsPreset = response + } + override suspend fun cacheEpisodes(seriesId: String, seasonNumber: Int, response: EpisodesResponse) { + episodesPreset = response + } override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = preset override suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = seasonsPreset override suspend fun getCachedEpisodes( @@ -322,4 +338,139 @@ class CatalogRepositoryDetailCacheTest { assertTrue(repository.getItemDetail("c1") is ApiResult.Success) assertEquals(null, cache.cachedId) } + @Test + fun languageChangeRejectsOldWarmupsAndReplacesAllLocalizedCaches() = runTest { + val language = org.siloserver.silo.domain.settings.CatalogLanguageRevision() + val oldRequestsEntered = CompletableDeferred<Unit>() + val releaseOldResponses = CompletableDeferred<Unit>() + var oldCalls = 0 + var newCalls = 0 + val cache = FakeCache() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + dispatcher = StandardTestDispatcher(testScheduler) + addHandler { request -> + val old = language.revision.value == 0L + if (old) { + oldCalls++ + if (oldCalls == 3) oldRequestsEntered.complete(Unit) + releaseOldResponses.await() + } else { + newCalls++ + } + val title = if (old) "Old language" else "New language" + val body = when { + request.url.encodedPath.endsWith("/episodes") -> + """{"items":[{"content_id":"e1","season_number":1,"episode_number":1,"title":"$title","pending_translation_language":"de"}]}""" + request.url.encodedPath.endsWith("/seasons") -> + """{"items":[{"content_id":"s1","season_number":1,"title":"$title"}]}""" + else -> """{"content_id":"c1","type":"series","title":"$title","cast":[],"crew":[],"subtitles":[],"versions":[]}""" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + })) { install(ContentNegotiation) { json(SiloJson) } } + val repository = CatalogRepository( + CatalogApi(client), cache, + requestDispatcher = StandardTestDispatcher(testScheduler), + catalogLanguage = language, + ) + val oldWarmups = listOf( + async { repository.warmItemDetail("c1") }, + async { repository.warmSeasons("c1") }, + async { repository.warmEpisodes("c1", 1) }, + ) + oldRequestsEntered.await() + language.changed() + val destinations = listOf( + async { repository.getItemDetail("c1") }, + async { repository.getSeasonsForPrefetch("c1") }, + async { repository.getEpisodesForPrefetch("c1", 1) }, + ) + // Drain new requests without releasing the old responses. A destination + // joining an old warmup would still be waiting here. + testScheduler.advanceUntilIdle() + val allDestinationsFinished = destinations.all { it.isCompleted } + releaseOldResponses.complete(Unit) + oldWarmups.awaitAll() + destinations.awaitAll() + assertTrue(allDestinationsFinished) + assertEquals(3, newCalls) + assertEquals("New language", repository.getCachedItemDetail("c1")?.title) + assertEquals("New language", repository.getCachedSeasons("c1")?.seasons?.single()?.title) + assertEquals("New language", repository.getCachedEpisodes("c1", 1)?.episodes?.single()?.title) + assertEquals("de", repository.getCachedEpisodes("c1", 1)?.episodes?.single()?.pendingTranslationLanguage) + } + + @Test + fun languageChangeClearsCachedPrefetchAndOfflineFallbacks() = runTest { + val language = org.siloserver.silo.domain.settings.CatalogLanguageRevision() + val cache = FakeCache( + preset = ItemDetail(contentId = "c1", type = "series", title = "Old"), + seasonsPreset = SeasonsResponse(), + episodesPreset = EpisodesResponse(), + ) + val client = HttpClient(MockEngine { + respond("{}", HttpStatusCode.ServiceUnavailable, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(SiloJson) } } + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + language.changed() + assertTrue(repository.getItemDetailForPrefetch("c1") is ApiResult.Error) + assertTrue(repository.getSeasonsForPrefetch("c1") is ApiResult.Error) + assertTrue(repository.getEpisodesForPrefetch("c1", 1) is ApiResult.Error) + } + + @Test + fun languageInvalidationWaitsForAnAdmittedCacheWriteThenPurgesIt() = runTest { + val language = org.siloserver.silo.domain.settings.CatalogLanguageRevision() + val writeEntered = CompletableDeferred<Unit>() + val releaseWrite = CompletableDeferred<Unit>() + val cache = FakeCache(beforeItemCache = { + writeEntered.complete(Unit) + releaseWrite.await() + }) + val client = HttpClient(MockEngine { + respond("""{"content_id":"c1","type":"movie","title":"Old","cast":[],"crew":[],"subtitles":[],"versions":[]}""", + HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) { install(ContentNegotiation) { json(SiloJson) } } + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + val oldRequest = async { repository.getItemDetail("c1") } + writeEntered.await() + language.changed() + val invalidation = async { repository.invalidateLocalizedMetadata() } + testScheduler.runCurrent() + val invalidationWaited = !invalidation.isCompleted + releaseWrite.complete(Unit) + oldRequest.await() + invalidation.await() + assertTrue(invalidationWaited) + assertEquals(null, repository.getCachedItemDetail("c1")) + } + + @Test + fun languageChangeBeforeLazyRepositoryConstructionStillPurgesPersistedMetadata() = runTest { + val language = org.siloserver.silo.domain.settings.CatalogLanguageRevision() + val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "series", title = "Old")) + language.changed() + val client = HttpClient(MockEngine { error("No live request expected") }) + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + repository.invalidateLocalizedMetadata() + assertEquals(null, repository.getCachedItemDetail("c1")) + } + + @Test + fun languageInvalidationTracksEveryWriterAndOnlyAcknowledgesItsOwnRevision() = runTest { + val language = org.siloserver.silo.domain.settings.CatalogLanguageRevision() + val a = CatalogCacheScope("server", "a") + val b = CatalogCacheScope("server", "b") + language.changed(a) + language.changed(b) + val cache = FakeCache() + val client = HttpClient(MockEngine { error("No live request expected") }) + val repository = CatalogRepository(CatalogApi(client), cache, catalogLanguage = language) + repository.invalidateLocalizedMetadata() + assertEquals<List<CatalogCacheScope?>>(listOf(a, b), cache.invalidatedScopes) + language.changed(a) + repository.invalidateLocalizedMetadata() + assertEquals<List<CatalogCacheScope?>>(listOf(a, b, a), cache.invalidatedScopes) + } + } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt index e2b734bc0d..a212001701 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt @@ -21,6 +21,7 @@ import kotlin.test.* class HomeScreenV2Test { private var owner = AuthScopeSnapshot("s", "p", "https://example.invalid", "pin", identityGeneration = 1) private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = owner } + private val language = org.siloserver.silo.domain.settings.CatalogLanguageRevision() private var title = "Old" private var unresolved = false private var failFallback = false @@ -61,7 +62,7 @@ class HomeScreenV2Test { val store = ViewModelStore() try { val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))) - val vm = HomeViewModel(repository, MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))), cache, local) + val vm = HomeViewModel(repository, MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))), cache, local, catalogLanguage = language) store.put("home", vm); block(vm) } finally { store.clear(); client.close(); Dispatchers.resetMain() } } @@ -96,4 +97,31 @@ class HomeScreenV2Test { gate.complete(Unit); runCurrent() assertEquals("New", vm.uiState.value.sections.single().items.single().title) } + @Test fun languageRefreshCompletesSupersededManualRefreshAndDrainsRealtime() = scenario { vm -> + runCurrent() + val gate = CompletableDeferred<Unit>() + var delayed = true + listHook = { if (delayed) { delayed = false; gate.await() } } + vm.refresh(); runCurrent() + assertTrue(vm.uiState.value.isRefreshing) + vm.refreshFromRealtime() + title = "Localized"; language.changed(); runCurrent() + assertFalse(vm.uiState.value.isRefreshing) + assertEquals(4, listReads) + gate.complete(Unit); runCurrent() + assertEquals("Localized", vm.uiState.value.sections.single().items.single().title) + } + + @Test fun newerLanguageCancelsOlderLanguageRead() = scenario { vm -> + runCurrent() + val gate = CompletableDeferred<Unit>() + var delayed = true + listHook = { if (delayed) { delayed = false; gate.await() } } + title = "First language"; language.changed(); runCurrent() + title = "Latest language"; language.changed(); runCurrent() + assertEquals("Latest language", vm.uiState.value.sections.single().items.single().title) + gate.complete(Unit); runCurrent() + assertEquals("Latest language", vm.uiState.value.sections.single().items.single().title) + } + } From 78f554ce8952bc851b598a77d130a32972668e09 Mon Sep 17 00:00:00 2001 From: Suspense <rhainland@gmail.com> Date: Sun, 11 Oct 2026 06:34:14 +0200 Subject: [PATCH 79/87] feat(tv): ask who's watching on a new Android TV and offer to remember (#542) * feat(tv): ask who's watching on a new Android TV and offer to remember A new Android TV now starts with Profile Selection set to Every Time. After the first profile is picked, Silo asks once whether to open that profile automatically: Remember switches to Automatic, Ask every time keeps asking. A TV that already remembers a profile keeps Automatic. * fix(tv): keep remembered profiles on other servers and drop stale remember offers The first-launch settle looked only at the active server's profile slot, so a TV whose active server had no profile selected but another saved server did was treated as new: it saved Every Time and cleared that server's profile on the next switch. Count a profile saved on any server. The remember offer could outlive its profile. If profile-verification recovery cleared the profile, or the grid's identity changed, while the dialog was open, either answer still saved and opened Home with no active profile. Drop the offer on those reloads, and save or navigate only while the offered profile is still the active one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tv): keep a fresh remember offer and settle only from a readable registry Clearing the offer whenever a grid reload found the identity changed also erased the offer from a pick committed while that reload was in flight, since the commit itself moves the identity. The viewer was left on an empty picker with the profile committed. Drop the offer only when its profile is no longer the active one. An unreadable server registry reports no servers, so the first-launch settle took an existing TV for a new one and saved Every Time for good. Skip the settle until the registry loads. Mark the launch resolved only after settling, so an Activity recreated mid-settle still settles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .../org/siloserver/silo/tv/MainTvActivity.kt | 12 ++ .../preferences/TvProfileLaunchPreferences.kt | 52 +++++- .../siloserver/silo/tv/di/AndroidTvModule.kt | 4 +- .../profiles/TvProfileSelectionScreen.kt | 24 +++ .../profiles/TvProfileSelectionViewModel.kt | 56 ++++++- .../TvProfileLaunchPreferencesTest.kt | 64 ++++++- .../profiles/TvProfileRememberOfferTest.kt | 156 ++++++++++++++++++ 7 files changed, 355 insertions(+), 13 deletions(-) create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt index e16d546337..fc6ba922ae 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt @@ -359,6 +359,18 @@ class MainTvActivity : ComponentActivity() { val registry = get<ServerRegistry>(ServerRegistry::class.java) val tokenManager = get<TokenManager>(TokenManager::class.java) val processStart = !hasResolvedLaunchProfile + // An unreadable registry hides which servers remember a profile, so + // settle on a later launch rather than mistake this TV for a new one. + if (processStart && !registry.stateLoadFailed) { + // The setting is device-wide, so a profile saved for any server + // makes this an existing TV, not just one on the active server. + val hasRememberedProfile = registry.entries.value.any { !it.profileId.isNullOrBlank() } || + !tokenManager.getProfileId().isNullOrBlank() + get<TvProfileLaunchPreferences>(TvProfileLaunchPreferences::class.java) + .settleFirstLaunch(hasRememberedProfile = hasRememberedProfile) + } + // Only once settled: an Activity recreated while this was suspended + // must still settle. hasResolvedLaunchProfile = true val activeEntry = registry.activeEntry.value diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt index 5a3febeede..ccdcca646d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt @@ -62,6 +62,12 @@ data class ProfileLaunchState( val behavior: ProfileLaunchBehavior = ProfileLaunchBehavior.Automatic, /** Epoch ms when Silo last went to the background unused; null while in use. */ val backgroundedAtMs: Long? = null, + /** + * A new TV offers the first profile picked on it as the one to open + * automatically. True until that offer is answered or a choice is made in + * Settings. + */ + val rememberOfferPending: Boolean = false, ) { /** * Cold start. Every Time asks even when the previous process never @@ -93,12 +99,34 @@ data class ProfileLaunchState( return since + timeout } - /** Automatic never keeps an away interval. */ + /** Automatic never keeps an away interval. Choosing a behavior answers the offer. */ fun withBehavior(behavior: ProfileLaunchBehavior): ProfileLaunchState = copy( behavior = behavior, backgroundedAtMs = if (behavior == ProfileLaunchBehavior.Automatic) null else backgroundedAtMs, + rememberOfferPending = false, ) + /** The answer to the offer: remember opens the picked profile automatically from now on. */ + fun answeringRememberOffer(remember: Boolean): ProfileLaunchState = when { + !rememberOfferPending -> this + remember -> withBehavior(ProfileLaunchBehavior.Automatic) + else -> copy(rememberOfferPending = false) + } + + companion object { + /** + * The state of a TV with no saved choice. A new TV asks who's watching + * and offers to remember the first profile picked; a TV that already + * remembers a profile (it predates this setting) keeps opening it. + */ + fun firstLaunch(hasRememberedProfile: Boolean): ProfileLaunchState = + if (hasRememberedProfile) { + ProfileLaunchState(behavior = ProfileLaunchBehavior.Automatic) + } else { + ProfileLaunchState(behavior = ProfileLaunchBehavior.EveryTime, rememberOfferPending = true) + } + } + fun markedBackgrounded(atMs: Long): ProfileLaunchState = if (behavior == ProfileLaunchBehavior.Automatic) copy(backgroundedAtMs = null) else copy(backgroundedAtMs = atMs) @@ -140,6 +168,21 @@ class TvProfileLaunchPreferences( fun setBehavior(behavior: ProfileLaunchBehavior) = update { it.withBehavior(behavior) } + /** + * Saves the starting choice the first time this TV runs Silo; later + * launches keep what is saved. [hasRememberedProfile] says whether the TV + * already opens a profile from an earlier version. + */ + @Synchronized + fun settleFirstLaunch(hasRememberedProfile: Boolean) { + if (prefs.contains(KEY_BEHAVIOR)) return + write(ProfileLaunchState.firstLaunch(hasRememberedProfile)) + } + + val isRememberOfferPending: Boolean get() = state.value.rememberOfferPending + + fun answerRememberOffer(remember: Boolean) = update { it.answeringRememberOffer(remember) } + /** Start the away clock. A no-op under Automatic. */ fun markBackgrounded() = update { it.markedBackgrounded(clock()) } @@ -150,10 +193,15 @@ class TvProfileLaunchPreferences( private fun update(change: (ProfileLaunchState) -> ProfileLaunchState) { val next = change(_state.value) if (next == _state.value) return + write(next) + } + + private fun write(next: ProfileLaunchState) { prefs.edit { putString(KEY_BEHAVIOR, next.behavior.raw) val at = next.backgroundedAtMs if (at == null) remove(KEY_BACKGROUNDED_AT) else putLong(KEY_BACKGROUNDED_AT, at) + if (next.rememberOfferPending) putBoolean(KEY_REMEMBER_OFFER, true) else remove(KEY_REMEMBER_OFFER) } _state.value = next } @@ -161,11 +209,13 @@ class TvProfileLaunchPreferences( private fun read(): ProfileLaunchState = ProfileLaunchState( behavior = ProfileLaunchBehavior.fromRaw(prefs.getString(KEY_BEHAVIOR, null)), backgroundedAtMs = if (prefs.contains(KEY_BACKGROUNDED_AT)) prefs.getLong(KEY_BACKGROUNDED_AT, 0L) else null, + rememberOfferPending = prefs.getBoolean(KEY_REMEMBER_OFFER, false), ) private companion object { const val PREFS_NAME = "silo_tv_profile_launch" const val KEY_BEHAVIOR = "behavior" const val KEY_BACKGROUNDED_AT = "backgrounded_at_ms" + const val KEY_REMEMBER_OFFER = "remember_offer_pending" } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 6c6d932cf9..c82ad3f7dd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -426,7 +426,9 @@ val androidTvModule = module { advertisesSignIn = BuildConfig.DEBUG, ) } - viewModel { TvProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull()) } + viewModel { + TvProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull(), launchPreferences = get()) + } viewModel { org.siloserver.silo.tv.ui.screens.profiles.TvCreateProfileViewModel(get()) } viewModel { params -> org.siloserver.silo.tv.ui.screens.profiles.TvEditProfileViewModel( diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt index 1c3e5f38e8..a5009c4c67 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionScreen.kt @@ -327,6 +327,30 @@ fun TvProfileSelectionScreen( ) } + // A new TV offers to open the first profile picked automatically. + val rememberOffer = state.rememberOffer + if (rememberOffer != null) { + TvOptionDialog( + title = "Open ${rememberOffer.name} automatically?", + options = listOf( + TvDialogOption( + key = "remember", + title = "Remember", + subtitle = "Open ${rememberOffer.name} when Silo starts on this TV.", + onClick = { viewModel.answerRememberOffer(remember = true) }, + ), + TvDialogOption( + key = "ask", + title = "Ask every time", + subtitle = "Show Who's watching when Silo starts. Change this in Settings > General > Profile Selection.", + onClick = { viewModel.answerRememberOffer(remember = false) }, + ), + ), + onDismiss = { viewModel.answerRememberOffer(remember = false) }, + initialFocusKey = "remember", + ) + } + // Delete confirmation — modeled as a two-option dialog (TV-native). val deleteCandidate = state.deleteCandidate if (deleteCandidate != null) { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt index bdd471f4f1..2eab071354 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt @@ -2,6 +2,12 @@ package org.siloserver.silo.tv.ui.screens.profiles 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.siloserver.silo.common.ui.marquee.SignInHandoff import org.siloserver.silo.model.profile.Profile import org.siloserver.silo.model.profile.authorizedProfileToken import org.siloserver.silo.model.profile.householdPrimary @@ -12,12 +18,7 @@ import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.ProfileCommitResult import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.ProfileVerificationRecovery -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.siloserver.silo.common.ui.marquee.SignInHandoff +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences data class TvProfileSelectionUiState( val profiles: List<Profile> = emptyList(), @@ -42,6 +43,8 @@ data class TvProfileSelectionUiState( val accountName: String? = null, /** A one-profile household is being opened straight after sign-in; the picker stays hidden. */ val openingOnlyProfile: Boolean = false, + /** The profile just picked on a new TV, while the TV asks whether to open it automatically. */ + val rememberOffer: Profile? = null, ) /** @@ -54,6 +57,7 @@ class TvProfileSelectionViewModel( private val authRepository: AuthRepository? = null, private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, private val profileVerificationRecovery: ProfileVerificationRecovery? = null, + private val launchPreferences: TvProfileLaunchPreferences? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(TvProfileSelectionUiState()) @@ -89,10 +93,24 @@ class TvProfileSelectionViewModel( private fun reloadWhenStaleProfileCleared() { val recovery = profileVerificationRecovery ?: return viewModelScope.launch { - recovery.profileCleared.collect { loadProfiles() } + recovery.profileCleared.collect { + dropStaleRememberOffer() + loadProfiles() + } } } + /** + * Closes an open remember offer whose profile is no longer the active one + * (cleared by recovery, or another identity took over). An offer from a + * commit that landed after this check began stays. + */ + private suspend fun dropStaleRememberOffer() { + val offer = _uiState.value.rememberOffer ?: return + if (profileRepository.getActiveProfileId() == offer.id) return + _uiState.update { if (it.rememberOffer == offer) it.copy(rememberOffer = null) else it } + } + /** Cancel on a re-prompt for the primary's PIN ends the session; manage mode goes with it. */ private fun leaveManageModeWhenSessionEnds() { viewModelScope.launch { @@ -162,6 +180,9 @@ class TvProfileSelectionViewModel( deleteCandidate = null, ) } + // A pick committed while this load was in flight also moves + // the identity; its offer is still current. + dropStaleRememberOffer() return@launch } when (val result = listed) { @@ -403,6 +424,27 @@ class TvProfileSelectionViewModel( loadProfiles() return@launch } + if (result == ProfileCommitResult.Committed && launchPreferences?.isRememberOfferPending == true) { + // A new TV: ask whether to open this profile automatically before Home. + _uiState.update { it.copy(rememberOffer = profile) } + return@launch + } + _uiState.update { it.copy(selectedProfileId = profile.id) } + } + } + + /** + * Answers the new TV's offer and continues into the picked profile. The + * dialog can stay up indefinitely, so an answer for a profile that is no + * longer the active one (cleared by recovery meanwhile) neither saves nor + * navigates; the offer stays pending for the next pick. + */ + fun answerRememberOffer(remember: Boolean) { + val profile = _uiState.value.rememberOffer ?: return + _uiState.update { it.copy(rememberOffer = null) } + viewModelScope.launch { + if (profileRepository.getActiveProfileId() != profile.id) return@launch + launchPreferences?.answerRememberOffer(remember) _uiState.update { it.copy(selectedProfileId = profile.id) } } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt index cd32ae3f27..fd8780a14f 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt @@ -114,10 +114,63 @@ class TvProfileLaunchPreferencesTest { restarted.clearBackgroundedAt() assertFalse(TvProfileLaunchPreferences(prefs) { now }.requiresSelectionAtLaunch()) } + + @Test + fun `a new TV asks who's watching and offers to remember the first profile`() { + val prefs = InMemoryPrefs() + val store = TvProfileLaunchPreferences(prefs) { start } + store.settleFirstLaunch(hasRememberedProfile = false) + + assertEquals(ProfileLaunchBehavior.EveryTime, store.state.value.behavior) + assertTrue(store.isRememberOfferPending) + assertTrue(store.requiresSelectionAtLaunch()) + // Later launches keep what was saved. + val next = TvProfileLaunchPreferences(prefs) { start } + next.settleFirstLaunch(hasRememberedProfile = true) + assertEquals(ProfileLaunchBehavior.EveryTime, next.state.value.behavior) + assertTrue(next.isRememberOfferPending) + } + + @Test + fun `a TV that already opens a profile keeps Automatic and gets no offer`() { + val prefs = InMemoryPrefs() + TvProfileLaunchPreferences(prefs) { start }.settleFirstLaunch(hasRememberedProfile = true) + + val restarted = TvProfileLaunchPreferences(prefs) { start } + assertEquals(ProfileLaunchBehavior.Automatic, restarted.state.value.behavior) + assertFalse(restarted.isRememberOfferPending) + assertFalse(restarted.requiresSelectionAtLaunch()) + } + + @Test + fun `the offer is answered once and the answer is kept`() { + for (remember in listOf(true, false)) { + val prefs = InMemoryPrefs() + TvProfileLaunchPreferences(prefs) { start }.apply { + settleFirstLaunch(hasRememberedProfile = false) + answerRememberOffer(remember) + } + val restarted = TvProfileLaunchPreferences(prefs) { start } + assertFalse(restarted.isRememberOfferPending) + assertEquals( + if (remember) ProfileLaunchBehavior.Automatic else ProfileLaunchBehavior.EveryTime, + restarted.state.value.behavior, + ) + } + } + + @Test + fun `choosing in Settings ends the offer`() { + val prefs = InMemoryPrefs() + val store = TvProfileLaunchPreferences(prefs) { start } + store.settleFirstLaunch(hasRememberedProfile = false) + store.setBehavior(ProfileLaunchBehavior.AfterOneHour) + assertFalse(TvProfileLaunchPreferences(prefs) { start }.isRememberOfferPending) + } } -/** Just enough SharedPreferences for the store: strings and longs. */ -private class InMemoryPrefs : SharedPreferences { +/** Just enough SharedPreferences for the store: strings, longs and booleans. */ +internal class InMemoryPrefs : SharedPreferences { private val values = mutableMapOf<String, Any?>() override fun getAll(): MutableMap<String, *> = values.toMutableMap() @@ -126,7 +179,7 @@ private class InMemoryPrefs : SharedPreferences { override fun getInt(key: String?, defValue: Int): Int = defValue override fun getLong(key: String?, defValue: Long): Long = values[key] as Long? ?: defValue override fun getFloat(key: String?, defValue: Float): Float = defValue - override fun getBoolean(key: String?, defValue: Boolean): Boolean = defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as Boolean? ?: defValue override fun contains(key: String?): Boolean = key in values override fun edit(): SharedPreferences.Editor = Editor() override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} @@ -154,7 +207,10 @@ private class InMemoryPrefs : SharedPreferences { override fun putStringSet(key: String, values: MutableSet<String>?): SharedPreferences.Editor = this override fun putInt(key: String, value: Int): SharedPreferences.Editor = this override fun putFloat(key: String, value: Float): SharedPreferences.Editor = this - override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = this + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + pending[key] = value + return this + } override fun clear(): SharedPreferences.Editor = this override fun commit(): Boolean { apply() diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt new file mode 100644 index 0000000000..c3a7c923f1 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt @@ -0,0 +1,156 @@ +package org.siloserver.silo.tv.ui.screens.profiles + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DefaultIdentityTransitionBarrier +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.ProfileApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.tv.data.preferences.InMemoryPrefs +import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior +import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class TvProfileRememberOfferTest { + private val profile = Profile(id = "profile-a", name = "Ana") + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `an offer for a profile cleared while it was open neither saves nor opens Home`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val repository = OneProfileRepository(barrier, profile) + val preferences = TvProfileLaunchPreferences(InMemoryPrefs()).apply { + settleFirstLaunch(hasRememberedProfile = false) + } + val viewModel = TvProfileSelectionViewModel( + repository, + consumeSkipsSingleProfile = { false }, + launchPreferences = preferences, + ) + advanceUntilIdle() + + viewModel.onProfileSelected(profile) + advanceUntilIdle() + assertEquals(profile, viewModel.uiState.value.rememberOffer) + + // What profile-verification recovery does while the dialog is up. + repository.clearProfile() + viewModel.answerRememberOffer(remember = true) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.selectedProfileId) + assertNull(viewModel.uiState.value.rememberOffer) + assertTrue(preferences.isRememberOfferPending) + assertEquals(ProfileLaunchBehavior.EveryTime, preferences.state.value.behavior) + } + + @Test + fun `answering the offer for the active profile saves it and opens Home`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val repository = OneProfileRepository(barrier, profile) + val preferences = TvProfileLaunchPreferences(InMemoryPrefs()).apply { + settleFirstLaunch(hasRememberedProfile = false) + } + val viewModel = TvProfileSelectionViewModel( + repository, + consumeSkipsSingleProfile = { false }, + launchPreferences = preferences, + ) + advanceUntilIdle() + + viewModel.onProfileSelected(profile) + viewModel.answerRememberOffer(remember = true) + advanceUntilIdle() + + assertEquals(profile.id, viewModel.uiState.value.selectedProfileId) + assertEquals(ProfileLaunchBehavior.Automatic, preferences.state.value.behavior) + } + + @Test + fun `a reload outdated by the offer's own commit keeps the offer`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val reloadInFlight = CompletableDeferred<Unit>() + val repository = OneProfileRepository(barrier, profile) { call -> + if (call == 2) reloadInFlight.await() + } + val preferences = TvProfileLaunchPreferences(InMemoryPrefs()).apply { + settleFirstLaunch(hasRememberedProfile = false) + } + val viewModel = TvProfileSelectionViewModel( + repository, + consumeSkipsSingleProfile = { false }, + launchPreferences = preferences, + ) + advanceUntilIdle() + + // The viewer picks while a reload is in flight; the pick's commit + // moves the identity that reload was fetched under. + viewModel.loadProfiles() + viewModel.onProfileSelected(profile) + advanceUntilIdle() + assertEquals(profile, viewModel.uiState.value.rememberOffer) + reloadInFlight.complete(Unit) + advanceUntilIdle() + assertEquals(profile, viewModel.uiState.value.rememberOffer) + + viewModel.answerRememberOffer(remember = true) + advanceUntilIdle() + assertEquals(profile.id, viewModel.uiState.value.selectedProfileId) + } +} + +private class OneProfileRepository( + barrier: DefaultIdentityTransitionBarrier, + private val profile: Profile, + private val beforeList: suspend (Int) -> Unit = {}, +) : ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") }), ApiV2Gate.Unrestricted), + tokenManager = ScopedTokenManager(barrier), + identityTransitions = barrier, +) { + private var calls = 0 + + override suspend fun listProfiles(): ApiResult<List<Profile>> { + beforeList(++calls) + return ApiResult.Success(listOf(profile)) + } +} + +/** Reports the barrier generation as its scope, so identity changes are visible. */ +private class ScopedTokenManager( + private val barrier: DefaultIdentityTransitionBarrier, +) : TokenManager by TokenManagerImpl(barrier) { + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = AuthScopeSnapshot( + serverId = "server-a", + profileId = null, + serverUrl = "https://server-a", + profileToken = null, + identityGeneration = barrier.generation.value, + ) +} From 538aa09d00f3236066193e828f8435eb23e31713 Mon Sep 17 00:00:00 2001 From: Suspense <rhainland@gmail.com> Date: Sun, 11 Oct 2026 07:14:12 +0200 Subject: [PATCH 80/87] feat(profiles): ask who's watching at launch on Android phones and tablets (#543) --- FEATURES.md | 2 +- .../common/profiles/ProfileAwayTracker.kt | 70 +++++--- .../profiles/ProfileLaunchPreferences.kt | 69 +++++--- .../profiles/ProfileLaunchPreferencesTest.kt | 55 ++++-- .../siloserver/silo/android/MainActivity.kt | 165 ++++++++++++++---- .../silo/android/auth/ActiveProfileReset.kt | 63 +++++++ .../android/cast/SiloCastSessionManager.kt | 8 + .../silo/android/di/AndroidModule.kt | 40 ++++- .../android/profiles/ProfileLaunchAction.kt | 30 ++++ .../android/ui/navigation/AppNavigation.kt | 155 +++++++++++++++- .../silo/android/ui/navigation/Routes.kt | 1 + .../ui/screens/servers/ServerListViewModel.kt | 16 ++ .../ui/screens/settings/SettingsPages.kt | 34 ++++ .../ui/screens/settings/SettingsScreen.kt | 20 ++- .../profiles/ProfileLaunchActionTest.kt | 31 ++++ .../org/siloserver/silo/tv/MainTvActivity.kt | 20 +-- .../siloserver/silo/tv/di/AndroidTvModule.kt | 23 ++- .../silo/tv/ui/navigation/TvAppNavigation.kt | 8 +- .../profiles/TvProfileSelectionViewModel.kt | 4 +- .../screens/servers/TvServerListViewModel.kt | 4 +- .../ui/screens/settings/TvSettingsScreen.kt | 9 +- .../screens/settings/TvSettingsViewModel.kt | 6 +- .../silo/tv/watchnext/TvWorkerFactory.kt | 4 +- .../WatchNextProfileExpiryReceiver.kt | 4 +- .../silo/tv/watchnext/WatchNextSyncWorker.kt | 4 +- .../tv/ui/screens/profiles/InMemoryPrefs.kt | 58 ++++++ .../profiles/TvProfileRememberOfferTest.kt | 11 +- 27 files changed, 768 insertions(+), 146 deletions(-) rename androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt => android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileAwayTracker.kt (53%) rename androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt => android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferences.kt (74%) rename androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt => android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferencesTest.kt (82%) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ActiveProfileReset.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchAction.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchActionTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/InMemoryPrefs.kt diff --git a/FEATURES.md b/FEATURES.md index 1471640fc5..50e5ba040f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -129,7 +129,7 @@ Shown only while Settings → Experimental → Watch Party is on (default on in |---|:---:|:---:|---| | Household profiles (multiple per account) | ✅ | ✅ | `ProfileRepository` | | PIN-protected & child profiles, content-rating limits | ✅ | ✅ | | -| Profile Selection at launch (Automatic, Every Time, After 1 Hour, After 12 Hours) | ➖ | ✅ | TV: Settings → General; device-local; tvOS parity, including hiding Watch Next | +| Profile Selection at launch (Automatic, Every Time, After 1 Hour, After 12 Hours) | ✅ | ✅ | Phone: Settings → Preferences; TV: Settings → General. Device-local; shared rules in `android-shared/.../profiles/`. TV also hides Watch Next | | Per-profile language / subtitle / playback prefs | ✅ | ✅ | | | Library access restrictions per profile | ✅ | ✅ | | | Favorites & watchlist | ✅ | ✅ | TV: from Settings | diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileAwayTracker.kt similarity index 53% rename from androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt rename to android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileAwayTracker.kt index 708f421892..c17c287f7a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/profiles/TvProfileAwayTracker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileAwayTracker.kt @@ -1,33 +1,38 @@ -package org.siloserver.silo.tv.profiles +package org.siloserver.silo.common.profiles import androidx.media3.common.Player import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.siloserver.silo.common.player.ActivePlayerHolder -import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences -import org.siloserver.silo.tv.watchnext.WatchNextSeeder /** * Applies the Profile Selection setting when Silo leaves and returns to the * foreground. Mirrors silo-apple ContentView's `markProfileAwayStartIfNeeded` - * and `handleReturnFromBackgroundIfNeeded`. + * and `handleReturnFromBackgroundIfNeeded`. Shared by the phone and TV apps. * - * Main thread only: [org.siloserver.silo.tv.MainTvActivity] calls it from - * onStop/onStart, and it reads the shared Media3 player, which lives on the - * main looper. + * Main thread only: the app's main Activity calls it from onStop/onStart, and + * it reads the shared Media3 player, which lives on the main looper. + * + * @param onLeave runs each time Silo leaves the foreground, with the current + * state (the TV empties its Watch Next row under Every Time). + * @param onAwayIntervalStarted runs with the time a timed away interval runs + * out, once it starts (the TV schedules its Watch Next expiry check). + * @param isCasting whether this device is playing to another screen (the + * phone's Cast session); that is still use of the profile. */ -class TvProfileAwayTracker( - private val preferences: TvProfileLaunchPreferences, +class ProfileAwayTracker( + private val preferences: ProfileLaunchPreferences, private val activePlayerHolder: ActivePlayerHolder, - private val watchNextSeeder: WatchNextSeeder, + private val onLeave: (ProfileLaunchState) -> Unit = {}, + private val onAwayIntervalStarted: (expiresAtMs: Long) -> Unit = {}, + private val isCasting: () -> Boolean = { false }, ) { private val _selectionRequired = MutableStateFlow(false) /** * Raised when a return to Silo must show Who's Watching. While it is up, - * MainTvActivity covers the screen; the navigation graph performs the + * the main Activity covers the screen; the navigation graph performs the * switch and then calls [onSelectionHandled]. */ val selectionRequired: StateFlow<Boolean> = _selectionRequired.asStateFlow() @@ -48,9 +53,7 @@ class TvProfileAwayTracker( fun onBackground() { returningFromBackground = true - // Every Time keeps the launcher's Watch Next row empty whenever Silo - // is not on screen (silo-apple Top Shelf parity). - if (preferences.state.value.behavior == ProfileLaunchBehavior.EveryTime) watchNextSeeder.clear() + onLeave(preferences.state.value) markAwayStart() watchedPlayer?.removeListener(playbackListener) watchedPlayer = activePlayerHolder.player.value?.also { it.addListener(playbackListener) } @@ -59,7 +62,7 @@ class TvProfileAwayTracker( fun onForeground() { watchedPlayer?.removeListener(playbackListener) watchedPlayer = null - // The first start of a process is a launch, which MainTvActivity's + // The first start of a process is a launch, which the main Activity's // start-route resolution handles. if (!returningFromBackground) return returningFromBackground = false @@ -75,12 +78,32 @@ class TvProfileAwayTracker( } /** - * An audiobook still playing in the background is still use of the - * profile, as on Apple. Video never qualifies: the TV player stops when - * Silo is hidden. Uses play intent rather than `isPlaying` so a rebuffer - * mid-book does not count as leaving. + * Ask now, while Silo is in the foreground: the phone uses it once the + * server is reachable again after it kept a profile offline for its + * downloads. The navigation graph handles it as it does a return. + */ + fun requestSelection() { + _selectionRequired.value = true + } + + /** + * Playback on another screen (the phone's Cast session) started or + * stopped. While Silo is hidden this moves the away clock, as a local + * player's events do; no local player hears about it. + */ + fun onRemotePlaybackChanged() { + if (returningFromBackground) markAwayStart() + } + + /** + * Playback that keeps going while Silo is hidden (an audiobook, or video + * in background play or picture-in-picture) is still use of the profile, + * as on Apple. The TV player stops video when Silo is hidden, so there + * only audio qualifies. Uses play intent rather than `isPlaying` so a + * rebuffer does not count as leaving. */ - private fun keepsProfileActive(): Boolean { + fun keepsProfileActive(): Boolean { + if (isCasting()) return true val player = activePlayerHolder.player.value ?: return false return player.playWhenReady && player.playbackState != Player.STATE_IDLE && @@ -96,8 +119,7 @@ class TvProfileAwayTracker( // pause) must not restart a clock that is already running. if (preferences.state.value.backgroundedAtMs != null) return preferences.markBackgrounded() - // The timed choices keep the Watch Next row until the interval runs - // out, then a check removes it. Automatic records no interval. - preferences.state.value.awayExpiresAtMs?.let(watchNextSeeder::scheduleProfileExpiryCheck) + // Automatic records no interval, and the untimed Every Time has no expiry. + preferences.state.value.awayExpiresAtMs?.let(onAwayIntervalStarted) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferences.kt similarity index 74% rename from androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt rename to android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferences.kt index ccdcca646d..137f4b4300 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferences.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferences.kt @@ -1,4 +1,4 @@ -package org.siloserver.silo.tv.data.preferences +package org.siloserver.silo.common.profiles import android.content.Context import android.content.SharedPreferences @@ -8,45 +8,51 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** - * When this Android TV asks "Who's watching?" instead of reopening the last - * profile. Mirrors silo-apple `ProfileLaunchBehavior` (tvOS General → PROFILE - * AT LAUNCH → Profile Selection), including its titles and descriptions. + * When this device asks "Who's watching?" instead of reopening the last + * profile. Mirrors silo-apple `ProfileLaunchBehavior` (General → Profile + * Selection), including its titles and descriptions. Shared by the phone and + * TV apps; each keeps its own choice on the device. */ enum class ProfileLaunchBehavior( /** Stored value. Same strings as silo-apple's raw values. */ val raw: String, val title: String, - val description: String, + private val describe: (device: String) -> String, /** How long Silo can be away before the picker shows again; null for the untimed choices. */ val awayTimeoutMs: Long?, ) { Automatic( raw = "automatic", title = "Automatic", - description = "Use the last profile selected on this Android TV. " + - "A protected profile can reopen without asking for its PIN.", + describe = { device -> + "Use the last profile selected on $device. " + + "A protected profile can reopen without asking for its PIN." + }, awayTimeoutMs = null, ), EveryTime( raw = "askEveryLaunch", title = "Every Time", - description = "Show Who's Watching whenever you return to Silo.", + describe = { "Show Who's Watching whenever you return to Silo." }, awayTimeoutMs = null, ), AfterOneHour( raw = "afterOneHour", title = "After 1 Hour", - description = "Show Who's Watching when you've been away from Silo for 1 hour.", + describe = { "Show Who's Watching when you've been away from Silo for 1 hour." }, awayTimeoutMs = 60 * 60 * 1000L, ), AfterTwelveHours( raw = "afterTwelveHours", title = "After 12 Hours", - description = "Show Who's Watching when you've been away from Silo for 12 hours.", + describe = { "Show Who's Watching when you've been away from Silo for 12 hours." }, awayTimeoutMs = 12 * 60 * 60 * 1000L, ), ; + /** The choice's description, naming [device] (for example "this Android TV") where it matters. */ + fun description(device: String): String = describe(device) + companion object { /** Unknown or missing values fall back to today's behavior. */ fun fromRaw(raw: String?): ProfileLaunchBehavior = @@ -63,8 +69,8 @@ data class ProfileLaunchState( /** Epoch ms when Silo last went to the background unused; null while in use. */ val backgroundedAtMs: Long? = null, /** - * A new TV offers the first profile picked on it as the one to open - * automatically. True until that offer is answered or a choice is made in + * Android TV only: a new TV offers the first profile picked on it as the + * one to open automatically. True until that offer is answered or a choice is made in * Settings. */ val rememberOfferPending: Boolean = false, @@ -84,7 +90,7 @@ data class ProfileLaunchState( } /** - * Whether the launcher's Watch Next row may show the active profile's + * Android TV only. Whether the launcher's Watch Next row may show the active profile's * titles. silo-apple `TopShelfProfilePolicy`: never with Every Time, and * not once a timed choice has expired. */ @@ -115,7 +121,7 @@ data class ProfileLaunchState( companion object { /** - * The state of a TV with no saved choice. A new TV asks who's watching + * Android TV only. The state of a TV with no saved choice. A new TV asks who's watching * and offers to remember the first profile picked; a TV that already * remembers a profile (it predates this setting) keeps opening it. */ @@ -133,7 +139,7 @@ data class ProfileLaunchState( private fun hasExpiredAwayInterval(nowMs: Long): Boolean { val since = backgroundedAtMs ?: return false val timeout = behavior.awayTimeoutMs ?: return false - // A clock behind the recorded start (a TV that booted before its time + // A clock behind the recorded start (a device that booted before its time // synced, or a manual change) cannot show how long Silo was away, so // ask rather than keep the profile open indefinitely. if (nowMs < since) return true @@ -143,19 +149,24 @@ data class ProfileLaunchState( /** * Device-wide Profile Selection setting. It is never sent to the server, so a - * choice on this TV leaves every other device alone (silo-apple + * choice on this device leaves every other device alone (silo-apple * profile-launch design §12). It survives sign-out; the profile itself does * not. * * Backed by SharedPreferences rather than DataStore: `onStop` writes the away * marker and `onStart` reads it straight after, and SharedPreferences makes * that write visible synchronously. + * + * The phone and TV apps each pass their own file name. The TV's is + * [TV_PREFS_NAME], unchanged from when this lived in the TV app, so choices + * made there survive the move. */ -class TvProfileLaunchPreferences( +class ProfileLaunchPreferences( private val prefs: SharedPreferences, private val clock: () -> Long = System::currentTimeMillis, ) { - constructor(context: Context) : this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)) + constructor(context: Context, prefsName: String) : + this(context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)) private val _state = MutableStateFlow(read()) val state: StateFlow<ProfileLaunchState> = _state.asStateFlow() @@ -169,9 +180,10 @@ class TvProfileLaunchPreferences( fun setBehavior(behavior: ProfileLaunchBehavior) = update { it.withBehavior(behavior) } /** - * Saves the starting choice the first time this TV runs Silo; later - * launches keep what is saved. [hasRememberedProfile] says whether the TV - * already opens a profile from an earlier version. + * Android TV only. Saves the starting choice the first time this TV runs + * Silo; later launches keep what is saved. [hasRememberedProfile] says + * whether the TV already opens a profile from an earlier version. The + * phone never calls this, so it starts at Automatic with no offer. */ @Synchronized fun settleFirstLaunch(hasRememberedProfile: Boolean) { @@ -212,10 +224,15 @@ class TvProfileLaunchPreferences( rememberOfferPending = prefs.getBoolean(KEY_REMEMBER_OFFER, false), ) - private companion object { - const val PREFS_NAME = "silo_tv_profile_launch" - const val KEY_BEHAVIOR = "behavior" - const val KEY_BACKGROUNDED_AT = "backgrounded_at_ms" - const val KEY_REMEMBER_OFFER = "remember_offer_pending" + companion object { + /** The Android TV app's file. */ + const val TV_PREFS_NAME = "silo_tv_profile_launch" + + /** The phone and tablet app's file. */ + const val PHONE_PREFS_NAME = "silo_profile_launch" + + private const val KEY_BEHAVIOR = "behavior" + private const val KEY_BACKGROUNDED_AT = "backgrounded_at_ms" + private const val KEY_REMEMBER_OFFER = "remember_offer_pending" } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferencesTest.kt similarity index 82% rename from androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt rename to android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferencesTest.kt index fd8780a14f..1767318e6c 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/data/preferences/TvProfileLaunchPreferencesTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/profiles/ProfileLaunchPreferencesTest.kt @@ -1,6 +1,7 @@ -package org.siloserver.silo.tv.data.preferences +package org.siloserver.silo.common.profiles import android.content.SharedPreferences +import org.siloserver.silo.common.player.ActivePlayerHolder import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -12,7 +13,7 @@ import kotlin.test.assertTrue * `ProfileLaunchPolicyTests`: when Who's Watching is required, and when the * launcher's Watch Next row may show the active profile. */ -class TvProfileLaunchPreferencesTest { +class ProfileLaunchPreferencesTest { private val hour = 60 * 60 * 1000L private val start = 1_000_000_000L @@ -101,31 +102,31 @@ class TvProfileLaunchPreferencesTest { fun `the choice and the away interval survive a process restart`() { val prefs = InMemoryPrefs() var now = start - val first = TvProfileLaunchPreferences(prefs) { now } + val first = ProfileLaunchPreferences(prefs) { now } first.setBehavior(ProfileLaunchBehavior.AfterOneHour) first.markBackgrounded() now = start + hour - val restarted = TvProfileLaunchPreferences(prefs) { now } + val restarted = ProfileLaunchPreferences(prefs) { now } assertEquals(ProfileLaunchBehavior.AfterOneHour, restarted.state.value.behavior) assertEquals(start, restarted.state.value.backgroundedAtMs) assertTrue(restarted.requiresSelectionAtLaunch()) restarted.clearBackgroundedAt() - assertFalse(TvProfileLaunchPreferences(prefs) { now }.requiresSelectionAtLaunch()) + assertFalse(ProfileLaunchPreferences(prefs) { now }.requiresSelectionAtLaunch()) } @Test fun `a new TV asks who's watching and offers to remember the first profile`() { val prefs = InMemoryPrefs() - val store = TvProfileLaunchPreferences(prefs) { start } + val store = ProfileLaunchPreferences(prefs) { start } store.settleFirstLaunch(hasRememberedProfile = false) assertEquals(ProfileLaunchBehavior.EveryTime, store.state.value.behavior) assertTrue(store.isRememberOfferPending) assertTrue(store.requiresSelectionAtLaunch()) // Later launches keep what was saved. - val next = TvProfileLaunchPreferences(prefs) { start } + val next = ProfileLaunchPreferences(prefs) { start } next.settleFirstLaunch(hasRememberedProfile = true) assertEquals(ProfileLaunchBehavior.EveryTime, next.state.value.behavior) assertTrue(next.isRememberOfferPending) @@ -134,9 +135,9 @@ class TvProfileLaunchPreferencesTest { @Test fun `a TV that already opens a profile keeps Automatic and gets no offer`() { val prefs = InMemoryPrefs() - TvProfileLaunchPreferences(prefs) { start }.settleFirstLaunch(hasRememberedProfile = true) + ProfileLaunchPreferences(prefs) { start }.settleFirstLaunch(hasRememberedProfile = true) - val restarted = TvProfileLaunchPreferences(prefs) { start } + val restarted = ProfileLaunchPreferences(prefs) { start } assertEquals(ProfileLaunchBehavior.Automatic, restarted.state.value.behavior) assertFalse(restarted.isRememberOfferPending) assertFalse(restarted.requiresSelectionAtLaunch()) @@ -146,11 +147,11 @@ class TvProfileLaunchPreferencesTest { fun `the offer is answered once and the answer is kept`() { for (remember in listOf(true, false)) { val prefs = InMemoryPrefs() - TvProfileLaunchPreferences(prefs) { start }.apply { + ProfileLaunchPreferences(prefs) { start }.apply { settleFirstLaunch(hasRememberedProfile = false) answerRememberOffer(remember) } - val restarted = TvProfileLaunchPreferences(prefs) { start } + val restarted = ProfileLaunchPreferences(prefs) { start } assertFalse(restarted.isRememberOfferPending) assertEquals( if (remember) ProfileLaunchBehavior.Automatic else ProfileLaunchBehavior.EveryTime, @@ -162,10 +163,38 @@ class TvProfileLaunchPreferencesTest { @Test fun `choosing in Settings ends the offer`() { val prefs = InMemoryPrefs() - val store = TvProfileLaunchPreferences(prefs) { start } + val store = ProfileLaunchPreferences(prefs) { start } store.settleFirstLaunch(hasRememberedProfile = false) store.setBehavior(ProfileLaunchBehavior.AfterOneHour) - assertFalse(TvProfileLaunchPreferences(prefs) { start }.isRememberOfferPending) + assertFalse(ProfileLaunchPreferences(prefs) { start }.isRememberOfferPending) + } + + @Test + fun `Cast that stops while Silo is hidden starts the away clock, so Every Time asks`() { + val store = ProfileLaunchPreferences(InMemoryPrefs()) { start } + store.setBehavior(ProfileLaunchBehavior.EveryTime) + var casting = true + val tracker = ProfileAwayTracker(store, ActivePlayerHolder(), isCasting = { casting }) + + tracker.onBackground() + assertNull(store.state.value.backgroundedAtMs) + + casting = false + tracker.onRemotePlaybackChanged() + assertEquals(start, store.state.value.backgroundedAtMs) + + tracker.onForeground() + assertTrue(tracker.selectionRequired.value) + } + + @Test + fun `a Cast change while Silo is in use starts no away clock`() { + val store = ProfileLaunchPreferences(InMemoryPrefs()) { start } + store.setBehavior(ProfileLaunchBehavior.EveryTime) + val tracker = ProfileAwayTracker(store, ActivePlayerHolder(), isCasting = { false }) + + tracker.onRemotePlaybackChanged() + assertNull(store.state.value.backgroundedAtMs) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt index 2ab03aca9e..9ffeac2bf1 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.width @@ -29,11 +30,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.lifecycle.lifecycleScope import org.siloserver.silo.common.diagnostics.DiagnosticsLifecycleLogger import org.siloserver.silo.android.downloads.LEGACY_PUBLIC_DOWNLOAD_PERMISSION import org.siloserver.silo.android.downloads.hasLegacyPublicDownloadPermission +import org.siloserver.silo.android.auth.ActiveProfileReset import org.siloserver.silo.android.cast.SiloCastController +import org.siloserver.silo.android.profiles.ProfileLaunchAction +import org.siloserver.silo.android.profiles.profileLaunchAction import org.siloserver.silo.android.push.PushNotificationPresenter import org.siloserver.silo.android.ui.navigation.AppNavigation import org.siloserver.silo.android.ui.navigation.ExternalRouteRequest @@ -56,6 +61,9 @@ import org.siloserver.silo.android.ui.screens.onboarding.OnboardingTourLocalCach import org.siloserver.silo.android.ui.theme.SiloTheme import org.siloserver.silo.common.network.ServerReachabilityMonitor import org.siloserver.silo.common.pip.SiloPictureInPictureCoordinator +import org.siloserver.silo.common.profiles.ProfileAwayTracker +import org.siloserver.silo.common.profiles.ProfileLaunchBehavior +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.common.pip.SiloPictureInPictureSurface import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.common.settings.ServerDrivenConfigRefresher @@ -73,6 +81,8 @@ import org.siloserver.silo.repository.SectionRepository import org.siloserver.silo.repository.port.HomeCachePort import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.get @@ -87,6 +97,12 @@ class MainActivity : ComponentActivity() { @Volatile private var hasShownColdSplash = false + // Profile Selection's launch rule applies once per process; a later + // Activity in the same process (Back left Silo, then it was reopened) + // follows the return rule instead. Mirrors MainTvActivity. + @Volatile + private var hasResolvedLaunchProfile = false + /** * Set on the launch Intent once its external route has been delivered. * `putExtra` mutates the process-local Intent, which covers ordinary @@ -138,10 +154,28 @@ class MainActivity : ComponentActivity() { maybeRequestNotificationPermission() maybeRequestLegacyPublicDownloadPermission() + // Recents and a warm return to Silo show a snapshot of its last screen. + // When Profile Selection asks, that is the previous viewer's profile, + // so keep no snapshot then (Android 13+; earlier versions have no + // equivalent short of blocking screenshots altogether). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lifecycleScope.launch { + get<ProfileLaunchPreferences>(ProfileLaunchPreferences::class.java).state + .map { it.behavior == ProfileLaunchBehavior.Automatic } + .distinctUntilChanged() + .collect { automatic -> setRecentsScreenshotEnabled(automatic) } + } + } + setContent { var startRoute by remember { mutableStateOf<String?>(null) } val pendingExternalRoute by pendingExternalRouteRequests.collectAsState() var splashPlaybackComplete by remember { mutableStateOf(hasShownColdSplash) } + // Profile Selection: from the moment Silo returns and the setting + // asks, cover the previous profile's screens until Who's Watching + // has replaced them (AppNavigation lifts the request). + val profileAwayTracker = remember { get<ProfileAwayTracker>(ProfileAwayTracker::class.java) } + val profileCoverVisible by profileAwayTracker.selectionRequired.collectAsState() LaunchedEffect(Unit) { val route = resolveStartDestination() @@ -188,36 +222,54 @@ class MainActivity : ComponentActivity() { ) } } else { - AppNavigation( - startDestination = resolvedRoute, - pendingExternalRoute = pendingExternalRoute, - onRequeueExternalRoute = { route -> - // A fresh request: clear the consumed marker so - // this re-delivery is not mistaken for the - // already-followed original. - consumedExternalRoute = null - intent?.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED) - pendingExternalRouteRequests.value = - externalRouteRequestFactory.create(route) - }, - onExternalRouteConsumed = { consumedRequest -> - // Record the delivery in two places. The Intent - // extra covers in-process Activity recreation, - // which re-parses the retained Intent in - // onCreate and would otherwise yank the user - // back to a link they already followed. It is - // process-local, so the saved-state route below - // is what covers process death. - intent?.putExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, true) - consumedExternalRoute = consumedRequest.route - pendingExternalRouteRequests.update { pendingRequest -> - clearConsumedExternalRouteRequest( - pendingRequest = pendingRequest, - consumedRequest = consumedRequest, - ) - } - }, - ) + Box(modifier = Modifier.fillMaxSize()) { + AppNavigation( + startDestination = resolvedRoute, + pendingExternalRoute = pendingExternalRoute, + onRequeueExternalRoute = { route -> + // A fresh request: clear the consumed marker so + // this re-delivery is not mistaken for the + // already-followed original. + consumedExternalRoute = null + intent?.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED) + pendingExternalRouteRequests.value = + externalRouteRequestFactory.create(route) + }, + onExternalRouteConsumed = { consumedRequest -> + // Record the delivery in two places. The Intent + // extra covers in-process Activity recreation, + // which re-parses the retained Intent in + // onCreate and would otherwise yank the user + // back to a link they already followed. It is + // process-local, so the saved-state route below + // is what covers process death. + intent?.putExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, true) + consumedExternalRoute = consumedRequest.route + pendingExternalRouteRequests.update { pendingRequest -> + clearConsumedExternalRouteRequest( + pendingRequest = pendingRequest, + consumedRequest = consumedRequest, + ) + } + }, + ) + if (profileCoverVisible) { + // Swallows touches too, so nothing on the previous + // profile's screens can be tapped meanwhile. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } + }, + ) + } + } } } } @@ -251,6 +303,7 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() DiagnosticsLifecycleLogger.state("foreground") + get<ProfileAwayTracker>(ProfileAwayTracker::class.java).onForeground() val refresher = get<ServerDrivenConfigRefresher>(ServerDrivenConfigRefresher::class.java) val monitor = get<ServerReachabilityMonitor>(ServerReachabilityMonitor::class.java) monitor.startForeground() @@ -282,6 +335,10 @@ class MainActivity : ComponentActivity() { override fun onStop() { DiagnosticsLifecycleLogger.state("background") super.onStop() + // A configuration-change recreation is not leaving Silo. + if (!isChangingConfigurations) { + get<ProfileAwayTracker>(ProfileAwayTracker::class.java).onBackground() + } val monitor = get<ServerReachabilityMonitor>(ServerReachabilityMonitor::class.java) monitor.stopForeground() val store = get<PlayerSettingsStore>(PlayerSettingsStore::class.java) @@ -443,6 +500,7 @@ class MainActivity : ComponentActivity() { private suspend fun resolveStartDestination(): String { val registry = get<ServerRegistry>(ServerRegistry::class.java) val tokenManager = get<TokenManager>(TokenManager::class.java) + val processStart = !hasResolvedLaunchProfile // NOTE: a device link is deliberately NOT returned as the start // destination. It used to be, which put Pair Device at the root of a @@ -487,6 +545,22 @@ class MainActivity : ComponentActivity() { val profileId = activeEntry.profileId ?: tokenManager.getProfileId() if (profileId.isNullOrBlank()) return Route.ProfileSelection.route + // Profile Selection (silo-apple `ProfileLaunchState.resolution`). A + // process start applies the launch rule: Every Time, or a timed choice + // whose away interval ran out while Silo was closed. A later Activity + // in this process follows the return rule the away tracker evaluated + // in onStart. Applied below, after the offline check. + val launchPreferences = get<ProfileLaunchPreferences>(ProfileLaunchPreferences::class.java) + val profileAwayTracker = get<ProfileAwayTracker>(ProfileAwayTracker::class.java) + val requiresSelection = if (processStart) { + // A process the playback service started (Android's media + // resumption) can already be playing this profile's audiobook; + // that is use of the profile, as on a return. + launchPreferences.requiresSelectionAtLaunch() && !profileAwayTracker.keepsProfileActive() + } else { + profileAwayTracker.selectionRequired.value + } + // Offline-with-downloads fast path: if local media exists and either // the device has no network OR the configured Silo server fails the // authoritative health probe, land directly on Downloads instead of @@ -505,15 +579,32 @@ class MainActivity : ComponentActivity() { } else { online } - if ( - shouldStartOnDownloads( - hasLocalDownloads = hasDownloads, - isDeviceOnline = online, - canUseServer = canUseServer, - ) + val startsOnDownloads = shouldStartOnDownloads( + hasLocalDownloads = hasDownloads, + isDeviceOnline = online, + canUseServer = canUseServer, + ) + when ( + profileLaunchAction(requiresSelection = requiresSelection, startsOfflineOnDownloads = startsOnDownloads) ) { - return Route.Downloads.route + ProfileLaunchAction.AskWhoIsWatching -> { + // Cleared before the picker loads; losing its PIN proof makes + // a protected profile ask for its PIN again. + get<ActiveProfileReset>(ActiveProfileReset::class.java).clearActiveProfile() + hasResolvedLaunchProfile = true + return Route.ProfileSelection.route + } + // The away interval stays recorded, so Silo asks once the server + // can be reached again (AppNavigation watches for it). Every Time + // can ask with none recorded, so start one. + ProfileLaunchAction.KeepProfileOffline -> + if (launchPreferences.state.value.backgroundedAtMs == null) launchPreferences.markBackgrounded() + ProfileLaunchAction.KeepProfile -> if (processStart) launchPreferences.clearBackgroundedAt() } + // Only once applied, as on the TV: an Activity recreated while this + // was suspended must still apply the launch rule. + hasResolvedLaunchProfile = true + if (startsOnDownloads) 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 diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ActiveProfileReset.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ActiveProfileReset.kt new file mode 100644 index 0000000000..cbf95393d2 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/auth/ActiveProfileReset.kt @@ -0,0 +1,63 @@ +package org.siloserver.silo.android.auth + +import kotlinx.coroutines.withTimeoutOrNull +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences +import org.siloserver.silo.common.settings.CardPresentationStore +import org.siloserver.silo.common.settings.EpisodeSpoilerStore +import org.siloserver.silo.common.settings.OverlayPrefsStore +import org.siloserver.silo.common.settings.PlayerSettingsStore +import org.siloserver.silo.common.settings.SeekIntervalStore +import org.siloserver.silo.common.settings.TitleArtStore +import org.siloserver.silo.model.profile.ActiveProfileStore +import org.siloserver.silo.repository.ProfileRepository + +/** + * Drops the active profile and everything cached for it, so whoever picks + * next starts clean. Profile Selection uses it at launch, on a return to + * Silo, and on a server switch; the per-profile caches are the ones Switch + * Profile drops. + */ +class ActiveProfileReset( + private val profileRepository: ProfileRepository, + private val playerSettingsStore: PlayerSettingsStore, + private val profileLaunchPreferences: ProfileLaunchPreferences, + private val overlayPrefsStore: OverlayPrefsStore, + private val activeProfileStore: ActiveProfileStore, + private val cardPresentationStore: CardPresentationStore, + private val seekIntervalStore: SeekIntervalStore, + private val titleArtStore: TitleArtStore, + private val episodeSpoilerStore: EpisodeSpoilerStore, +) { + suspend fun clearActiveProfile() { + // The flusher drops a write once its profile is no longer active, and + // onStop's flush may still be in flight; push pending writes first, + // bounded, as Switch Profile does (ProfileSwitchTeardown). + withTimeoutOrNull(FLUSH_TIMEOUT_MS) { playerSettingsStore.flushPendingDeviceSettings() } + // The profile id and its PIN proof go together, so a protected + // profile asks for its PIN again. + profileRepository.clearProfile() + overlayPrefsStore.clear() + activeProfileStore.reset() + cardPresentationStore.clear() + seekIntervalStore.clear() + titleArtStore.clear() + episodeSpoilerStore.clear() + } + + /** + * Profile Selection covers a server switch as it does a launch (silo-apple + * design §7.5): when the setting would ask now, drop the newly active + * server's saved profile so Who's Watching shows and a protected profile + * needs its PIN. Call it after the switch, before choosing a destination. + * Returns whether it cleared the profile. + */ + suspend fun clearIfSelectionRequired(): Boolean { + if (!profileLaunchPreferences.requiresSelectionAtLaunch()) return false + clearActiveProfile() + return true + } + + private companion object { + const val FLUSH_TIMEOUT_MS = 5_000L + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastSessionManager.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastSessionManager.kt index 65e553ba24..a9398b9027 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastSessionManager.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastSessionManager.kt @@ -41,6 +41,8 @@ data class SiloCastState( val isConnected: Boolean = false, val deviceName: String? = null, val isPlaying: Boolean = false, + /** Playing, or buffering or loading to play: a rebuffer is still playback. */ + val isPlaybackActive: Boolean = false, val position: Double = 0.0, val duration: Double = 0.0, val title: String = "", @@ -687,6 +689,7 @@ class SiloCastSessionManager(private val context: Context) { isConnected = true, deviceName = session.castDevice?.friendlyName, isPlaying = remoteClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING, + isPlaybackActive = remoteClient?.playerState in ACTIVE_PLAYER_STATES, position = remoteClient?.approximateStreamPosition ?.div(1000.0) ?.let { player -> pending?.playbackSession?.sourcePositionForPlayer(player) ?: player } @@ -750,5 +753,10 @@ class SiloCastSessionManager(private val context: Context) { private companion object { private const val TAG = "SiloCastSessionMgr" private const val SERVER_PROGRESS_INTERVAL_MS = 10_000L + private val ACTIVE_PLAYER_STATES = setOf( + MediaStatus.PLAYER_STATE_PLAYING, + MediaStatus.PLAYER_STATE_BUFFERING, + MediaStatus.PLAYER_STATE_LOADING, + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index b321d3886f..05aaaec63f 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -2,6 +2,11 @@ package org.siloserver.silo.android.di +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import org.siloserver.silo.common.downloads.DownloadEnqueuer import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory import org.siloserver.silo.common.downloads.DownloadSubscriptionWorker @@ -89,6 +94,7 @@ import org.siloserver.silo.android.cast.SharedPrefsSiloCastLastTargetStore import org.siloserver.silo.android.cast.SiloCastController import org.siloserver.silo.android.cast.SiloCastLastTargetStore import org.siloserver.silo.android.cast.SiloCastSessionManager +import org.siloserver.silo.android.cast.SiloCastState import org.koin.android.ext.koin.androidContext import org.koin.androidx.workmanager.dsl.worker import org.koin.core.module.dsl.viewModel @@ -534,6 +540,38 @@ val androidModule = module { single { org.siloserver.silo.android.auth.ProfileSwitchTeardown(get(), get(), get(), get(), get(), get(), get()) } + // Profile Selection (Apple Settings → General → Profile Selection): the + // choice kept on this device, the tracker MainActivity drives from + // onStop/onStart, and the reset that drops the active profile when it asks. + single { + org.siloserver.silo.common.profiles.ProfileLaunchPreferences( + androidContext(), + org.siloserver.silo.common.profiles.ProfileLaunchPreferences.PHONE_PREFS_NAME, + ) + } + single { + val cast = get<SiloCastSessionManager>() + // Casting counts as use only while the receiver plays (a rebuffer + // included): a session left connected after a pause or the end of a + // title does not hold the profile. + fun SiloCastState.castPlaying() = isConnected && isPlaybackActive + org.siloserver.silo.common.profiles.ProfileAwayTracker( + get(), + get(), + isCasting = { cast.castState.value.castPlaying() }, + ).also { tracker -> + // Cast playback that stops or disconnects while Silo is hidden + // starts the away clock then, as a local player's stop does. + cast.castState.map { it.castPlaying() }.distinctUntilChanged().drop(1) + .onEach { tracker.onRemotePlaybackChanged() } + .launchIn( + kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate, + ), + ) + } + } + single { org.siloserver.silo.android.auth.ActiveProfileReset(get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } @@ -553,7 +591,7 @@ val androidModule = module { viewModel { ProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull()) } viewModel { CreateProfileViewModel(get()) } viewModel { EditProfileViewModel(get()) } - viewModel { ServerListViewModel(get(), get(), get()) } + viewModel { ServerListViewModel(get(), get(), get(), get()) } viewModel { params -> val args = params.get<Pair<String?, String?>>() val authRepository = get<org.siloserver.silo.repository.AuthRepository>() diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchAction.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchAction.kt new file mode 100644 index 0000000000..293900dc36 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchAction.kt @@ -0,0 +1,30 @@ +package org.siloserver.silo.android.profiles + +/** What the phone does about Profile Selection when Silo opens or returns. */ +enum class ProfileLaunchAction { + /** Keep the current profile; Silo is in use again. */ + KeepProfile, + + /** Clear the current profile and show Who's Watching. */ + AskWhoIsWatching, + + /** + * The setting asks, but the server cannot be reached and this profile + * has downloads. Who's Watching cannot load profiles or check a PIN + * offline, so keep the profile and its downloads usable, and ask once + * the server is back. + */ + KeepProfileOffline, +} + +/** + * The phone's launch and return decision. [requiresSelection] is the shared + * Profile Selection rule; [startsOfflineOnDownloads] says the server cannot be + * used and the active profile has local downloads. + */ +fun profileLaunchAction(requiresSelection: Boolean, startsOfflineOnDownloads: Boolean): ProfileLaunchAction = + when { + !requiresSelection -> ProfileLaunchAction.KeepProfile + startsOfflineOnDownloads -> ProfileLaunchAction.KeepProfileOffline + else -> ProfileLaunchAction.AskWhoIsWatching + } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt index a6d856db4e..0f962ab71d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt @@ -15,7 +15,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.siloserver.silo.android.auth.ActiveProfileReset +import org.siloserver.silo.android.profiles.ProfileLaunchAction +import org.siloserver.silo.android.profiles.profileLaunchAction +import org.siloserver.silo.common.downloads.DownloadStorage +import org.siloserver.silo.common.network.ServerReachabilityMonitor +import org.siloserver.silo.common.network.ServerReachabilityStatus +import org.siloserver.silo.common.profiles.ProfileAwayTracker +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.model.profile.ActiveProfileStore import org.siloserver.silo.android.ui.components.rememberVideoSeekIntervals import org.siloserver.silo.android.cast.GOOGLE_CAST_LEGACY_SEEK_INTERVALS @@ -109,6 +123,7 @@ import org.siloserver.silo.android.ui.screens.servers.ServerSwitchDestination import org.siloserver.silo.android.ui.screens.settings.SettingsDownloadsScreen import org.siloserver.silo.android.ui.screens.settings.SettingsInterfaceScreen import org.siloserver.silo.android.ui.screens.settings.SettingsNotificationsScreen +import org.siloserver.silo.android.ui.screens.settings.SettingsProfileSelectionScreen import org.siloserver.silo.android.ui.screens.settings.SettingsPlaybackScreen import org.siloserver.silo.android.ui.screens.settings.SettingsScreen import org.siloserver.silo.android.ui.screens.settings.SettingsSubtitlesScreen @@ -168,6 +183,12 @@ private val ProfilePromptDeferredRoutes: Set<String> = setOf( /** Page-to-page cross-fade duration (ms). Snappier than Compose Nav's 700ms default. */ private const val PageFadeDurationMs = 200 +/** Longest the Profile Selection cover stays up while Who's Watching replaces the previous profile's screens. */ +private const val PROFILE_COVER_MAX_MS = 2_000L + +/** Screens that show no profile: sign-in, server setup, and the profile picker itself. */ +private val ProfileFreeRoutes: Set<String> get() = MarqueeRoutes + ProfilePromptPickerRoutes + Route.ServerList.route + internal class PlayerTargetProviderRegistration( val backStackEntryId: String, val target: () -> MobilePlayerRouteTarget?, @@ -347,6 +368,114 @@ fun AppNavigation( } } + // Android can restore Silo's previous back stack over the start route + // MainActivity chose: after its process was killed in the background. + // When Profile Selection has already cleared the profile at launch, that + // brings back the previous profile's screens with no profile behind them, + // so go to Who's Watching instead. Look beneath the top too: Manage + // Servers over Home would otherwise let Back reach Home with no profile + // chosen, and every stack of profile screens has a tab at its root. + LaunchedEffect(navController) { + val restoredRoute = navController.currentBackStackEntryFlow.first().destination.route + val restoresProfileScreens = (restoredRoute != null && restoredRoute !in ProfileFreeRoutes) || + Tab.entries.any { tab -> runCatching { navController.getBackStackEntry(tab.route) }.isSuccess } + if (startDestination == Route.ProfileSelection.route && + restoresProfileScreens && + !tokenManager.getAccessToken().isNullOrBlank() && + tokenManager.getProfileId().isNullOrBlank() + ) { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + } + + // Profile Selection's return rule (silo-apple `applyProfileReturnPolicy`): + // when Silo comes back from the background and the setting asks, Who's + // Watching replaces whatever was on screen. Playback that kept going in + // the background never raises the request (see ProfileAwayTracker). + val profileAwayTracker: ProfileAwayTracker = koinInject() + val profileLaunchPreferences: ProfileLaunchPreferences = koinInject() + val activeProfileReset: ActiveProfileReset = koinInject() + val downloadStorage: DownloadStorage = koinInject() + val reachabilityMonitor: ServerReachabilityMonitor = koinInject() + LaunchedEffect(profileAwayTracker) { + kotlinx.coroutines.flow.combine( + profileAwayTracker.selectionRequired, + navController.currentBackStackEntryFlow, + ) { required, entry -> required to entry.destination.route }.collect { (required, route) -> + if (!required) return@collect + // Nothing to replace when signed out, or when no profile is in + // use and none of its screens are showing (already choosing a + // profile, or adding a server). + val profileId = tokenManager.getProfileId() + if (route == null || + tokenManager.getAccessToken().isNullOrBlank() || + (profileId.isNullOrBlank() && route in ProfileFreeRoutes) + ) { + profileAwayTracker.onSelectionHandled() + return@collect + } + val serverId = serverRegistry.activeEntry.value?.id + val offlineWithDownloads = withContext(Dispatchers.IO) { + hasLocalDownloadsForScope(downloadStorage, serverId, profileId) && + !reachabilityMonitor.retryNow().canUseServer + } + // Re-check after the wait, as silo-apple's return policy does. + val action = profileLaunchAction( + requiresSelection = profileLaunchPreferences.requiresSelectionAfterBackground(), + startsOfflineOnDownloads = offlineWithDownloads, + ) + if (action != ProfileLaunchAction.AskWhoIsWatching) { + profileAwayTracker.onSelectionHandled() + return@collect + } + // Leave the profile's screens before clearing its state, as + // Switch Profile does. The reset pushes pending device-setting + // writes under this profile before it drops it. + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + activeProfileReset.clearActiveProfile() + // MainActivity covers the screen while the request is up; lift + // it once the previous profile's screens have faded out. + withTimeoutOrNull(PROFILE_COVER_MAX_MS) { + navController.visibleEntries.first { entries -> + entries.all { it.destination.route in ProfileFreeRoutes } + } + } + profileAwayTracker.onSelectionHandled() + } + } + + // The offline exception above keeps a profile for its downloads while the + // server cannot be reached, with the away interval still recorded. Ask + // once the server answers again, without waiting for the next return. + // Playback and reading keep going until the user leaves them, as the + // profile-verification prompt waits. + LaunchedEffect(reachabilityMonitor) { + kotlinx.coroutines.flow.combine( + reachabilityMonitor.state.map { it.status == ServerReachabilityStatus.Reachable }.distinctUntilChanged(), + navController.currentBackStackEntryFlow, + ) { reachable, entry -> reachable to entry.destination.route }.collect { (reachable, route) -> + if (!reachable || route == null || route in ProfilePromptDeferredRoutes || route in ProfileFreeRoutes) { + return@collect + } + // In use with a profile, a recorded interval that requires + // selection is only left behind by that exception: every other + // path ends the interval or asks. + if (profileAwayTracker.selectionRequired.value || + !profileLaunchPreferences.requiresSelectionAfterBackground() || + tokenManager.getProfileId().isNullOrBlank() + ) { + return@collect + } + profileAwayTracker.requestSelection() + } + } + fun navigateExternalRoute(route: String) { // An external link to a TAB (silo://downloads) must switch tabs, // not push a second copy of that tab. A duplicate tab entry also @@ -422,8 +551,17 @@ fun AppNavigation( LaunchedEffect(pendingExternalRoute?.generation) { consumeExternalRouteOnce( pendingExternalRoute = pendingExternalRoute, - currentDestinationRoutes = navController.currentBackStackEntryFlow - .map { entry -> entry.destination.route }, + // While Profile Selection is replacing the previous profile's + // screens, those screens are not a place to deliver a link: it + // would open (or start playing) under the profile being cleared, + // then be wiped. Wait until the request is handled; Who's + // Watching then holds the link as it does at launch. + currentDestinationRoutes = kotlinx.coroutines.flow.combine( + navController.currentBackStackEntryFlow, + profileAwayTracker.selectionRequired, + ) { entry, selectionRequired -> entry.destination.route to selectionRequired } + .filter { (_, selectionRequired) -> !selectionRequired } + .map { (route, _) -> route }, isAlreadyAtRoute = { route -> navController.isDisplayingExactPlayerRoute( route = route, @@ -788,6 +926,12 @@ fun AppNavigation( onSwitch = { pairingScope.launch { authRepository.switchToServer(resolved.entry.id) + // Profile Selection covers this switch as + // it does the server list's: a saved + // profile there needs choosing (and its + // PIN) again, which routes through the + // picker below. + activeProfileReset.clearIfSelectionRequired() // Re-queue ONLY if the target server will send // the user through auth: that flow ends at // profile selection, whose popUpTo(0) wipes this @@ -929,6 +1073,9 @@ fun AppNavigation( } ProfileSelectionScreen( onNavigateToHome = { + // A chosen profile ends any away interval (silo-apple + // `remember`). + profileLaunchPreferences.clearBackgroundedAt() // The switch-profile paths dropped the per-profile card // and seek-interval caches; re-hydrate for the profile just picked. The // providers above the graph only re-run when the profile id @@ -1068,6 +1215,7 @@ fun AppNavigation( onOpenSubtitles = { navController.navigate(Route.SettingsSubtitles.route) }, onOpenDownloads = { navController.navigate(Route.SettingsDownloads.route) }, onOpenNotifications = { navController.navigate(Route.SettingsNotifications.route) }, + onOpenProfileSelection = { navController.navigate(Route.SettingsProfileSelection.route) }, onNavigateToServers = { navController.navigate(Route.ServerList.route) }, @@ -1125,6 +1273,9 @@ fun AppNavigation( onBackClick = { navController.popBackStack() }, ) } + composable(Route.SettingsProfileSelection.route) { + SettingsProfileSelectionScreen(onBackClick = { navController.popBackStack() }) + } composable(Route.Diagnostics.route) { DiagnosticsSettingsScreen( onBackClick = { navController.popBackStack() }, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt index 0663f4c6a4..403e178e37 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt @@ -117,6 +117,7 @@ sealed class Route(val route: String) { data object SettingsSubtitles : Route("settings/subtitles") data object SettingsDownloads : Route("settings/downloads") data object SettingsNotifications : Route("settings/notifications") + data object SettingsProfileSelection : Route("settings/profile_selection") data object Diagnostics : Route("settings/diagnostics") data class DiagnosticsReport(val reportId: String) : Route("settings/diagnostics/report/${Uri.encode(reportId)}") { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListViewModel.kt index cc18ffec26..c08b4eeeb0 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/servers/ServerListViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.siloserver.silo.android.auth.ActiveProfileReset /** * Where to land after a successful server switch. The list view emits this @@ -39,6 +40,7 @@ class ServerListViewModel( private val serverRegistry: ServerRegistry, private val tokenManager: TokenManager, private val authRepository: AuthRepository, + private val activeProfileReset: ActiveProfileReset, ) : ViewModel() { private val _uiState = MutableStateFlow(ServerListUiState()) @@ -82,6 +84,10 @@ class ServerListViewModel( val destination = when { accessToken.isNullOrBlank() -> ServerSwitchDestination.Login profileId.isNullOrBlank() -> ServerSwitchDestination.ProfileSelection + // Profile Selection asks on a server switch as it does at + // launch, so a protected profile there needs its PIN. Cleared + // before navigating, so the picker loads under the new server. + activeProfileReset.clearIfSelectionRequired() -> ServerSwitchDestination.ProfileSelection else -> ServerSwitchDestination.Home } @@ -118,6 +124,16 @@ class ServerListViewModel( val promotedId = serverRegistry.activeServerId.value if (wasActive && promotedId != null) { authRepository.switchToServer(promotedId) + // A promotion is a server switch too: when Profile Selection + // asks, the promoted server's saved profile needs choosing + // (and its PIN) again, so leave for Who's Watching rather + // than back to a shell that would reopen it. Without a + // saved profile there is nothing to protect. + val hasProfile = !tokenManager.getAccessToken().isNullOrBlank() && + !(serverRegistry.activeEntry.value?.profileId ?: tokenManager.getProfileId()).isNullOrBlank() + if (hasProfile && activeProfileReset.clearIfSelectionRequired()) { + _uiState.update { it.copy(switchedTo = ServerSwitchDestination.ProfileSelection) } + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt index 21084277cd..dbe4f895a5 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsPages.kt @@ -25,6 +25,8 @@ import org.siloserver.silo.android.ui.screens.home.HomeSectionsEditor import org.siloserver.silo.android.ui.theme.SettingsDimens import org.siloserver.silo.android.ui.theme.SiloSettingsBackground import org.siloserver.silo.android.ui.util.formatBytes +import org.siloserver.silo.common.profiles.ProfileLaunchBehavior +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.common.settings.TitleArtStore import org.siloserver.silo.common.settings.UseProfileSettingsCopy import org.siloserver.silo.model.feature.MetadataAiFeatureStore @@ -390,3 +392,35 @@ fun SettingsNotificationsScreen(viewModel: SettingsViewModel, onBackClick: () -> } } } + +/** How Profile Selection's descriptions name this device. */ +internal const val PROFILE_LAUNCH_DEVICE = "this device" + +/** + * Profile Selection (Apple Settings → General → Profile Selection): whether + * Silo reopens the last profile or asks who's watching. The choice is kept on + * this phone or tablet only and never sent to the server. + */ +@Composable +fun SettingsProfileSelectionScreen(onBackClick: () -> Unit) { + val preferences: ProfileLaunchPreferences = koinInject() + val launchState by preferences.state.collectAsState() + + SettingsPageScaffold(title = "Profile Selection", onBackClick = onBackClick) { + item(key = "profile-selection") { + SettingsSection( + title = null, + footer = "Choose when Silo asks who's watching. This applies only to this device.", + ) { + ProfileLaunchBehavior.entries.forEach { behavior -> + SettingsChoiceRow( + label = behavior.title, + description = behavior.description(PROFILE_LAUNCH_DEVICE), + selected = behavior == launchState.behavior, + onSelect = { preferences.setBehavior(behavior) }, + ) + } + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index 0bbca453fc..2100b2896d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.SwitchAccount import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -86,6 +87,7 @@ fun SettingsScreen( onOpenSubtitles: () -> Unit = {}, onOpenDownloads: () -> Unit = {}, onOpenNotifications: () -> Unit = {}, + onOpenProfileSelection: () -> Unit = {}, onNavigateToServers: () -> Unit = {}, onPairDevice: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, @@ -111,6 +113,9 @@ fun SettingsScreen( val watchPartyExperiment: org.siloserver.silo.common.watchparty.WatchPartyExperiment = org.koin.compose.koinInject() val watchPartyEnabled by watchPartyExperiment.enabled.collectAsState() + val profileLaunchPreferences: org.siloserver.silo.common.profiles.ProfileLaunchPreferences = + org.koin.compose.koinInject() + val profileLaunchState by profileLaunchPreferences.state.collectAsState() LaunchedEffect(state.loggedOut) { if (state.loggedOut) { @@ -132,6 +137,9 @@ fun SettingsScreen( val matchesNotifications = state.notificationsAvailable && search.matches( "notifications", "alerts", "favorites", "watchlist", "continue watching", "next up", ) + val matchesProfileSelection = search.matches( + "profile selection", "profiles", "who's watching", "launch", "pin", + ) val matchesPlayback = search.matches( "playback", "quality", "audio", "dolby vision", "picture-in-picture", "episodes", "next up", "skip", "intros", "credits", "rewind", "still watching", "skip interval", "audiobooks", @@ -156,7 +164,7 @@ fun SettingsScreen( val matchesAbout = search.matches("about", "version", versionLabel, "privacy", "policy", "information") val matchesSignOut = search.matches("sign out", "account") - val hasResults = matchesInterface || matchesNotifications || matchesPlayback || matchesSubtitles || + val hasResults = matchesInterface || matchesNotifications || matchesProfileSelection || matchesPlayback || matchesSubtitles || matchesDownloads || matchesWatchlist || matchesFavorites || matchesHistory || matchesCollections || showDiagnostics || matchesServer || matchesPairDevice || matchesExperimental || matchesAbout || matchesSignOut || matchesSignIn @@ -219,7 +227,7 @@ fun SettingsScreen( } } - if (matchesInterface || matchesNotifications) { + if (matchesInterface || matchesNotifications || matchesProfileSelection) { item(key = "preferences") { SettingsSection(title = "Preferences") { if (matchesInterface) { @@ -240,6 +248,14 @@ fun SettingsScreen( onClick = onOpenNotifications, ) } + if (matchesProfileSelection) { + SettingsNavigationRow( + label = "Profile Selection", + icon = Icons.Filled.SwitchAccount, + value = profileLaunchState.behavior.title, + onClick = onOpenProfileSelection, + ) + } } } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchActionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchActionTest.kt new file mode 100644 index 0000000000..c7a3db58ca --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/profiles/ProfileLaunchActionTest.kt @@ -0,0 +1,31 @@ +package org.siloserver.silo.android.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileLaunchActionTest { + + @Test + fun `asks who's watching only when the setting asks and the server can be used`() { + assertEquals( + ProfileLaunchAction.KeepProfile, + profileLaunchAction(requiresSelection = false, startsOfflineOnDownloads = false), + ) + assertEquals( + ProfileLaunchAction.KeepProfile, + profileLaunchAction(requiresSelection = false, startsOfflineOnDownloads = true), + ) + assertEquals( + ProfileLaunchAction.AskWhoIsWatching, + profileLaunchAction(requiresSelection = true, startsOfflineOnDownloads = false), + ) + } + + @Test + fun `offline with downloads keeps the profile instead of stranding the picker`() { + assertEquals( + ProfileLaunchAction.KeepProfileOffline, + profileLaunchAction(requiresSelection = true, startsOfflineOnDownloads = true), + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt index fc6ba922ae..8eeb535689 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/MainTvActivity.kt @@ -56,10 +56,10 @@ import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.SectionRepository import org.siloserver.silo.repository.port.HomeCachePort import org.siloserver.silo.tv.cast.TvSiloCastReceiver -import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchBehavior +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.tv.profiles.TvActiveProfileReset -import org.siloserver.silo.tv.profiles.TvProfileAwayTracker +import org.siloserver.silo.common.profiles.ProfileAwayTracker import org.siloserver.silo.tv.ui.navigation.TvAppNavigation import org.siloserver.silo.tv.ui.navigation.TvRoute import org.siloserver.silo.tv.ui.screens.player.TvPlayerRemoteKeyBridge @@ -135,7 +135,7 @@ class MainTvActivity : ComponentActivity() { // equivalent short of blocking screenshots altogether). if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { lifecycleScope.launch { - get<TvProfileLaunchPreferences>(TvProfileLaunchPreferences::class.java).state + get<ProfileLaunchPreferences>(ProfileLaunchPreferences::class.java).state .map { it.behavior == ProfileLaunchBehavior.Automatic } .distinctUntilChanged() .collect { automatic -> setRecentsScreenshotEnabled(automatic) } @@ -179,7 +179,7 @@ class MainTvActivity : ComponentActivity() { // Profile Selection: from the moment Silo returns and the setting // asks, cover the previous profile's screens until Who's Watching // has replaced them (TvAppNavigation lifts the request). - val profileAwayTracker = remember { get<TvProfileAwayTracker>(TvProfileAwayTracker::class.java) } + val profileAwayTracker = remember { get<ProfileAwayTracker>(ProfileAwayTracker::class.java) } val profileCoverVisible by profileAwayTracker.selectionRequired.collectAsState() SiloTvTheme { @@ -268,7 +268,7 @@ class MainTvActivity : ComponentActivity() { override fun onStart() { super.onStart() DiagnosticsLifecycleLogger.state("foreground") - get<TvProfileAwayTracker>(TvProfileAwayTracker::class.java).onForeground() + get<ProfileAwayTracker>(ProfileAwayTracker::class.java).onForeground() val refresher = get<ServerDrivenConfigRefresher>(ServerDrivenConfigRefresher::class.java) val monitor = get<ServerReachabilityMonitor>(ServerReachabilityMonitor::class.java) monitor.startForeground() @@ -339,7 +339,7 @@ class MainTvActivity : ComponentActivity() { super.onStop() // A configuration-change recreation is not leaving Silo. if (!isChangingConfigurations) { - get<TvProfileAwayTracker>(TvProfileAwayTracker::class.java).onBackground() + get<ProfileAwayTracker>(ProfileAwayTracker::class.java).onBackground() } val monitor = get<ServerReachabilityMonitor>(ServerReachabilityMonitor::class.java) monitor.stopForeground() @@ -366,7 +366,7 @@ class MainTvActivity : ComponentActivity() { // makes this an existing TV, not just one on the active server. val hasRememberedProfile = registry.entries.value.any { !it.profileId.isNullOrBlank() } || !tokenManager.getProfileId().isNullOrBlank() - get<TvProfileLaunchPreferences>(TvProfileLaunchPreferences::class.java) + get<ProfileLaunchPreferences>(ProfileLaunchPreferences::class.java) .settleFirstLaunch(hasRememberedProfile = hasRememberedProfile) } // Only once settled: an Activity recreated while this was suspended @@ -417,11 +417,11 @@ class MainTvActivity : ComponentActivity() { // return rule the away tracker evaluated in onStart. Either way the // profile is cleared here, before the picker loads, and losing its PIN // proof makes a protected profile ask for its PIN again. - val launchPreferences = get<TvProfileLaunchPreferences>(TvProfileLaunchPreferences::class.java) + val launchPreferences = get<ProfileLaunchPreferences>(ProfileLaunchPreferences::class.java) val requiresSelection = if (processStart) { launchPreferences.requiresSelectionAtLaunch() } else { - get<TvProfileAwayTracker>(TvProfileAwayTracker::class.java).selectionRequired.value + get<ProfileAwayTracker>(ProfileAwayTracker::class.java).selectionRequired.value } if (requiresSelection) { get<TvActiveProfileReset>(TvActiveProfileReset::class.java).clearActiveProfile() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index c82ad3f7dd..121c130bca 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -62,9 +62,10 @@ import org.siloserver.silo.tv.ui.screens.player.TvVideoPlaybackStarter import org.siloserver.silo.tv.ui.screens.profiles.TvProfileSelectionViewModel import org.siloserver.silo.tv.ui.screens.search.TvSearchViewModel import org.siloserver.silo.tv.data.preferences.TvLibraryScopeStore -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchBehavior +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.tv.profiles.TvActiveProfileReset -import org.siloserver.silo.tv.profiles.TvProfileAwayTracker +import org.siloserver.silo.common.profiles.ProfileAwayTracker import org.siloserver.silo.tv.watchnext.WatchNextRepository import org.siloserver.silo.tv.watchnext.WatchNextSeeder import android.net.Uri @@ -332,8 +333,22 @@ val androidTvModule = module { // Profile Selection (tvOS General → PROFILE AT LAUNCH): the device-wide // choice, the tracker MainTvActivity drives from onStop/onStart, and the // reset every path uses to drop the active profile. - single { TvProfileLaunchPreferences(androidContext()) } - single { TvProfileAwayTracker(get(), get(), get()) } + single { ProfileLaunchPreferences(androidContext(), ProfileLaunchPreferences.TV_PREFS_NAME) } + single { + val watchNextSeeder = get<WatchNextSeeder>() + ProfileAwayTracker( + preferences = get(), + activePlayerHolder = get(), + // Every Time keeps the launcher's Watch Next row empty whenever + // Silo is not on screen (silo-apple Top Shelf parity). + onLeave = { state -> + if (state.behavior == ProfileLaunchBehavior.EveryTime) watchNextSeeder.clear() + }, + // The timed choices keep the Watch Next row until the interval + // runs out, then a check removes it. + onAwayIntervalStarted = watchNextSeeder::scheduleProfileExpiryCheck, + ) + } single { TvActiveProfileReset(get(), get(), get(), get(), get(), get(), get(), get()) } // Deep-link bridge between MainTvActivity (producer) and TvAppNavigation diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt index 6d11a94214..b6de746b6c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt @@ -75,9 +75,9 @@ import org.siloserver.silo.common.settings.OverlayPrefsStore import org.siloserver.silo.tv.watchnext.WatchNextSeeder import org.siloserver.silo.tv.cast.RemotePlaybackIdentityManager import org.siloserver.silo.tv.cast.TvSiloCastReceiver -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.tv.profiles.TvActiveProfileReset -import org.siloserver.silo.tv.profiles.TvProfileAwayTracker +import org.siloserver.silo.common.profiles.ProfileAwayTracker import org.siloserver.silo.common.player.PlaybackSessionLifecycle import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull @@ -484,8 +484,8 @@ fun TvAppNavigation( val pendingDeepLink: MutableStateFlow<Uri?> = koinInject(qualifier = named("pendingDeepLink")) val siloCastStandby by siloCastReceiver.standbyState.collectAsState() - val profileAwayTracker: TvProfileAwayTracker = koinInject() - val profileLaunchPreferences: TvProfileLaunchPreferences = koinInject() + val profileAwayTracker: ProfileAwayTracker = koinInject() + val profileLaunchPreferences: ProfileLaunchPreferences = koinInject() val activeProfileReset: TvActiveProfileReset = koinInject() val remotePlaybackIdentityManager: RemotePlaybackIdentityManager = koinInject() val playbackLifecycle: PlaybackSessionLifecycle = koinInject() diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt index 2eab071354..99837a3020 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt @@ -18,7 +18,7 @@ import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.ProfileCommitResult import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.repository.ProfileVerificationRecovery -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences data class TvProfileSelectionUiState( val profiles: List<Profile> = emptyList(), @@ -57,7 +57,7 @@ class TvProfileSelectionViewModel( private val authRepository: AuthRepository? = null, private val consumeSkipsSingleProfile: () -> Boolean = SignInHandoff::consumeSkipsSingleProfilePicker, private val profileVerificationRecovery: ProfileVerificationRecovery? = null, - private val launchPreferences: TvProfileLaunchPreferences? = null, + private val launchPreferences: ProfileLaunchPreferences? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(TvProfileSelectionUiState()) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt index f0671715f7..db1ef422f7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/servers/TvServerListViewModel.kt @@ -6,7 +6,7 @@ import org.siloserver.silo.model.server.ServerEntry import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager import org.siloserver.silo.repository.AuthRepository -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.tv.profiles.TvActiveProfileReset import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -39,7 +39,7 @@ class TvServerListViewModel( private val serverRegistry: ServerRegistry, private val tokenManager: TokenManager, private val authRepository: AuthRepository, - private val profileLaunchPreferences: TvProfileLaunchPreferences, + private val profileLaunchPreferences: ProfileLaunchPreferences, private val activeProfileReset: TvActiveProfileReset, ) : ViewModel() { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt index 1addcf03cd..12a369c4c6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsScreen.kt @@ -127,7 +127,7 @@ import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.common.ui.components.profileAvatarDisplayText import org.siloserver.silo.common.ui.components.rememberProfileAvatarImage import androidx.compose.ui.layout.ContentScale -import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior +import org.siloserver.silo.common.profiles.ProfileLaunchBehavior import org.siloserver.silo.tv.data.preferences.SubtitleMode import org.siloserver.silo.tv.ui.screens.player.TvSubtitleAppearanceOptions import org.siloserver.silo.tv.ui.screens.settings.diagnostics.TvDiagnosticsSettingsPane @@ -1013,7 +1013,7 @@ private fun TvGeneralSettingsPane( onClick = { showProfileLaunchPicker = true }, focusRequester = firstFocusRequester, ) - SettingsFooterText(text = state.profileLaunchBehavior.description) + SettingsFooterText(text = state.profileLaunchBehavior.description(PROFILE_LAUNCH_DEVICE)) } } item { @@ -1158,7 +1158,7 @@ private fun TvGeneralSettingsPane( TvSettingsPickerSheet( title = "Profile Selection", options = ProfileLaunchBehavior.entries.map { - PickerOption(it.raw, it.title, detail = it.description) + PickerOption(it.raw, it.title, detail = it.description(PROFILE_LAUNCH_DEVICE)) }, selectedId = state.profileLaunchBehavior.raw, onSelect = { id -> @@ -1186,6 +1186,9 @@ private enum class CardPresentationPicker { /** Picker id for the synthetic "Custom" preset row (never on the wire). */ private const val CustomCardPresetId = "custom" +/** How Profile Selection's descriptions name this device. */ +private const val PROFILE_LAUNCH_DEVICE = "this Android TV" + @Composable private fun TvPlaybackSettingsPane( state: TvSettingsViewModel.UiState, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt index 08109b4694..61520044d3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -32,10 +32,10 @@ import org.siloserver.silo.network.TokenManager import org.siloserver.silo.repository.AuthRepository import org.siloserver.silo.repository.ProfileRepository import org.siloserver.silo.tv.data.preferences.LegacyTvPrefsMigration -import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior +import org.siloserver.silo.common.profiles.ProfileLaunchBehavior import org.siloserver.silo.tv.data.preferences.SubtitleMode import org.siloserver.silo.tv.data.preferences.SubtitleSize -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.siloserver.silo.tv.watchnext.WatchNextSeeder import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -73,7 +73,7 @@ class TvSettingsViewModel( private val seekIntervalStore: SeekIntervalStore? = null, private val episodeSpoilerStore: EpisodeSpoilerStore? = null, audiobookSettingsStore: AudiobookSettingsStore? = null, - private val profileLaunchPreferences: TvProfileLaunchPreferences? = null, + private val profileLaunchPreferences: ProfileLaunchPreferences? = null, private val watchNextSeeder: WatchNextSeeder? = null, ) : ViewModel() { diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt index 09c774327f..98df00e83f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/TvWorkerFactory.kt @@ -13,7 +13,7 @@ import org.siloserver.silo.common.diagnostics.HostedDiagnosticsDeletionWorker import org.siloserver.silo.common.diagnostics.HostedDiagnosticsReportDeleter import org.siloserver.silo.common.diagnostics.PendingReportStore import org.siloserver.silo.repository.SectionRepository -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import org.koin.core.context.GlobalContext import org.siloserver.silo.network.TokenManager @@ -51,7 +51,7 @@ class TvWorkerFactory : WorkerFactory() { repository = koin.get<WatchNextRepository>(), spoilerStore = koin.get(), catalogRepository = koin.get(), - allowsWatchNext = koin.get<TvProfileLaunchPreferences>()::allowsWatchNext, + allowsWatchNext = koin.get<ProfileLaunchPreferences>()::allowsWatchNext, hasTemporaryScope = koin.get<TokenManager>()::hasTemporaryScope, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt index bd299d72c3..271b547356 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextProfileExpiryReceiver.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.util.Log import org.koin.core.context.GlobalContext -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences /** * Keeps the launcher's Watch Next row in line with Profile Selection while @@ -26,7 +26,7 @@ class WatchNextProfileExpiryReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action !in HANDLED_ACTIONS) return val koin = GlobalContext.get() - val preferences = koin.get<TvProfileLaunchPreferences>() + val preferences = koin.get<ProfileLaunchPreferences>() val seeder = koin.get<WatchNextSeeder>() // This runs on the main thread, as choosing a profile does, so a // choice cannot land between this check and the wipe, and the seed diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt index cce4dcd62a..564410c636 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/watchnext/WatchNextSyncWorker.kt @@ -8,7 +8,7 @@ import org.siloserver.silo.repository.CatalogRepository import org.siloserver.silo.common.settings.EpisodeSpoilerStore import org.siloserver.silo.common.settings.EpisodeSpoilerSupport import org.siloserver.silo.network.ApiResult -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -28,7 +28,7 @@ class WatchNextSyncWorker( private val repository: WatchNextRepository, private val spoilerStore: EpisodeSpoilerStore, private val catalogRepository: CatalogRepository, - /** Profile Selection's verdict; see [TvProfileLaunchPreferences.allowsWatchNext]. */ + /** Profile Selection's verdict; see [ProfileLaunchPreferences.allowsWatchNext]. */ private val allowsWatchNext: () -> Boolean = { true }, /** * A phone's cast identity is active; its titles never reach this TV's diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/InMemoryPrefs.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/InMemoryPrefs.kt new file mode 100644 index 0000000000..6a24d84a22 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/InMemoryPrefs.kt @@ -0,0 +1,58 @@ +package org.siloserver.silo.tv.ui.screens.profiles + +import android.content.SharedPreferences + +/** Just enough SharedPreferences for the store: strings, longs and booleans. */ +internal class InMemoryPrefs : SharedPreferences { + private val values = mutableMapOf<String, Any?>() + + override fun getAll(): MutableMap<String, *> = values.toMutableMap() + override fun getString(key: String?, defValue: String?): String? = values[key] as String? ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? = defValues + override fun getInt(key: String?, defValue: Int): Int = defValue + override fun getLong(key: String?, defValue: Long): Long = values[key] as Long? ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as Boolean? ?: defValue + override fun contains(key: String?): Boolean = key in values + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf<String, Any?>() + private val removed = mutableSetOf<String>() + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removed += key + return this + } + + override fun putStringSet(key: String, values: MutableSet<String>?): SharedPreferences.Editor = this + override fun putInt(key: String, value: Int): SharedPreferences.Editor = this + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + pending[key] = value + return this + } + override fun clear(): SharedPreferences.Editor = this + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + removed.forEach(values::remove) + values.putAll(pending) + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt index c3a7c923f1..fc9f4d0c6a 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileRememberOfferTest.kt @@ -20,9 +20,8 @@ import org.siloserver.silo.network.TokenManagerImpl import org.siloserver.silo.network.api.ProfileApi import org.siloserver.silo.network.apiv2.ApiV2Gate import org.siloserver.silo.repository.ProfileRepository -import org.siloserver.silo.tv.data.preferences.InMemoryPrefs -import org.siloserver.silo.tv.data.preferences.ProfileLaunchBehavior -import org.siloserver.silo.tv.data.preferences.TvProfileLaunchPreferences +import org.siloserver.silo.common.profiles.ProfileLaunchBehavior +import org.siloserver.silo.common.profiles.ProfileLaunchPreferences import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -43,7 +42,7 @@ class TvProfileRememberOfferTest { Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) val barrier = DefaultIdentityTransitionBarrier() val repository = OneProfileRepository(barrier, profile) - val preferences = TvProfileLaunchPreferences(InMemoryPrefs()).apply { + val preferences = ProfileLaunchPreferences(InMemoryPrefs()).apply { settleFirstLaunch(hasRememberedProfile = false) } val viewModel = TvProfileSelectionViewModel( @@ -73,7 +72,7 @@ class TvProfileRememberOfferTest { Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) val barrier = DefaultIdentityTransitionBarrier() val repository = OneProfileRepository(barrier, profile) - val preferences = TvProfileLaunchPreferences(InMemoryPrefs()).apply { + val preferences = ProfileLaunchPreferences(InMemoryPrefs()).apply { settleFirstLaunch(hasRememberedProfile = false) } val viewModel = TvProfileSelectionViewModel( @@ -99,7 +98,7 @@ class TvProfileRememberOfferTest { val repository = OneProfileRepository(barrier, profile) { call -> if (call == 2) reloadInFlight.await() } - val preferences = TvProfileLaunchPreferences(InMemoryPrefs()).apply { + val preferences = ProfileLaunchPreferences(InMemoryPrefs()).apply { settleFirstLaunch(hasRememberedProfile = false) } val viewModel = TvProfileSelectionViewModel( From 726684df7f4c023cc99ea3abab3629fa4dfe9421 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:28:03 -0400 Subject: [PATCH 81/87] perf(home): load 100-item Home rows without blocking the main thread (#551) Read and decode Home responses and the Room Home cache off the main thread, show rows before writing the cache, let Home's first load join the startup warmup's read when no membership change has been acknowledged, and skip the TV resume refresh while the first load runs. Closes Silo-Server/silo-android#549 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../repository/RoomHomeCacheRepository.kt | 42 +++++++++++++---- .../data/sync/MembershipActivationTest.kt | 6 +-- .../silo/tv/ui/screens/home/TvHomeScreen.kt | 5 ++- .../silo/network/apiv2/HomeSectionsV2Api.kt | 32 +++++++++---- .../silo/repository/SectionRepository.kt | 44 +++++++++++++++++- .../silo/viewmodel/HomeViewModel.kt | 43 +++++++++++------- .../silo/network/apiv2/HomeSectionsV2Test.kt | 45 +++++++++++++++++++ .../silo/viewmodel/HomeDismissalV2Test.kt | 2 +- .../silo/viewmodel/HomeScreenV2Test.kt | 2 +- .../HomeViewModelCacheIdentityTest.kt | 6 +-- 10 files changed, 183 insertions(+), 44 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomHomeCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomHomeCacheRepository.kt index b24092535a..237b3c6b6c 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomHomeCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/repository/RoomHomeCacheRepository.kt @@ -10,6 +10,9 @@ import org.siloserver.silo.network.IdentityTransitionBarrier import org.siloserver.silo.repository.port.HomeCachePort import org.siloserver.silo.repository.port.HomeCacheSnapshot import org.siloserver.silo.repository.port.HomeCacheWriteLease +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json /** @@ -19,23 +22,42 @@ import kotlinx.serialization.json.Json * Scope comes from the active [AuthScopeSnapshot]; with no active server/profile * there's nothing to cache or serve (returns null). Corrupt/forward-incompatible * JSON decodes to null rather than crashing the home screen. + * + * The blob is encoded and decoded on [jsonDispatcher]: callers read the cache + * from the main thread, and with 100-item Home rows the blob is about 1 MB, + * which takes hundreds of milliseconds to decode on a low-end TV. */ class RoomHomeCacheRepository( private val db: SiloDatabase, private val snapshotProvider: suspend () -> AuthScopeSnapshot?, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val now: () -> Long = { System.currentTimeMillis() }, + private val jsonDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : HomeCachePort { private val dao = db.homeCacheDao() private val scopedDao = db.catalogCacheDao() private val json = Json { ignoreUnknownKeys = true } + private suspend fun encode(sections: List<ResolvedSection>): String = + withContext(jsonDispatcher) { json.encodeToString(sections) } + + private suspend fun decode(body: String): List<ResolvedSection>? = withContext(jsonDispatcher) { + // Corrupt or forward-incompatible JSON (a SerializationException, which is an + // IllegalArgumentException) is a cache miss; cancellation still propagates. + try { + json.decodeFromString<List<ResolvedSection>>(body) + } catch (e: IllegalArgumentException) { + null + } + } + override suspend fun cacheHomeV2(sections: List<ResolvedSection>, owner: AuthScopeSnapshot, stillCurrent: () -> Boolean) { val profile = owner.profileId ?: return + // Encoding suspends, so it runs before the ownership check, not between it and the write. + val body = encode(sections) if (owner != snapshotProvider()) return val key = scopedKey(owner) - val body = json.encodeToString(sections) db.withTransaction { if (!stillCurrent()) return@withTransaction if (body.encodeToByteArray().size > MAX_CACHE_BYTES) scopedDao.delete(owner.serverId, profile, key) @@ -45,10 +67,10 @@ class RoomHomeCacheRepository( override suspend fun cacheHomeV2IfAbsent(sections: List<ResolvedSection>, owner: AuthScopeSnapshot, stillCurrent: () -> Boolean) { val profile = owner.profileId ?: return + val body = encode(sections) + if (body.encodeToByteArray().size > MAX_CACHE_BYTES) return if (owner != snapshotProvider()) return val key = scopedKey(owner) - val body = json.encodeToString(sections) - if (body.encodeToByteArray().size > MAX_CACHE_BYTES) return db.withTransaction { if (!stillCurrent() || scopedDao.get(owner.serverId, profile, key) != null) return@withTransaction scopedDao.upsert(org.siloserver.silo.common.data.db.entity.CatalogCacheEntity(owner.serverId, profile, key, body, now())) @@ -58,9 +80,10 @@ class RoomHomeCacheRepository( override suspend fun getCachedHomeV2(owner: AuthScopeSnapshot): HomeCacheSnapshot? { val profile = owner.profileId ?: return null if (owner != snapshotProvider()) return null - val row = runCatching { scopedDao.get(owner.serverId, profile, scopedKey(owner)) }.getOrNull() + val row = runCatching { scopedDao.get(owner.serverId, profile, scopedKey(owner)) }.getOrNull() ?: return null + val sections = decode(row.json) if (owner != snapshotProvider()) return null - return row?.let { runCatching { HomeCacheSnapshot(json.decodeFromString<List<ResolvedSection>>(it.json), it.cachedAtMs) }.getOrNull() } + return sections?.let { HomeCacheSnapshot(it, row.cachedAtMs) } } private fun scopedKey(owner: AuthScopeSnapshot): String = owner.identityCacheKey("home-sections-v2") @@ -77,10 +100,10 @@ class RoomHomeCacheRepository( lease: HomeCacheWriteLease, ) { if (lease.identityGeneration != identityTransitions.generation.value) return + val sectionsJson = encode(sections) 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 // that, so don't cache an unreadable row — drop any prior row (which may @@ -100,6 +123,7 @@ class RoomHomeCacheRepository( } override suspend fun getCachedHome(): HomeCacheSnapshot? { + val identityGeneration = identityTransitions.generation.value val snapshot = snapshotProvider() ?: return null val profileId = snapshot.profileId ?: return null // An oversized legacy row (written before the cap) throws @@ -110,9 +134,9 @@ class RoomHomeCacheRepository( runCatching { dao.delete(snapshot.serverId, profileId) } null } ?: return null - val sections = runCatching { - json.decodeFromString<List<ResolvedSection>>(row.sectionsJson) - }.getOrNull() ?: return null + val sections = decode(row.sectionsJson) ?: return null + // The read and the decode suspend: serve nothing if the identity moved meanwhile. + if (snapshot != snapshotProvider() || identityGeneration != identityTransitions.generation.value) return null return HomeCacheSnapshot(sections = sections, cachedAtMs = row.cachedAtMs) } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt index 69a305f9d5..6e27a273c3 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/sync/MembershipActivationTest.kt @@ -176,7 +176,7 @@ class MembershipActivationTest { } }) val (repository, _) = repository(client) - val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = Dispatchers.Main))), org.siloserver.silo.domain.MediaActionsCoordinator(repository), identityTransitions = barrier) viewModels += vm vm.uiState.first { !it.isLoading } @@ -225,7 +225,7 @@ class MembershipActivationTest { } }) val (repository, _) = repository(client) - val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = Dispatchers.Main))), org.siloserver.silo.domain.MediaActionsCoordinator(repository), identityTransitions = barrier) viewModels += vm vm.uiState.first { !it.isLoading } @@ -286,7 +286,7 @@ class MembershipActivationTest { } }) val (repository, _) = repository(client) - val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + val vm = HomeViewModel(SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = Dispatchers.Main))), org.siloserver.silo.domain.MediaActionsCoordinator(repository), identityTransitions = barrier) viewModels += vm vm.uiState.first { !it.isLoading } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt index 420151bdc2..62e937e9e8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt @@ -101,7 +101,10 @@ fun TvHomeScreen( androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { - if (shouldRefreshOnResume()) { + // The observer gets ON_RESUME as soon as it is added. On a new + // ViewModel that is while its own first load is still running, + // and a second full Home read would only supersede it. + if (shouldRefreshOnResume() && !viewModel.uiState.value.isLoading) { viewModel.refreshFromRealtime() } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Api.kt index ad6c75223e..76a8be053e 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Api.kt @@ -3,6 +3,9 @@ package org.siloserver.silo.network.apiv2 import io.ktor.client.HttpClient import io.ktor.client.request.* import io.ktor.http.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.* import org.siloserver.silo.model.section.SectionsResponse import org.siloserver.silo.model.section.ResolvedSection @@ -22,7 +25,17 @@ internal fun decodeSectionsV2(body: JsonObject): SectionsResponse { return SectionsResponse(rows.map { decodeSectionV2(it.jsonObject) }) } -class HomeSectionsV2Api(private val client: HttpClient, private val tokens: TokenManager, private val gate: ApiV2Gate) { +class HomeSectionsV2Api( + private val client: HttpClient, + private val tokens: TokenManager, + private val gate: ApiV2Gate, + /** + * Where Home responses are read and decoded. With 100-item rows the full + * Home response is over 1 MB, which takes about a second to read and decode + * on a low-end TV, so it must not run on the caller's (often the main) thread. + */ + private val decodeDispatcher: CoroutineDispatcher = Dispatchers.Default, +) { suspend fun capture(): AuthScopeSnapshot? = tokens.captureProfileScope() suspend fun current(owner: AuthScopeSnapshot): Boolean = owner.stillOwns(tokens, OwnerPolicy.FULL) suspend fun dismiss(surface: String, itemId: String, anchor: String, owner: AuthScopeSnapshot): ApiResult<Unit> { @@ -38,19 +51,22 @@ class HomeSectionsV2Api(private val client: HttpClient, private val tokens: Toke }) { } } - suspend fun list(owner: AuthScopeSnapshot): ApiResult<SectionsResponse> = + suspend fun list(owner: AuthScopeSnapshot): ApiResult<SectionsResponse> = withContext(decodeDispatcher) { ownedV2Call<JsonObject, SectionsResponse>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> client.get("/api/v2/home/sections") { authScope(scope!!); requireSiloAuth() } }) { decodeSectionsV2(it) } + } suspend fun section(id: String, owner: AuthScopeSnapshot): ApiResult<HomeSectionItemsResponse> { if (id.isBlank()) return ApiResult.Error(422, "validation_failed", "Invalid home section.") - return ownedV2Call<JsonObject, HomeSectionItemsResponse>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> - client.get("/api/v2/home/sections/${id.encodeURLPathPart()}/items") { authScope(scope!!); requireSiloAuth() } - }) { row -> - val section = decodeSectionV2(row) - require(section.id == id) { "Wrong section identity." } - HomeSectionItemsResponse(section, section.items) + return withContext(decodeDispatcher) { + ownedV2Call<JsonObject, HomeSectionItemsResponse>(gate, tokens, owner, OwnerPolicy.FULL, HttpStatusCode.OK, { scope -> + client.get("/api/v2/home/sections/${id.encodeURLPathPart()}/items") { authScope(scope!!); requireSiloAuth() } + }) { row -> + val section = decodeSectionV2(row) + require(section.id == id) { "Wrong section identity." } + HomeSectionItemsResponse(section, section.items) + } } } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt index 9e4c7368f3..4f3a60dd12 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/SectionRepository.kt @@ -14,8 +14,15 @@ import org.siloserver.silo.network.map import org.siloserver.silo.repository.port.CatalogCachePort import org.siloserver.silo.repository.port.NoOpCatalogCachePort import org.siloserver.silo.repository.port.canServeCache +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class SectionRepository( private val sectionApi: SectionApi, @@ -25,7 +32,42 @@ class SectionRepository( suspend fun captureHomeAuthority() = sectionApi.captureHomeAuthority() suspend fun isHomeAuthorityCurrent(owner: org.siloserver.silo.network.AuthScopeSnapshot) = sectionApi.isHomeAuthorityCurrent(owner) - suspend fun getHomeSections(owner: org.siloserver.silo.network.AuthScopeSnapshot) = sectionApi.getHomeSections(owner) + private val homeReadLock = Mutex() + private var homeRead: Pair<org.siloserver.silo.network.AuthScopeSnapshot, CompletableDeferred<ApiResult<SectionsResponse>>>? = null + + suspend fun getHomeSections(owner: org.siloserver.silo.network.AuthScopeSnapshot): ApiResult<SectionsResponse> { + val read = CompletableDeferred<ApiResult<SectionsResponse>>() + homeReadLock.withLock { homeRead = owner to read } + try { + return sectionApi.getHomeSections(owner).also { read.complete(it) } + } catch (e: Throwable) { + read.completeExceptionally(e) + throw e + } finally { + withContext(NonCancellable) { homeReadLock.withLock { if (homeRead?.second === read) homeRead = null } } + } + } + + /** + * Home's first load: joins a read for the same [owner] that is already + * running instead of starting another. At launch the startup warmup starts + * one, and with 100-item rows each read downloads and decodes over 1 MB. + * Refreshes use [getHomeSections], which never joins: they must see changes + * made after an earlier read started. + */ + suspend fun getHomeSectionsJoiningRead(owner: org.siloserver.silo.network.AuthScopeSnapshot): ApiResult<SectionsResponse> { + val running = homeReadLock.withLock { homeRead?.takeIf { it.first == owner }?.second } + if (running != null) { + try { + return running.await() + } catch (e: CancellationException) { + // The read we joined was cancelled with its caller; read for ourselves. + currentCoroutineContext().ensureActive() + } + } + return getHomeSections(owner) + } + suspend fun getHomeSectionItems(id: String, owner: org.siloserver.silo.network.AuthScopeSnapshot) = sectionApi.getHomeSectionItems(id, owner) suspend fun dismissHomeItem(surface: String, itemId: String, anchor: String, owner: org.siloserver.silo.network.AuthScopeSnapshot) = diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt index 1fc1899fc0..67d341283c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt @@ -307,7 +307,15 @@ class HomeViewModel( ), ) } - when (val result = sectionRepository.getHomeSections(owner)) { + // The first load may join the startup warmup's read; refreshes always read anew. + // Only with no acknowledged membership change: a joined read may predate one, and + // must not vouch for it through the witnesses captured above. + val read = if (trigger == HomeLoadTrigger.INITIAL && membershipWitnesses.isEmpty()) { + sectionRepository.getHomeSectionsJoiningRead(owner) + } else { + sectionRepository.getHomeSections(owner) + } + when (val result = read) { is ApiResult.Success -> { val sections = result.data.sections // `/home/sections` already returns each section with its items @@ -331,23 +339,11 @@ class HomeViewModel( // Don't persist a partially-resolved home over a good cached one. val fullyResolved = hydration.fullyResolved - // Cache the RAW server sections (snapshot), but display with the - // local optimistic overlay applied. - if ( - fullyResolved && - // A superseded fetch must not write its sections to the - // cache either: the next cold start would serve them. - generation == fetchGeneration && - requestIdentityGeneration == identityTransitions.generation.value - ) { - homeCache.cacheHomeV2(resolved, owner) { generation == fetchGeneration && displayedOwner == owner } - } val overlaid = overlayLocalState(resolved) - // Checked AGAIN, after the cache write and the overlay. Both - // suspend, and a newer fetch can complete and publish during - // either — so a check taken before them proves only that this - // reply was current when it arrived, not that it still is when - // it finally writes. + // Checked AGAIN, after the overlay. It suspends, and a newer + // fetch can complete and publish meanwhile — so a check taken + // before it proves only that this reply was current when it + // arrived, not that it still is when it finally writes. if (!mayPublish(owner, generation)) { report(HomeLoadOutcome.SUPERSEDED, resolved) return generation @@ -374,6 +370,19 @@ class HomeViewModel( it.copy(isLoading = false, error = null) } } + // Cache the RAW server sections (snapshot); the screen shows them + // with the local overlay. Written after publishing: with 100-item + // rows the snapshot is about 1 MB, and encoding it would hold the + // rows back from the screen. + if ( + fullyResolved && + // A superseded fetch must not write its sections to the + // cache either: the next cold start would serve them. + generation == fetchGeneration && + requestIdentityGeneration == identityTransitions.generation.value + ) { + homeCache.cacheHomeV2(resolved, owner) { generation == fetchGeneration && displayedOwner == owner } + } } is ApiResult.Error -> { // A superseded fetch's failure is not this home's failure. diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Test.kt index e38c15d720..4518d02425 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/HomeSectionsV2Test.kt @@ -4,10 +4,13 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.mock.* import io.ktor.http.* import kotlinx.coroutines.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.siloserver.silo.network.* import org.siloserver.silo.network.api.SectionApi import org.siloserver.silo.repository.SectionRepository +import kotlin.coroutines.CoroutineContext import kotlin.test.* class HomeSectionsV2Test { @@ -37,6 +40,48 @@ class HomeSectionsV2Test { val sent = sends; assertIs<ApiResult.Error>(api.list(original)); assertEquals(sent, sends) } finally { client.close() } } + private class RecordingDispatcher : CoroutineDispatcher() { + var dispatches = 0 + override fun dispatch(context: CoroutineContext, block: Runnable) { dispatches++; Dispatchers.Default.dispatch(context, block) } + } + // With 100-item rows Home is over 1 MB; reading it on the caller's main thread stalled low-end TVs. + @Test fun homeResponsesAreReadAndDecodedOnTheDecodeDispatcher() = runTest { + val row = """{"id":"second","section_type":"continue_watching","title":"Second","items":[{"content_id":"movie:2","type":"movie","title":"Two"}]}""" + val client = HttpClient(MockEngine { + respond(if (it.url.encodedPath.endsWith("/items")) row else good, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + }) + try { + val decode = RecordingDispatcher() + val api = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = decode) + api.list(owner).getOrThrow(); val afterList = decode.dispatches; assertTrue(afterList > 0) + assertEquals("second", api.section("second", owner).getOrThrow().section?.id); assertTrue(decode.dispatches > afterList) + } finally { client.close() } + } + @Test fun firstLoadJoinsARunningReadButRefreshesNeverDo() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var sends = 0; var gate = CompletableDeferred<Unit>() + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = dispatcher + addHandler { sends++; gate.await(); respond(good, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) } + })) + try { + val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))) + val warmup = async { repository.getHomeSections(owner) }; runCurrent() + val firstLoad = async { repository.getHomeSectionsJoiningRead(owner) }; runCurrent(); assertEquals(1, sends) + val refresh = async { repository.getHomeSections(owner) }; runCurrent(); assertEquals(2, sends) + gate.complete(Unit) + for (read in listOf(warmup, firstLoad, refresh)) assertEquals(listOf("second", "first"), read.await().getOrThrow().sections.map { it.id }) + assertEquals(2, sends) + // A joined read cancelled with its own caller leaves the first load to read for itself. + gate = CompletableDeferred() + val cancelled = async { repository.getHomeSections(owner) }; runCurrent() + val retried = async { repository.getHomeSectionsJoiningRead(owner) }; runCurrent(); assertEquals(3, sends) + cancelled.cancel(); runCurrent(); assertEquals(4, sends) + gate.complete(Unit); assertIs<ApiResult.Success<*>>(retried.await()) + // Nothing running: the first load reads normally. + assertIs<ApiResult.Success<*>>(repository.getHomeSectionsJoiningRead(owner)); assertEquals(5, sends) + } finally { client.close() } + } @Test fun suspendedAuthorityCannotPublishIntoReplacementPlaybackRun() = runTest { var sends = 0; var checks = 0; var run = 1; var published = false val entered = CompletableDeferred<Unit>(); val release = CompletableDeferred<Unit>() diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalV2Test.kt index 245158e412..d970ff850a 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalV2Test.kt @@ -68,7 +68,7 @@ class HomeDismissalV2Test { })) { install(ContentNegotiation) { json(SiloJson) } } val store = ViewModelStore() try { - val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))) val vm = HomeViewModel(repository, MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client)))) store.put("home", vm) runCurrent() diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt index a212001701..ee2e6029ad 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeScreenV2Test.kt @@ -61,7 +61,7 @@ class HomeScreenV2Test { })) val store = ViewModelStore() try { - val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))) + val repository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))) val vm = HomeViewModel(repository, MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))), cache, local, catalogLanguage = language) store.put("home", vm); block(vm) } finally { store.clear(); client.close(); Dispatchers.resetMain() } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt index 2d1b060e96..36bf132e90 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeViewModelCacheIdentityTest.kt @@ -77,7 +77,7 @@ class HomeViewModelCacheIdentityTest { val cache = RecordingHomeCache() val viewModel = HomeViewModel( sectionRepository = SectionRepository( - sectionApi = SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted)), + sectionApi = SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher)), ), mediaActions = mediaActions(), homeCache = cache, @@ -107,7 +107,7 @@ class HomeViewModelCacheIdentityTest { } val observations = mutableListOf<HomeLoadObservation>() val viewModel = HomeViewModel( - sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))), mediaActions = mediaActions(), diagnostics = HomeDiagnosticsObserver(observations::add), ) @@ -150,7 +150,7 @@ class HomeViewModelCacheIdentityTest { install(ContentNegotiation) { json(SiloJson) } } val viewModel = HomeViewModel( - sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted))), + sectionRepository = SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))), mediaActions = mediaActions(), ) viewModel.uiState.first { !it.isLoading } From 940602bc5d81d7ab16bbde2f6431af6531991f0b Mon Sep 17 00:00:00 2001 From: JonahMMay <jonah@mayfamily.me> Date: Mon, 12 Oct 2026 16:53:44 +0000 Subject: [PATCH 82/87] Point the TV setup marquee at prairie_icon_background Upstream's new marquee referenced R.color.silo_icon_background, which only exists as prairie_icon_background in Prairie. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt index fc8109e9b9..638fdbc004 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/marquee/TvMarqueeSetupSteps.kt @@ -89,7 +89,7 @@ private fun AppIconPicture() { Modifier .size(46.dp) .clip(RoundedCornerShape(11.dp)) - .background(colorResource(R.color.silo_icon_background)) + .background(colorResource(R.color.prairie_icon_background)) .border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(11.dp)), contentAlignment = Alignment.Center, ) { From 251aed86487da952641c05065e805e1bec4cc2d7 Mon Sep 17 00:00:00 2001 From: JonahMMay <jonah@mayfamily.me> Date: Mon, 12 Oct 2026 17:02:36 +0000 Subject: [PATCH 83/87] Drop the retired set_hdr_enabled cast assertion Upstream ca53cb00f (#381) retired the command; the test kept asserting it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../org/prairieserver/prairie/cast/PrairieCastMessageTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt index 0f6e1f5193..72c39c7b2e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/cast/PrairieCastMessageTest.kt @@ -134,7 +134,6 @@ class PrairieCastMessageTest { assertEquals(12.5, PrairieCastControlCommand.seek(12.5).seconds) assertEquals(1.25, PrairieCastControlCommand.setPlaybackSpeed(1.25).speed) assertEquals("fit", PrairieCastControlCommand.setVideoGravity("fit").value) - assertEquals(true, PrairieCastControlCommand.setHdrEnabled(true).enabled) assertEquals("bottom", PrairieCastControlCommand.setSubtitlePosition("bottom").value) assertEquals(0.4, PrairieCastControlCommand.setVolume(0.4).volume) assertEquals(true, PrairieCastControlCommand.setMuted(true).enabled) From a6bbb8ebf3a04fd68b7924c3e2c429aa23167b5e Mon Sep 17 00:00:00 2001 From: JonahMMay <jonah@mayfamily.me> Date: Mon, 12 Oct 2026 17:10:59 +0000 Subject: [PATCH 84/87] Rename TvPlayerScreen's toSiloCast* helpers to toPrairieCast* The merge left upstream's new call sites rebranded (toPrairieCastVideoFillMode, toPrairieCastSubtitlePosition) while the private helpers kept their Silo names, so the TV app would not compile. Name them all Prairie. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../tv/ui/screens/player/TvPlayerScreen.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt index d387e8419a..b3e3fe0031 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt @@ -3858,11 +3858,11 @@ private fun TvPlayerViewModel.UiState.toPrairieCastPlaybackState( isBuffering = isBuffering, currentTime = position, duration = duration, - audioTracks = audioTracks.map { it.toSiloCastTrack(kind = "audio") }, - subtitleTracks = subtitleTracks.map { it.toSiloCastTrack(kind = "subtitle") }, + audioTracks = audioTracks.map { it.toPrairieCastTrack(kind = "audio") }, + subtitleTracks = subtitleTracks.map { it.toPrairieCastTrack(kind = "subtitle") }, selectedAudioTrackId = audioTracks.firstOrNull { it.isSelected }?.index?.toLong(), selectedSubtitleTrackId = subtitleTracks.firstOrNull { it.isSelected }?.index?.toLong(), - qualityOptions = videoQualities.map { it.toSiloCastQualityOption() }, + qualityOptions = videoQualities.map { it.toPrairieCastQualityOption() }, activeQualityId = activeQualityId, isQualitySwitching = false, playbackSpeed = playbackSpeed, @@ -3878,7 +3878,7 @@ private fun TvPlayerViewModel.UiState.toPrairieCastPlaybackState( // stays on the wire because older remotes require it. supportsHDRToggle = false, subtitleSyncMs = subtitleDelayMs, - subtitlePosition = subtitleAppearance.position.toSiloCastPositionValue(), + subtitlePosition = subtitleAppearance.position.toPrairieCastPositionValue(), supportsSubtitleDelay = true, supportsSubtitlePosition = true, volume = volumeState.volume, @@ -3890,7 +3890,7 @@ private fun TvPlayerViewModel.UiState.toPrairieCastPlaybackState( ) } -private fun PlayerTrackEntry.toSiloCastTrack(kind: String): PrairieCastTrack = +private fun PlayerTrackEntry.toPrairieCastTrack(kind: String): PrairieCastTrack = PrairieCastTrack( kind = kind, trackId = index.toLong(), @@ -3898,7 +3898,7 @@ private fun PlayerTrackEntry.toSiloCastTrack(kind: String): PrairieCastTrack = detail = language, ) -private fun VideoQualityOption.toSiloCastQualityOption(): PrairieCastQualityOption = +private fun VideoQualityOption.toPrairieCastQualityOption(): PrairieCastQualityOption = PrairieCastQualityOption( id = id, label = label, @@ -3906,20 +3906,20 @@ private fun VideoQualityOption.toSiloCastQualityOption(): PrairieCastQualityOpti ) // Apple SubtitlePositionPreset raw values: "bottom", "lower-third", "top". -private fun SubtitlePositionPreset.toSiloCastPositionValue(): String = when (this) { +private fun SubtitlePositionPreset.toPrairieCastPositionValue(): String = when (this) { SubtitlePositionPreset.Top -> "top" SubtitlePositionPreset.LowerThird -> "lower-third" SubtitlePositionPreset.Bottom -> "bottom" } -private fun String.toSiloCastVideoFillMode(): VideoFillMode = +private fun String.toPrairieCastVideoFillMode(): VideoFillMode = when (trim().lowercase()) { "zoom", "crop", "fill" -> VideoFillMode.Zoom "stretch" -> VideoFillMode.Stretch else -> VideoFillMode.Fit } -private fun String.toSiloCastSubtitlePosition(): SubtitlePositionPreset { +private fun String.toPrairieCastSubtitlePosition(): SubtitlePositionPreset { when (trim().lowercase()) { "top" -> return SubtitlePositionPreset.Top "lower-third", "lower_third", "lowerthird" -> return SubtitlePositionPreset.LowerThird From fc0ef5580fd2eaaadcc951b94622a6b855656d80 Mon Sep 17 00:00:00 2001 From: JonahMMay <jonah@mayfamily.me> Date: Mon, 12 Oct 2026 17:48:54 +0000 Subject: [PATCH 85/87] Restore Hud body text constants and MaterialTheme import; cancel VM scope in CollectionsViewModelLoadOrderTest Upstream's player restyle dropped the HudBody* constants and the tv MaterialTheme import that Prairie's playback stats block still uses. The collections load-order test leaked an in-flight viewModelScope load past resetMain, surfacing as UncaughtExceptionsBeforeTest in the next test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../prairie/tv/ui/screens/player/TvPlayerHud.kt | 3 +++ .../viewmodel/CollectionsViewModelLoadOrderTest.kt | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt index 3b1a93fd6f..bb6be83501 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt @@ -105,6 +105,7 @@ 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 androidx.tv.material3.MaterialTheme import androidx.tv.material3.Icon import androidx.tv.material3.Text import kotlinx.coroutines.launch @@ -154,6 +155,8 @@ private val HudRowPadding = 10.dp private val HudCheckColumn = 20.dp private val HudPaneColumnGap = 26.dp private val HudSectionGap = 12.dp +private val HudBodyTextSize = 16.sp +private val HudBodyLineHeight = 20.sp /** The left column of a two-column pane takes 330 of 736dp, as in the mockup. */ private const val HudLeftColumnWeight = 33f diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt index 3273d10a50..d11198ce23 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt @@ -5,9 +5,11 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.http.HttpHeaders import io.ktor.http.headersOf +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -46,8 +48,8 @@ class CollectionsViewModelLoadOrderTest { ) }, ) + val viewModel = CollectionsViewModel(CollectionRepository(CollectionApi(client, ApiV2Gate.Unrestricted))) try { - val viewModel = CollectionsViewModel(CollectionRepository(CollectionApi(client, ApiV2Gate.Unrestricted))) withContext(Dispatchers.Default) { firstEntered.await() } // An access change refreshes while the initial load still waits on @@ -61,6 +63,10 @@ class CollectionsViewModelLoadOrderTest { assertEquals(listOf("after"), viewModel.uiState.value.collections.map { it.id }) } finally { + // Cancel the VM's scope before Main is reset so a load still in + // flight cannot resume on a missing Main and leak an uncaught + // exception into whichever test runs next. + viewModel.viewModelScope.cancel() client.close() Dispatchers.resetMain() } From da16a540b0bb3719e03db586872bf3f6e894a09c Mon Sep 17 00:00:00 2001 From: JonahMMay <jonah@mayfamily.me> Date: Mon, 12 Oct 2026 18:01:16 +0000 Subject: [PATCH 86/87] Restore the 95% :shared coverage gate after the upstream merge Upstream dropped Kover, so the 83-commit merge brought untested code that took :shared to 94.4%. Add tests for the pure Watch Party presentation, playback-context and request-target-summary logic, and exclude live I/O glue (OkHttp, websocket frame helpers, server identity probe) like the other realtime clients already are. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- shared/build.gradle.kts | 9 + .../model/request/RequestTargetSummaryTest.kt | 54 ++++++ .../WatchPartyPresentationCoverageTest.kt | 167 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestTargetSummaryTest.kt create mode 100644 shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPresentationCoverageTest.kt diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 9ca97f2734..aacca68672 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -152,6 +152,15 @@ kover { "org.prairieserver.prairie.player.SubtitleTrackDisplayLabelKt", "org.prairieserver.prairie.discovery.LocalIpv4Addresses_androidKt", "org.prairieserver.prairie.util.Dispatchers_androidKt", + // Platform OkHttp glue that lives in androidMain; the source-set filter leaks it. + "org.prairieserver.prairie.network.PrairieOkHttp", + "org.prairieserver.prairie.network.PrairieOkHttp$*", + // Live websocket frame I/O helpers in the same file as the realtime clients above. + "org.prairieserver.prairie.network.WatchTogetherRealtimeClientKt", + "org.prairieserver.prairie.network.WatchTogetherRealtimeClientKt$*", + // Live server identity/connection probe (needs a real server). + "org.prairieserver.prairie.network.api.DefaultServerIdentityApi", + "org.prairieserver.prairie.network.api.DefaultServerIdentityApi$*", ) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestTargetSummaryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestTargetSummaryTest.kt new file mode 100644 index 0000000000..2b2a96293b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/request/RequestTargetSummaryTest.kt @@ -0,0 +1,54 @@ +package org.prairieserver.prairie.model.request + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RequestTargetSummaryTest { + private fun target(quality: String, status: String) = RequestTarget( + id = quality + status, + requestId = "r", + quality = quality, + status = status, + createdAt = "2026-10-01T00:00:00Z", + updatedAt = "2026-10-01T00:00:00Z", + ) + + @Test + fun textIsNullForSingleTargetRequests() { + assertNull(RequestTargetSummary.text(emptyList())) + assertNull(RequestTargetSummary.text(listOf(target("1080p", RequestStatus.Downloading)))) + } + + @Test + fun textNamesEachQualityState() { + val targets = listOf( + target("1080p", RequestStatus.Downloading), + target("4K", RequestStatus.Queued), + target("720p", RequestStatus.Approved), + target("480p", RequestStatus.Pending), + target("360p", RequestStatus.Completed), + target("240p", RequestStatus.Failed), + ) + assertEquals( + "1080p downloading · 4K queued · 720p queued · 480p pending · 360p done · 240p failed", + RequestTargetSummary.text(targets), + ) + } + + @Test + fun textSkipsBlankQualitiesAndUnknownStatuses() { + assertEquals( + "1080p done", + RequestTargetSummary.text(listOf(target("1080p", RequestStatus.Completed), target("", RequestStatus.Completed), target("4K", "weird"))), + ) + assertNull(RequestTargetSummary.text(listOf(target("", RequestStatus.Completed), target("4K", "weird")))) + } + + @Test + fun qualitiesListsNonBlankQualities() { + assertEquals("1080p · 4K", RequestTargetSummary.qualities(listOf(target("1080p", "x"), target("", "x"), target("4K", "x")))) + assertNull(RequestTargetSummary.qualities(listOf(target("", "x")))) + assertNull(RequestTargetSummary.qualities(emptyList())) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPresentationCoverageTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPresentationCoverageTest.kt new file mode 100644 index 0000000000..811bd323b0 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchPartyPresentationCoverageTest.kt @@ -0,0 +1,167 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomMember +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.Suggestion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WatchPartyPresentationCoverageTest { + private fun playing( + content: String? = "movie:a", + file: Int? = 7, + position: Double = 12.5, + paused: Boolean = false, + state: RoomPlaybackState = RoomPlaybackState.Playing, + phase: RoomPhase = RoomPhase.Playing, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + playbackState = state, + selectionRevision = 3L, + selectedContentId = content, + selectedFileId = file, + selectedLibraryId = 9, + isPaused = paused, + anchorPositionSeconds = position, + ) + + @Test + fun playbackContextCarriesTheRoomsExactSelection() { + val context = assertNotNull(playing().playbackContext()) + assertEquals("r", context.roomId) + assertEquals(3L, context.selectionRevision) + assertEquals("movie:a", context.contentId) + assertEquals(7, context.fileId) + assertEquals(9, context.libraryId) + assertEquals(12.5, context.positionSeconds) + assertEquals(false, context.paused) + } + + @Test + fun playbackContextIsNullUntilTheRoomIsPlayingWithAFile() { + assertNull(playing(phase = RoomPhase.Lobby).playbackContext()) + assertNull(playing(content = null).playbackContext()) + assertNull(playing(content = " ").playbackContext()) + assertNull(playing(file = null).playbackContext()) + assertNull(playing(file = 0).playbackContext()) + } + + @Test + fun playbackContextStartsPausedUnlessTheRoomIsPlaying() { + assertTrue(assertNotNull(playing(paused = true).playbackContext()).paused) + assertTrue(assertNotNull(playing(state = RoomPlaybackState.Paused).playbackContext()).paused) + assertEquals(0.0, assertNotNull(playing(position = -1.0).playbackContext()).positionSeconds) + assertEquals(0.0, assertNotNull(playing(position = Double.NaN).playbackContext()).positionSeconds) + } + + private val host = RoomMember(userId = "1", profileId = "a", displayName = "Ana", isHost = true, connected = true) + private val guest = RoomMember(userId = "2", profileId = "b", displayName = "ben", connected = true) + + @Test + fun seatStatesAndStatusWords() { + val phase = RoomPhase.Playing + assertEquals(WatchPartySeatState.Away, watchPartySeatState(guest.copy(connected = false), phase)) + assertEquals(WatchPartySeatState.Buffering, watchPartySeatState(guest.copy(isBuffering = true), phase)) + assertEquals(WatchPartySeatState.Syncing, watchPartySeatState(guest.copy(isSyncing = true), phase)) + assertEquals(WatchPartySeatState.Watching, watchPartySeatState(guest.copy(isReady = true), phase)) + assertEquals(WatchPartySeatState.Joining, watchPartySeatState(guest, phase)) + assertEquals(WatchPartySeatState.Ready, watchPartySeatState(host, RoomPhase.Lobby)) + assertEquals(WatchPartySeatState.NotReady, watchPartySeatState(guest, RoomPhase.Lobby)) + + assertNull(watchPartySeatStatus(WatchPartySeatState.Ready)) + assertNull(watchPartySeatStatus(WatchPartySeatState.Watching)) + assertEquals("not ready", watchPartySeatStatus(WatchPartySeatState.NotReady)) + assertEquals("reconnecting", watchPartySeatStatus(WatchPartySeatState.Away)) + assertEquals("syncing", watchPartySeatStatus(WatchPartySeatState.Syncing)) + assertEquals("buffering", watchPartySeatStatus(WatchPartySeatState.Buffering)) + assertEquals("joining", watchPartySeatStatus(WatchPartySeatState.Joining)) + } + + @Test + fun seatOrderAndPresenceSummary() { + val self = guest.copy(userId = "3", displayName = "Zed", isSelf = true) + val other = guest.copy(userId = "4", displayName = "Cy") + assertEquals(listOf("Ana", "Zed", "Cy"), watchPartySeatOrder(listOf(other, self, host)).map { it.displayName }) + + assertEquals("nobody connected", watchPartyPresenceSummary(emptyList(), RoomPhase.Lobby)) + assertEquals("only you so far", watchPartyPresenceSummary(listOf(host), RoomPhase.Lobby)) + assertEquals("1 of 2 ready", watchPartyPresenceSummary(listOf(host, guest), RoomPhase.Lobby)) + assertEquals("1 of 2 watching", watchPartyPresenceSummary(listOf(host.copy(isReady = true), guest), RoomPhase.Playing)) + } + + private fun lobby( + role: MemberRole, + mode: RoomSelectionMode = RoomSelectionMode.HostPick, + content: String? = null, + phase: RoomPhase = RoomPhase.Lobby, + hostConnected: Boolean = true, + ) = RoomSnapshot( + roomId = "r", + phase = phase, + selfRole = role, + selfCanManageRoom = role == MemberRole.Host, + selectionMode = mode, + selectedContentId = content, + hostConnected = hostConnected, + members = listOf(host.copy(isSelf = role == MemberRole.Host), guest.copy(isSelf = role == MemberRole.Guest)), + ) + + private fun suggestion(id: String) = Suggestion( + id = id, roomId = "r", contentId = "movie:$id", contentType = "movie", title = id, + voteCount = 0, createdAt = "2026-09-25T10:00:00Z", + ) + + @Test + fun lobbyHeroForVotingChosenAndOpenRooms() { + val vote = lobby(MemberRole.Host, mode = RoomSelectionMode.Vote) + assertTrue(watchPartyLobbyHero(vote, listOf(suggestion("a"))).isVoteQuestion) + val one = watchPartyLobbyHero(vote, listOf(suggestion("a"))).eyebrow + val two = watchPartyLobbyHero(vote, listOf(suggestion("a"), suggestion("b"))).eyebrow + assertTrue(one.contains("1 title") && !one.contains("titles")) + assertTrue(two.contains("2 titles")) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest, mode = RoomSelectionMode.Vote), emptyList()).supporting!!.contains("vote")) + + val chosenByYou = watchPartyLobbyHero(lobby(MemberRole.Host, content = "movie:a"), emptyList(), selectionUnavailable = true) + assertEquals("You chose this", chosenByYou.supporting) + assertEquals("A title you can't see", chosenByYou.title) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest, content = "movie:a"), emptyList()).supporting!!.endsWith("chose this")) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest, content = "movie:a", phase = RoomPhase.Playing), emptyList()).eyebrow.startsWith("Now watching")) + + assertEquals("Pick something\nto watch", watchPartyLobbyHero(lobby(MemberRole.Host), emptyList()).title) + assertTrue(watchPartyLobbyHero(lobby(MemberRole.Guest), emptyList()).title!!.startsWith("Waiting for")) + } + + @Test + fun connectionLabelAndBanner() { + val open = lobby(MemberRole.Host) + assertEquals(WatchPartyConnectionTone.Good to "2 here", watchPartyConnectionLabel(open, connected = true, reconnecting = false)) + val solo = open.copy(members = listOf(host)) + assertEquals(WatchPartyConnectionTone.Good to "Open", watchPartyConnectionLabel(solo, connected = true, reconnecting = false)) + val hostGone = lobby(MemberRole.Guest, hostConnected = false) + assertEquals(WatchPartyConnectionTone.Warning to "Host away", watchPartyConnectionLabel(hostGone, connected = true, reconnecting = false)) + assertEquals(WatchPartyConnectionTone.Warning to "Reconnecting", watchPartyConnectionLabel(open, connected = false, reconnecting = true)) + assertEquals(WatchPartyConnectionTone.Neutral to "Connecting", watchPartyConnectionLabel(open, connected = false, reconnecting = false)) + + assertTrue(watchPartyLobbyBanner(open, selectionUnavailable = true, reconnecting = false)!!.contains("Choose something else")) + assertTrue(watchPartyLobbyBanner(lobby(MemberRole.Guest), selectionUnavailable = true, reconnecting = false)!!.contains("pick something else")) + assertTrue(watchPartyLobbyBanner(hostGone, selectionUnavailable = false, reconnecting = false)!!.contains("lost connection")) + assertEquals("Reconnecting to the party…", watchPartyLobbyBanner(open, selectionUnavailable = false, reconnecting = true)) + assertNull(watchPartyLobbyBanner(open, selectionUnavailable = false, reconnecting = false)) + } + + @Test + fun avatarIndexIsStableAndBounded() { + assertEquals(0, watchPartyAvatarIndex(guest, 0)) + val index = watchPartyAvatarIndex(guest, 6) + assertTrue(index in 0..5) + assertEquals(index, watchPartyAvatarIndex(guest, 6)) + } +} From 778b44f293bbfcea0760753f168913794a7fc543 Mon Sep 17 00:00:00 2001 From: JonahMMay <jonah@mayfamily.me> Date: Mon, 12 Oct 2026 18:17:35 +0000 Subject: [PATCH 87/87] CollectionsViewModelLoadOrderTest: cancelAndJoin VM scope before resetMain cancel() alone let the in-flight Ktor call resume on a reset Main, which surfaced as UncaughtExceptionsBeforeTest in LiveTvViewModelTest. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../viewmodel/CollectionsViewModelLoadOrderTest.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt index d11198ce23..f6d7d914b3 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CollectionsViewModelLoadOrderTest.kt @@ -9,7 +9,8 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -63,10 +64,11 @@ class CollectionsViewModelLoadOrderTest { assertEquals(listOf("after"), viewModel.uiState.value.collections.map { it.id }) } finally { - // Cancel the VM's scope before Main is reset so a load still in - // flight cannot resume on a missing Main and leak an uncaught - // exception into whichever test runs next. - viewModel.viewModelScope.cancel() + // Cancel AND join the VM's scope before Main is reset: a plain cancel() + // lets an in-flight Ktor call resume on a missing Main and leak an + // uncaught exception into whichever test runs next. + releaseFirst.complete(Unit) + viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() client.close() Dispatchers.resetMain() }

    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